Comments in benilla-app, capture, sound, net, spells and portraits: one line of fact each, no history, no record numbers (#345)
Part of #41: the fourth of five `benilla-app` pieces. This one is capture and its probes, sound, the network layer and remote movement, spells, and portraits. Their comments are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 23,383 comment lines become 9,669. Fixes #35. Fixes #110. Fixes #111. - **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal. - **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them: - The probes' run blocks pointed at tooling outside this repository; each now points at `docs/CONTRIBUTING.md`, "Running it unattended". The protocol examples show placeholders for an account's password. - The dismount kit's name-match string is `SpiritWolf (DONOTRENAME)` (`0x8627bc`); the stun and jump-start vocals are never dispatched, and jump-end finds no kit in the shipped data. - The cast bar's flash and fade step once per `OnUpdate` (`CastingBarFrame.lua:132-147`); there is no 30 Hz normalisation. - vmangos sends the OK cast result before the GO (`Spell.cpp:3681`, `:3715`), and a creature can levitate from a script (`boss_onyxia.cpp:679`). - A live unit whose model is not ready has a blank portrait in the reference (`0x519fcc`); the stand-in path is for a unit with no object. - Docs sitting on the wrong item, removed names, and vmangos line citations checked against the pinned revision. - **Deviation labels.** "Deviation:" now marks only a choice with a reason. An unbuilt behaviour, or one whose reference side is untraced, says so plainly. - **Verification.** Every file strips to identical code, apart from two test literals in `net.rs` that now use placeholder account names, and `cargo fmt --check` is clean. Six second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the code, the stock files, vmangos and the reference. Behaviour differences the rewrite surfaced are filed as their own issues (#319, #324, #329, #330 and #331 among them). String literals are untouched; they are #92. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
co-authored by
Claude Opus 5.5
parent
e2baf94be6
commit
b7a7f88f6b
@@ -1,56 +1,18 @@
|
||||
//! `WOW_DEPTH` — **what depth actually won this pixel, and how far away is it?**
|
||||
//! `WOW_DEPTH`: reads back the depth that won a pixel in the opaque pass, and how far away it is.
|
||||
//!
|
||||
//! The last unread link in B38. There, two surfaces of the Far Watch Post tower trade places: the
|
||||
//! awning is nearer, writes depth, compares `GreaterEqual`, is not discarded, is not culled, keeps
|
||||
//! the same mesh/material/texture every frame, and is submitted into `AlphaMask3d` at a stable draw
|
||||
//! position on every single frame (decisions 0662, 0665, 0667) — and on some frames the plank behind
|
||||
//! it wins the pixel anyway. Every one of those facts was established on the **CPU** side. None of
|
||||
//! them says what value the GPU wrote into the depth buffer, which is the one thing that decides the
|
||||
//! pixel and the one thing we could not read.
|
||||
//! `WOW_DEPTH="<x>,<y>[;<x>,<y>…]"` logs, per frame, the raw reverse-Z value at each screenshot
|
||||
//! pixel and the surface's distance both along the pixel's ray (comparable with `WOW_PICK`'s hit
|
||||
//! distances) and to the camera plane. `WOW_DEPTH_AT=<secs>` (default 20) and
|
||||
//! `WOW_DEPTH_COUNT=<n>` (default 1) shape the sampling like the screenshot burst and the ray pick.
|
||||
//! Pixels are used as given: the depth texture is allocated in physical pixels.
|
||||
//!
|
||||
//! So: `WOW_DEPTH="<x>,<y>[;<x>,<y>…]"` copies the view's depth texture back after the main pass and
|
||||
//! logs, per frame, the raw reverse-Z value at each pixel and **where that puts the surface** — both
|
||||
//! as a distance along the pixel's ray and as the perpendicular distance to the camera plane, because
|
||||
//! those are different numbers and only the first is comparable with a ray cast.
|
||||
//! `WOW_DEPTH_AT=<secs>` (default 20) / `WOW_DEPTH_COUNT=<n>` (default 1) shape the sampling like the
|
||||
//! screenshot burst and the ray pick, so the three line up frame for frame.
|
||||
//! `WOW_DEPTH_QUADS=<bone>[,<bone>…]` (empty value = every quad emitter) samples a grid inside
|
||||
//! each live particle quad's projected corners instead, and logs the fraction that survives the
|
||||
//! depth test and how deep the occluder sits in front, in yards. Frames with no live quad are
|
||||
//! skipped and not counted.
|
||||
//!
|
||||
//! **Run it together with `WOW_PICK` at the same pixels.** This probe answers *what depth won*; the
|
||||
//! ray pick answers *what is standing there* — every hit along the ray with its distance. Reading the
|
||||
//! won distance against the hit distances is what turns a number into "**whose** depth won": if a
|
||||
//! frame's depth matches hit 1's distance rather than hit 0's, the nearer surface never wrote depth
|
||||
//! there, and no argument about draw order or culling survives that. Splitting the two keeps each
|
||||
//! probe single-purpose, and because the geometry is static the pick's distances hold across the
|
||||
//! whole burst.
|
||||
//!
|
||||
//! Coordinates are **screenshot pixels** — the same space `benilla-visual` reports boxes in and
|
||||
//! `WOW_PICK` takes — and here they are used directly, because the depth texture is allocated in
|
||||
//! physical pixels. (`WOW_PICK` has to divide by the window scale factor; its ray cast works in
|
||||
//! logical units. Same input space, different reason.)
|
||||
//!
|
||||
//! ## `WOW_DEPTH_QUADS` — the same reading, taken at a particle quad's OWN pixels
|
||||
//!
|
||||
//! A hand-written pixel list is the wrong instrument for a *moving* subject. B16's eye glow is two
|
||||
//! additive quads a few dozen pixels across, riding an animated bone: name a pixel and by the frame
|
||||
//! the readback lands the quad is somewhere else, and a grid coarse enough to catch it samples the
|
||||
//! head, the ground and the neighbouring unit instead (that mis-measurement is retracted).
|
||||
//!
|
||||
//! `WOW_DEPTH_QUADS=<bone>[,<bone>…]` (empty value = every quad emitter) reads the emitter's **live
|
||||
//! vertex buffer** — the four corners `expand_quads` actually wrote, the ones the GPU is about to
|
||||
//! rasterize — projects them with the frame's own matrices, and samples the depth buffer on a
|
||||
//! bilinear grid *inside that quad*. What comes back is the number the offline model predicts and
|
||||
//! the reference measures: the **surviving area fraction** of the quad under `GreaterEqual`, plus
|
||||
//! how deep the winning surface sits in front of it, in yards. No camera hunting, no pixel guessing,
|
||||
//! and no way to accidentally measure a different surface.
|
||||
//!
|
||||
//! Frames with no live quad are skipped entirely (no copy, no frame counted), so the burst lands on
|
||||
//! the frames where the subject exists rather than on a wall clock.
|
||||
//!
|
||||
//! **MSAA must be off** (`WOW_MSAA=off`). A multisampled depth texture cannot be copied to a buffer
|
||||
//! at all, and there is no single "the" depth at a pixel to report if it could — there are four. The
|
||||
//! probe refuses rather than reporting one of them, because a number that looks like the measurement
|
||||
//! you wanted, taken slightly wrong, is how this bug has already produced four confident wrong
|
||||
//! answers (0667).
|
||||
//! MSAA must be off (`WOW_MSAA=off`): a multisampled depth texture cannot be copied and has no
|
||||
//! single depth per pixel, so the probe refuses.
|
||||
|
||||
use bevy::core_pipeline::core_3d::graph::{Core3d, Node3d};
|
||||
use bevy::ecs::query::QueryItem;
|
||||
@@ -88,9 +50,7 @@ impl Plugin for DepthProbePlugin {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Quad mode arms at once: the subject is a particle pool that does not exist until its
|
||||
// model spawns, and the frame gate below (report only frames that HAVE quads) is a
|
||||
// sharper window than any wall clock the caller could guess.
|
||||
// Quad mode arms at once: frames without a live quad are skipped anyway.
|
||||
let at = std::env::var("WOW_DEPTH_AT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
@@ -128,26 +88,15 @@ impl Plugin for DepthProbePlugin {
|
||||
Render,
|
||||
(
|
||||
prepare_staging.in_set(RenderSystems::PrepareResources),
|
||||
// The node encodes the copy inside the graph; by the time the graph has run and
|
||||
// submitted, the staging buffer holds it and can be mapped.
|
||||
// The node encodes the copy in the graph; after submit, staging maps.
|
||||
read_depth.after(RenderSystems::Render),
|
||||
),
|
||||
)
|
||||
.add_render_graph_node::<ViewNodeRunner<DepthReadbackNode>>(Core3d, DepthReadbackLabel)
|
||||
// Between the opaque pass (which draws `Opaque3d` *and* `AlphaMask3d`) and the
|
||||
// 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. The retained static pass draws BEFORE the opaque pass (2016), so
|
||||
// its walls are in this read too; while it ran after, the graph left the two
|
||||
// unordered and a probe could read a wall-less depth on some frames.
|
||||
//
|
||||
// `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.
|
||||
// After the opaque pass (`Opaque3d` and `AlphaMask3d`), before the transmissive one.
|
||||
// The retained static pass draws before the opaque pass, so its walls are in the read.
|
||||
// `WOW_DEPTH_AFTER=1` copies after the transparent pass instead, to read the depth the
|
||||
// transparent pass itself wrote.
|
||||
.add_render_graph_edges(
|
||||
Core3d,
|
||||
if std::env::var_os("WOW_DEPTH_AFTER").is_some() {
|
||||
@@ -167,8 +116,7 @@ impl Plugin for DepthProbePlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pixels to read and the sampling window. Extracted to the render world as a plain clone (a
|
||||
/// handful of coordinates once per sampled frame).
|
||||
/// The pixels to read and the sampling window.
|
||||
#[derive(Resource, Clone)]
|
||||
struct DepthWatch {
|
||||
pixels: Vec<(u32, u32)>,
|
||||
@@ -192,24 +140,19 @@ struct QuadWatch(Option<Vec<u16>>);
|
||||
#[derive(Clone, Copy)]
|
||||
struct QuadProbe {
|
||||
bone: u16,
|
||||
/// Index within the emitter's mesh — quads are written oldest-first, so 0 is the smallest,
|
||||
/// dimmest particle and the last is the one born this frame (the big, bright one that decides
|
||||
/// whether the glow reads at all).
|
||||
/// Index within the emitter's quads, written oldest-first: the last was born this frame.
|
||||
index: u32,
|
||||
/// The four corners in physical pixels, in `expand_quads`' own vertex order.
|
||||
corners: [Vec2; 4],
|
||||
/// The NDC depth the corners carry. Read from the projected corners, not assumed constant —
|
||||
/// the *spread* is reported, because a billboard that is not a constant-depth plane is a
|
||||
/// different bug wearing the same symptom (`0x7b2a50`'s plain billboard gives all four
|
||||
/// corners one depth).
|
||||
/// The corners' mid NDC depth; `dspread` is their spread, which the reference's plain
|
||||
/// billboard (`0x7b2a50`) keeps at zero.
|
||||
dquad: f32,
|
||||
dspread: f32,
|
||||
/// The quad centre's distance to the camera plane, yards — the unit the burial is reported in.
|
||||
/// The quad centre's distance to the camera plane, yards.
|
||||
viewz: f32,
|
||||
}
|
||||
|
||||
/// This frame's live quads, in `$WOW_DEPTH_QUADS` scope. Empty on every frame in every run that
|
||||
/// did not ask for the mode.
|
||||
/// This frame's live quads, in `$WOW_DEPTH_QUADS` scope.
|
||||
#[derive(Resource, Clone, Default)]
|
||||
struct QuadProbes(Vec<QuadProbe>);
|
||||
|
||||
@@ -220,14 +163,9 @@ impl ExtractResource for QuadProbes {
|
||||
}
|
||||
}
|
||||
|
||||
/// Project every in-scope emitter's live quads into pixels, once a frame.
|
||||
///
|
||||
/// Deliberately reads the **shared effect stream** (0732 P1), not the simulation: these are the
|
||||
/// vertices the draw will consume, so a quad that was mis-built (collapsed, mis-billboarded,
|
||||
/// left at last frame's anchor) is measured as itself rather than as what the sim intended. Runs
|
||||
/// after `BillboardPlace`, the set that fills the stream, so the read is this frame's. An
|
||||
/// emitter's CHILD-pool quads ride its draw records too (same `main_entity`), attributed to the
|
||||
/// parent's bone — the old per-mesh read never saw them at all.
|
||||
/// Project every in-scope emitter's live quads into pixels, once a frame, from the shared effect
|
||||
/// stream `BillboardPlace` fills, so each quad is measured as drawn. Child-pool quads share the
|
||||
/// emitter's draw records and count under its bone.
|
||||
fn collect_quads(
|
||||
watch: Res<QuadWatch>,
|
||||
mut probes: ResMut<QuadProbes>,
|
||||
@@ -245,8 +183,7 @@ fn collect_quads(
|
||||
let Some(vp) = camera.physical_viewport_size() else {
|
||||
return;
|
||||
};
|
||||
// The frame's own matrices, the same pair `depthdump` projects with — so a quad's `dquad` here
|
||||
// and its `dquad` there are the same number, and the two logs can be read against each other.
|
||||
// The frame's own matrices, the same pair `depthdump` projects with.
|
||||
let clip_from_world = projection.get_clip_from_view() * cam_tf.to_matrix().inverse();
|
||||
for (entity, emitter) in &emitters {
|
||||
if !bones.is_empty() && !bones.contains(&emitter.bone()) {
|
||||
@@ -298,8 +235,7 @@ fn collect_quads(
|
||||
}
|
||||
}
|
||||
|
||||
/// `"60,61"` → the bone scope; an empty or absent value means every quad emitter. `None` when the
|
||||
/// variable is unset at all, which is what turns the whole mode off.
|
||||
/// `"60,61"` → the bone scope; an empty value means every quad emitter, an unset one `None`.
|
||||
fn parse_bones(spec: Option<&str>) -> Option<Vec<u16>> {
|
||||
Some(
|
||||
spec?
|
||||
@@ -309,9 +245,7 @@ fn parse_bones(spec: Option<&str>) -> Option<Vec<u16>> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Marks the one view whose depth to read. The depth texture is cached per *render target*, so the
|
||||
/// world camera and the UI camera on the same window share it — but picking a view by viewport size
|
||||
/// would be picking by coincidence, and this bug is made of numbers taken slightly wrong.
|
||||
/// Marks the one view whose depth to read; the UI camera shares the world camera's depth texture.
|
||||
#[derive(Component, Clone, Copy, ExtractComponent)]
|
||||
struct DepthProbeView;
|
||||
|
||||
@@ -323,10 +257,8 @@ struct DepthStaging {
|
||||
height: u32,
|
||||
}
|
||||
|
||||
/// Wait for the sampling window, then mark the world camera: opt its depth texture into `COPY_SRC`
|
||||
/// (the default is `RENDER_ATTACHMENT` alone, which cannot be copied), and refuse the whole probe if
|
||||
/// MSAA is on. Checking the live `Msaa` component rather than `$WOW_MSAA` means the refusal tracks
|
||||
/// what the renderer is actually doing, not what the environment asked for.
|
||||
/// Once the sampling window opens, opt the world camera's depth texture into `COPY_SRC` and mark
|
||||
/// it; the live `Msaa` component being anything but off disables the probe.
|
||||
fn arm(
|
||||
mut watch: ResMut<DepthWatch>,
|
||||
time: ProbeClock,
|
||||
@@ -373,8 +305,7 @@ fn prepare_staging(
|
||||
}
|
||||
let Ok(depth) = view.single() else { return };
|
||||
let size = depth.texture.size();
|
||||
// `Depth32Float` is 4 bytes a texel, and a buffer copy's row stride must be 256-aligned — so the
|
||||
// stride is the aligned *byte* count, which is not the aligned pixel count times four.
|
||||
// `Depth32Float` is 4 bytes a texel; a buffer copy's row stride is 256-byte aligned.
|
||||
let bytes_per_row = RenderDevice::align_copy_bytes_per_row(size.width as usize * 4) as u32;
|
||||
if staging.is_some_and(|s| s.bytes_per_row == bytes_per_row && s.height == size.height) {
|
||||
return;
|
||||
@@ -394,17 +325,8 @@ fn prepare_staging(
|
||||
#[derive(RenderLabel, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
struct DepthReadbackLabel;
|
||||
|
||||
/// Copies the depth texture aside **right after the opaque pass** — the moment the question is about.
|
||||
///
|
||||
/// Placement is the whole correctness of this probe, and getting it wrong is quiet. Read at the end of
|
||||
/// the main pass instead and the numbers come back 5–8 yd at pixels where the ray cast finds the tower
|
||||
/// at 32–43 yd, with a distance even at pixels that have **no** geometry along the ray: by then the
|
||||
/// *transparent* pass has written depth of its own, from something that tracks the camera. Those were
|
||||
/// stable, plausible, reproducible numbers about the wrong thing — which is worse than no probe,
|
||||
/// because it looks like a measurement.
|
||||
///
|
||||
/// `MainOpaquePass` draws `Opaque3d` and then `AlphaMask3d`, which is exactly the pair the B38 fight is
|
||||
/// between, so this is the depth that decided it.
|
||||
/// Copies the depth texture right after the opaque pass. Read at the end of the main pass instead,
|
||||
/// it also holds depth the transparent pass wrote, from something that tracks the camera.
|
||||
#[derive(Default)]
|
||||
struct DepthReadbackNode;
|
||||
|
||||
@@ -424,12 +346,11 @@ impl ViewNode for DepthReadbackNode {
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
// `read_depth` counts the frames; the copy is cheap but not free, so stop with it.
|
||||
// `read_depth` counts the frames; stop copying once the burst is done.
|
||||
if !watch.armed || world.resource::<DepthFramesRead>().0 >= watch.count {
|
||||
return Ok(());
|
||||
}
|
||||
// Quad mode with nothing live this frame: no copy, and `read_depth` will not count it —
|
||||
// the burst spends its frames on the ones that have the subject in them.
|
||||
// Quad mode with nothing live this frame: no copy, and `read_depth` does not count it.
|
||||
if watch.pixels.is_empty()
|
||||
&& world
|
||||
.get_resource::<QuadProbes>()
|
||||
@@ -438,14 +359,13 @@ impl ViewNode for DepthReadbackNode {
|
||||
return Ok(());
|
||||
}
|
||||
let size = depth.texture.size();
|
||||
// `COPY_SRC` only lands on the texture allocated *after* `arm` patched the camera, so the
|
||||
// first armed frame still has the old one. Skip it rather than trip wgpu validation.
|
||||
// `COPY_SRC` lands only on the texture allocated after `arm`, so the first armed frame
|
||||
// still has the old one.
|
||||
if !depth.texture.usage().contains(TextureUsages::COPY_SRC) {
|
||||
return Ok(());
|
||||
}
|
||||
// Depth-stencil formats reject partial copies (wgpu-core `validate_texture_copy_range`), so
|
||||
// the whole texture goes across even though we want a handful of pixels. At one debug frame
|
||||
// each, the simple thing and the correct thing are the same.
|
||||
// the whole texture goes across.
|
||||
render_context.command_encoder().copy_texture_to_buffer(
|
||||
TexelCopyTextureInfo {
|
||||
texture: &depth.texture,
|
||||
@@ -471,7 +391,7 @@ impl ViewNode for DepthReadbackNode {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many frames [`read_depth`] has reported — read by the graph node, which cannot hold a `Local`.
|
||||
/// How many frames [`read_depth`] has reported; a resource because the graph node reads it.
|
||||
#[derive(Resource, Default)]
|
||||
struct DepthFramesRead(u32);
|
||||
|
||||
@@ -491,8 +411,7 @@ fn read_depth(
|
||||
if !watch.armed || read.0 >= watch.count {
|
||||
return;
|
||||
}
|
||||
// Mirrors the node's quad-mode skip: no copy was encoded, so there is nothing to read and this
|
||||
// frame does not spend one of the burst's slots.
|
||||
// Mirrors the node's quad-mode skip: nothing was copied, and the frame is not counted.
|
||||
let quads = quads.map(|q| q.0.clone()).unwrap_or_default();
|
||||
if watch.pixels.is_empty() && quads.is_empty() {
|
||||
return;
|
||||
@@ -507,17 +426,14 @@ fn read_depth(
|
||||
let size = depth.texture.size();
|
||||
let slice = staging.buffer.slice(..);
|
||||
slice.map_async(MapMode::Read, |_| {});
|
||||
// Block. A probe run is not gameplay, and a frame's numbers are worth nothing if they arrive
|
||||
// attached to a later frame's index.
|
||||
// Block, so a frame's numbers never arrive under a later frame's index.
|
||||
if let Err(e) = device.poll(bevy::render::render_resource::PollType::wait_indefinitely()) {
|
||||
error!("depth: poll failed: {e}");
|
||||
return;
|
||||
}
|
||||
let frame = read.0;
|
||||
read.0 += 1;
|
||||
// The projection the frame was actually drawn with, once per burst. Stated rather than assumed:
|
||||
// the reported distances are only as good as this matrix, and an instrument whose calibration is
|
||||
// invisible is one whose numbers cannot be audited later.
|
||||
// The projection the frame was drawn with, once per burst: the distances derive from it.
|
||||
if frame == 0 {
|
||||
info!(
|
||||
"depth: {}x{} view, clip_from_view P₂₂ {} P₃₂ {} P₀₀ {} P₁₁ {}",
|
||||
@@ -543,10 +459,8 @@ fn read_depth(
|
||||
let at = (y * staging.bytes_per_row + x * 4) as usize;
|
||||
let d = f32::from_le_bytes([data[at], data[at + 1], data[at + 2], data[at + 3]]);
|
||||
match view_point(&view_from_clip, ndc_of(x, y, size.width, size.height), d) {
|
||||
// Both distances, because they are not the same number and the difference is 15% at
|
||||
// the edge of the frame: `along` is measured along the ray (what `WOW_PICK` reports,
|
||||
// so this is the one to compare), `z` is the perpendicular distance to the camera
|
||||
// plane (what the depth buffer natively encodes).
|
||||
// Along the ray (what `WOW_PICK` reports) and to the camera plane (what depth
|
||||
// encodes); they differ by 15% at the frame edge.
|
||||
Some(p) => info!(
|
||||
"depth#{frame} ({x}, {y}): {d:.9} = {:.4} yd along the ray ({:.4} yd view z)",
|
||||
p.length(),
|
||||
@@ -563,17 +477,12 @@ fn read_depth(
|
||||
staging.buffer.unmap();
|
||||
}
|
||||
|
||||
/// Samples in each direction across a quad's own area — 16×16 = 256 tests, fine enough that the
|
||||
/// fraction is stable to well under a percent against the offline model's 48×48 and 64×64 grids,
|
||||
/// cheap enough to run on every quad of a pool.
|
||||
/// Samples per side across a quad's own area: 16x16, stable to under a percent.
|
||||
const QUAD_GRID: usize = 16;
|
||||
|
||||
/// Run one quad's depth contest and log it.
|
||||
///
|
||||
/// Reverse-Z, `GreaterEqual` (the particle material takes Bevy's default compare): the fragment
|
||||
/// survives iff `dquad >= dbuffer`. Sampling walks the quad's own `(u, v)` by bilinear interpolation
|
||||
/// of its four projected corners, so every sample is inside the quad by construction — including a
|
||||
/// spun quad, whose screen AABB is up to 41 % larger than the quad itself.
|
||||
/// Run one quad's depth contest and log it. Reverse-Z with Bevy's default `GreaterEqual`: a
|
||||
/// fragment survives iff `dquad >= dbuffer`. Samples interpolate the four projected corners, so
|
||||
/// each lies inside the quad even when it is spun.
|
||||
fn report_quad(
|
||||
frame: u32,
|
||||
q: &QuadProbe,
|
||||
@@ -583,8 +492,7 @@ fn report_quad(
|
||||
height: u32,
|
||||
) {
|
||||
let (mut passed, mut total) = (0usize, 0usize);
|
||||
// Reverse-Z: the LARGEST buffer value is the NEAREST surface, so `dmax` is the closest thing
|
||||
// standing in the quad's way and `dmin` the furthest.
|
||||
// Reverse-Z: `dmax` is the nearest occluder, `dmin` the furthest.
|
||||
let (mut dmin, mut dmax) = (f32::MAX, f32::MIN);
|
||||
let mut cleared = 0usize;
|
||||
for iy in 0..QUAD_GRID {
|
||||
@@ -619,8 +527,7 @@ fn report_quad(
|
||||
);
|
||||
return;
|
||||
}
|
||||
// `dquad` and the buffer values live on the same reverse-Z curve, so the ratio converts either
|
||||
// to yards without re-deriving the projection: view z scales as 1/d.
|
||||
// On the reverse-Z curve view z scales as 1/d, so the ratio to `dquad` converts to yards.
|
||||
let yd = |d: f32| q.viewz * q.dquad / d;
|
||||
let (near, far) = (yd(dmax), yd(dmin));
|
||||
info!(
|
||||
@@ -648,17 +555,8 @@ fn ndc_of(x: u32, y: u32, width: u32, height: u32) -> Vec2 {
|
||||
)
|
||||
}
|
||||
|
||||
/// Unproject a pixel's depth back to where it is in **view space**, in yards.
|
||||
///
|
||||
/// It has to be the whole point, not just the depth: a depth value alone linearises to the distance
|
||||
/// to the camera *plane*, while a ray cast measures along the *ray*, and off-axis those differ by
|
||||
/// `1/cos θ` — 15% at the edge of a 45° frame, six yards at this bug's range. Comparing the two
|
||||
/// without converting is the same axis mix-up that mis-measured B38's surface gap twice (0665), and
|
||||
/// it is 100× the gap the readback exists to resolve. So unproject the actual pixel and hand back the
|
||||
/// point; the caller reports both lengths.
|
||||
///
|
||||
/// Going through the inverse of the live matrix, rather than a reverse-Z formula, means the reading
|
||||
/// cannot silently disagree with the projection the frame was drawn with.
|
||||
/// Unproject a pixel's depth through the live matrix to its view-space point, in yards. The whole
|
||||
/// point is needed: off axis, the ray length exceeds the plane distance by `1/cos θ`.
|
||||
fn view_point(view_from_clip: &Mat4, ndc: Vec2, d: f32) -> Option<Vec3> {
|
||||
let p = *view_from_clip * Vec4::new(ndc.x, ndc.y, d, 1.0);
|
||||
// Behind the camera or at infinity (a reverse-Z clear reads 0 ⇒ w 0) means nothing drew here.
|
||||
@@ -667,8 +565,7 @@ fn view_point(view_from_clip: &Mat4, ndc: Vec2, d: f32) -> Option<Vec3> {
|
||||
.filter(|v| v.is_finite() && v.z < 0.0)
|
||||
}
|
||||
|
||||
/// `"x,y;x,y"` → pixels. Malformed pairs are dropped with a warning rather than failing the run:
|
||||
/// a typo in one coordinate of a list of eight should not cost a capture.
|
||||
/// `"x,y;x,y"` → pixels; a malformed pair is dropped with a warning.
|
||||
fn parse_pixels(spec: &str) -> Vec<(u32, u32)> {
|
||||
spec.split(';')
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
@@ -729,7 +626,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn off_axis_the_ray_is_longer_than_the_perpendicular_distance() {
|
||||
// The bug this test exists for: reporting view-space z as if it were the ray-cast distance.
|
||||
// Straight ahead the two agree; at the frame edge they must not.
|
||||
let inv = proj().inverse();
|
||||
let d = depth_of(46.0);
|
||||
@@ -759,9 +655,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_awning_and_the_plank_are_thousands_of_ulps_apart() {
|
||||
// The whole instrument rests on this: at the B38 pin the two surfaces are 1.4 cm apart
|
||||
// perpendicular, and a readback can only name the winner if that gap survives `f32`.
|
||||
// If this ever fails, the probe cannot answer the question it exists for.
|
||||
// Two measured surfaces 1.4 cm apart perpendicular: the readback names the winner only if
|
||||
// that gap survives `f32`.
|
||||
let (awning, plank) = (46.0253f32, 46.0897f32);
|
||||
let (da, dp) = (depth_of(awning), depth_of(plank));
|
||||
let ulps = ((da.to_bits() as i64) - (dp.to_bits() as i64)).abs();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,5 @@
|
||||
//! The `fxview` fixture's **driver** — the half of the effect-viewer instrument that stands the
|
||||
//! subject up in the world (the request, its knobs and the camera are [`super`]'s). It lives here
|
||||
//! because a fixture's driver belongs with its fixture: `entities::spell_fx` is gameplay, and
|
||||
//! gameplay never depends on the capture harness.
|
||||
//! The `fxview` fixture's driver: stands the effect-viewer's subject up in the world; the
|
||||
//! request, its knobs and the camera are [`super`]'s.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -15,20 +13,15 @@ use crate::entities::spell_fx::{
|
||||
attach_effect_visuals, EffectHost, FxMaterials, FxTintAnims, SpellFx, FALLBACK_SPAN,
|
||||
};
|
||||
|
||||
/// The `fxview` unit lane's synthetic guid (`WOW_FX_DISPLAY`) — a high-word `0xF130` creature guid
|
||||
/// like the wire's, in a serverless capture where nothing else claims one.
|
||||
/// The unit lane's synthetic guid (`WOW_FX_DISPLAY`): a wire-shaped `0xF130` creature guid.
|
||||
const FXVIEW_UNIT_GUID: u64 = (0xF130u64 << 48) | 0xFC0FEE;
|
||||
|
||||
/// The `fxview` GameObject lane's synthetic guid (`WOW_FX_GO`) — the wire's `0xF110` high word,
|
||||
/// which is what `crate::net` reads a GO's identity out of.
|
||||
/// The GameObject lane's synthetic guid (`WOW_FX_GO`): the `0xF110` high word `crate::net` reads
|
||||
/// a GO's identity from.
|
||||
const FXVIEW_GO_GUID: u64 = (0xF110u64 << 48) | 0xFC0FEE;
|
||||
|
||||
/// Drive the `fxview` capture fixture (see [`super::FxViewRequest`]) — the effect-
|
||||
/// viewer instrument: once the capture driver arms it (scene settled), spawn a root at the
|
||||
/// fixture point, create the model's [`SpellFx`] cache entry, attach the full visual set
|
||||
/// through the same [`attach_effect_visuals`] body the game uses, and (for missiles) fly the
|
||||
/// root along its facing so trails extend. Inert outside fxview captures (the request resource
|
||||
/// only exists then).
|
||||
/// Drives the `fxview` fixture once armed: spawns a root, attaches the model through the game's
|
||||
/// own [`attach_effect_visuals`], and flies a missile along its facing so trails extend.
|
||||
pub(crate) fn drive_fx_view(
|
||||
req: Option<Res<FxViewRequest>>,
|
||||
state: Option<ResMut<FxViewState>>,
|
||||
@@ -58,11 +51,8 @@ pub(crate) fn drive_fx_view(
|
||||
};
|
||||
let root = *state.root.get_or_insert_with(|| {
|
||||
let mut pos = benilla_assets::coords::wow_to_bevy(req.at.unwrap_or(FXVIEW_POS));
|
||||
// `WOW_FX_DISPLAY`: the UNIT lane. Spawn the live component set a streamed creature gets
|
||||
// (`net::apply`'s, the same one the `vplates` fixture's wolf uses) and let the ordinary
|
||||
// unit pipeline build the model — so what this shoots is a creature, not a model, and
|
||||
// every unit-only term (tag alpha, the fade gate, anim LOD, the emitters' sequence host)
|
||||
// is in the picture. Always seated on the terrain: a creature stands on the ground.
|
||||
// `WOW_FX_DISPLAY`, the unit lane: the component set `net::apply` gives a streamed
|
||||
// creature, so the unit pipeline builds it with every unit-only term; seated on terrain.
|
||||
if let Some(display) = req.display {
|
||||
if let Some(hit) = spatial.cast_ray(
|
||||
pos,
|
||||
@@ -87,7 +77,7 @@ pub(crate) fn drive_fx_view(
|
||||
(22, 100), // UNIT_FIELD_HEALTH
|
||||
(28, 100), // UNIT_FIELD_MAXHEALTH
|
||||
(34, 60), // UNIT_FIELD_LEVEL
|
||||
(35, 35), // UNIT_FIELD_FACTIONTEMPLATE — friendly, so no combat pose
|
||||
(35, 35), // UNIT_FIELD_FACTIONTEMPLATE: friendly, no combat pose
|
||||
],
|
||||
)),
|
||||
Transform::from_translation(pos)
|
||||
@@ -96,12 +86,9 @@ pub(crate) fn drive_fx_view(
|
||||
))
|
||||
.id();
|
||||
}
|
||||
// `WOW_FX_GO`: the GAMEOBJECT lane. A placed trap/door/chest reaches the screen through
|
||||
// `crate::go_anim`'s state machine (`0x5f3cb0`), never through the effect pool, so this is
|
||||
// the only lane that reproduces one. The descriptor carries the three fields the machine
|
||||
// reads — display, TYPE_ID (the `go_animates` gate) and STATE (the substate) — and
|
||||
// nothing else, so an unset knob renders exactly what an omitted wire field renders.
|
||||
// Always seated on the terrain: a GO stands on the ground.
|
||||
// `WOW_FX_GO`, the GameObject lane: a placed GO animates through `crate::go_anim`'s state
|
||||
// machine (`0x5f3cb0`), not the effect pool. The descriptor carries only display, TYPE_ID
|
||||
// and STATE, the fields the machine reads; seated on terrain.
|
||||
if let Some(display) = req.go {
|
||||
if let Some(hit) = spatial.cast_ray(
|
||||
pos,
|
||||
@@ -133,9 +120,7 @@ pub(crate) fn drive_fx_view(
|
||||
))
|
||||
.id();
|
||||
}
|
||||
// `WOW_FX_GROUND=1`: seat the fixture ON the terrain — a ground-anchored effect's flat
|
||||
// quads render as projected surface decals and need ground inside their vertical slab.
|
||||
// The scene settled before arming, so the streamed tile's collider is there to hit.
|
||||
// `WOW_FX_GROUND=1`: a ground-anchored effect's quads decal onto terrain inside their slab.
|
||||
if req.ground {
|
||||
if let Some(hit) = spatial.cast_ray(
|
||||
pos,
|
||||
@@ -156,8 +141,7 @@ pub(crate) fn drive_fx_view(
|
||||
))
|
||||
.id()
|
||||
});
|
||||
// A missile only trails in motion: fly the root along its model-forward (local −Z).
|
||||
// WOW_FX_TURN keeps the fixture rotating — the attached-model heading-since-birth fan.
|
||||
// A missile only trails in motion: fly along model-forward (local -Z); `WOW_FX_TURN` spins it.
|
||||
if req.fly > 0.0 || req.turn != 0.0 {
|
||||
if let Ok(mut t) = transforms.get_mut(root) {
|
||||
let fwd = t.rotation * -Vec3::Z;
|
||||
@@ -168,11 +152,8 @@ pub(crate) fn drive_fx_view(
|
||||
}
|
||||
}
|
||||
}
|
||||
// The one-pass reap (the game's completion callback, `fx_attach`): a discrete kit
|
||||
// instance dies at exactly one pass of sequence 0 and its emitters DRAIN — the fixture
|
||||
// mirrors it so a capture past the span shows the truth. `WOW_FX_HOLD=1` previews a
|
||||
// persistent hold instead (reaped by its spell edge in game, so no clock here).
|
||||
// The one-pass reap is an effect-instance rule; a unit — or a placed GameObject — stands there.
|
||||
// The game's one-pass reap (`fx_attach`): a one-shot kit instance dies after one pass of
|
||||
// sequence 0. Not for `WOW_FX_HOLD=1` (reaped by its spell in game) or a unit or GO.
|
||||
if !req.hold && !state.expired && req.display.is_none() && req.go.is_none() {
|
||||
if let Some(at) = state.attached_at {
|
||||
let span = fx
|
||||
@@ -187,9 +168,7 @@ pub(crate) fn drive_fx_view(
|
||||
}
|
||||
}
|
||||
if state.attached_at.is_none() {
|
||||
// The unit and GameObject lanes have no attach step — the entity pipeline builds the model
|
||||
// on its own. Their "age" is therefore seconds since the entity appeared, which is what the
|
||||
// animation phase is measured in.
|
||||
// The unit and GO lanes have no attach step: their age counts from the entity's spawn.
|
||||
if req.display.is_some() || req.go.is_some() {
|
||||
state.attached_at = Some(time.elapsed_secs());
|
||||
return;
|
||||
@@ -204,14 +183,11 @@ pub(crate) fn drive_fx_view(
|
||||
root,
|
||||
&fx.models[&req.model_path],
|
||||
time.elapsed_secs(),
|
||||
true, // the fixture plants at a world point — ground quads decal onto the terrain
|
||||
// The fixture previews kit effects, which are attached models — but it hangs on
|
||||
// nothing (there is no host model in a preview), so its pool keeps the drain.
|
||||
true, // planted at a world point: ground quads decal onto the terrain
|
||||
// No host model in a preview, so the pool keeps the drain.
|
||||
EffectHost { parent: None },
|
||||
// `WOW_FX_HOLD=1` previews a PERSISTENT instance, so it must show the persistent
|
||||
// lifecycle — birth then `Hold` — or the instrument would report a freeze the game
|
||||
// does not have. Without it the fixture previews a one-shot, which runs its birth once
|
||||
// and is reaped by the fixture's own span clock below.
|
||||
// `WOW_FX_HOLD=1` shows the persistent lifecycle (birth then `Hold`); otherwise a
|
||||
// one-shot, reaped by the span clock above.
|
||||
Some(if req.hold {
|
||||
FxStage::State
|
||||
} else {
|
||||
@@ -225,7 +201,7 @@ pub(crate) fn drive_fx_view(
|
||||
},
|
||||
&ibps,
|
||||
&mut palettes,
|
||||
None, // the fixture previews a kit effect on its model's own `Stand`
|
||||
None, // a kit effect on its model's own `Stand`
|
||||
) {
|
||||
state.attached_at = Some(time.elapsed_secs());
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
//! The LIVE probe shot — one screenshot (or an adjacent-frame burst) of a NORMAL connected run,
|
||||
//! with the two validity gates that make its output *evidence*: the death gate (a dead/ghost
|
||||
//! avatar renders the whole world through the death filter) and the subject gate
|
||||
//! (`WOW_SHOT_REQUIRE` — a frame that cannot contain the reported subject is not a measurement of
|
||||
//! it). Split from `probes` when the gates outgrew it; the sibling probe one-shots stay there.
|
||||
//! The live probe shot: a screenshot or adjacent-frame burst of a connected run, gated on a live
|
||||
//! avatar (a ghost renders through the death filter) and on the subject being in frame.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::view::screenshot::{save_to_disk, Screenshot};
|
||||
@@ -14,43 +11,22 @@ use crate::names::NameCache;
|
||||
use crate::net::{Guid, NetCommands, NetEntity, ObjectStore, SelfPlayer};
|
||||
use benilla_world::view::WorldCamera;
|
||||
|
||||
/// How far the subject gate's WARN looks when listing what *is* nearby — wide enough to catch the
|
||||
/// "right room, wrong wing" mis-aim even when `WOW_SHOT_REQUIRE_DIST` is tight.
|
||||
/// How far the subject gate's warning looks when listing what is nearby.
|
||||
const REQUIRE_NEARBY_RANGE: f32 = 100.0;
|
||||
|
||||
/// Seconds between subject-gate WARNs while waiting. Frequent enough to be read live, rare enough
|
||||
/// that a log grep isn't wading through it.
|
||||
/// Seconds between subject-gate warnings while waiting.
|
||||
const REQUIRE_REWARN_SECS: f32 = 5.0;
|
||||
|
||||
/// The LIVE probe shot (`WOW_LIVE_SHOT=<png>`, delay via `WOW_LIVE_SHOT_AT` seconds, default 12):
|
||||
/// one screenshot of the primary window on a NORMAL connected run — the "what does the live scene
|
||||
/// actually look like" instrument (server GameObjects, event spawns, NPCs — everything the
|
||||
/// server-less harness deliberately excludes). The app keeps running after the save; pair with
|
||||
/// `WOW_USER`/`WOW_CHAR` and an outer `timeout` for unattended probes. Unlike
|
||||
/// [`super::CapturePlugin`], nothing is pinned — the shot shows the scene as a player would see it.
|
||||
/// The live probe shot (`WOW_LIVE_SHOT=<png>`, after `WOW_LIVE_SHOT_AT` seconds, default 12): the
|
||||
/// primary window of a connected run, nothing pinned; the app keeps running after the save.
|
||||
///
|
||||
/// **`WOW_LIVE_SHOT_COUNT=<n>` makes it a burst** — `n` shots written as `<stem>-000.png`,
|
||||
/// `<stem>-001.png`, … , spaced by `WOW_LIVE_SHOT_EVERY` seconds (**default 0 = one per frame**,
|
||||
/// i.e. *adjacent* frames). One shot still writes the bare path, so every existing invocation is
|
||||
/// unchanged.
|
||||
/// `WOW_LIVE_SHOT_COUNT=<n>` makes a burst of `<stem>-000.png`, ... spaced `WOW_LIVE_SHOT_EVERY`
|
||||
/// seconds (default 0, adjacent frames), so a flicker from a parked camera (`WOW_PROBE_CAM`) can
|
||||
/// be measured with `benilla-visual flicker <dir>`.
|
||||
///
|
||||
/// The burst exists because **a flicker is a temporal artefact and a single frame cannot show one**
|
||||
/// (decision 0653). "This object's textures flicker" was un-diagnosable with the instruments we had:
|
||||
/// the reporter's stills prove nothing, and grading a live run by eye is exactly the loop rule 7
|
||||
/// forbids. Adjacent frames from a *parked* camera ([`crate::player`]'s `WOW_PROBE_CAM`) turn it
|
||||
/// into arithmetic: `benilla-visual flicker <dir>` collapses the burst to the envelope of what
|
||||
/// would not hold still, and the lit pixels are the defect.
|
||||
///
|
||||
/// **`WOW_SHOT_REQUIRE=<name>` is the SUBJECT gate** (substring, case-insensitive; range via
|
||||
/// `WOW_SHOT_REQUIRE_DIST`, default 60): no shot fires until a unit with that name is inside the
|
||||
/// viewport and range — the same contract as the death gate below, because a frame that cannot
|
||||
/// contain the reported subject is not a measurement of it either. Ten banshee runs were once read
|
||||
/// as "can't reproduce" with the banshee never in frame, and a voidwalker crop-negative was a model
|
||||
/// that had wandered out of a fixed window (decision 0705). While the subject is missing the gate
|
||||
/// WARNs with what *is* nearby — names and distances — so a mis-aimed run re-aims itself instead of
|
||||
/// ending as a silent blank; a run that exits with 0 shots written names its reason in the log.
|
||||
/// The unit's feet anchor decides "in frame", and in frame does not mean unoccluded — the gate
|
||||
/// closes the aim trap, not line-of-sight.
|
||||
/// `WOW_SHOT_REQUIRE=<name>` (case-insensitive substring; range `WOW_SHOT_REQUIRE_DIST`, default
|
||||
/// 60) holds every shot until a unit so named has its feet in the viewport and in range, and warns
|
||||
/// with what is nearby while it waits. In frame does not mean unoccluded.
|
||||
pub(crate) struct LiveShotPlugin;
|
||||
|
||||
impl Plugin for LiveShotPlugin {
|
||||
@@ -89,9 +65,7 @@ impl Plugin for LiveShotPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`LiveShotPlugin`] state: the output path, how many shots the burst wants, their spacing
|
||||
/// (`0` = one per frame), how many have gone out, and when the next one may — seeded to
|
||||
/// `WOW_LIVE_SHOT_AT`, so the first-fire delay and the burst spacing are the same clock.
|
||||
/// [`LiveShotPlugin`] state; `next_at` starts at `WOW_LIVE_SHOT_AT`, then steps by `every`.
|
||||
#[derive(Resource)]
|
||||
struct LiveShot {
|
||||
out: String,
|
||||
@@ -104,8 +78,7 @@ struct LiveShot {
|
||||
require_dist: f32,
|
||||
}
|
||||
|
||||
/// `stem-007.png` for shot 7 of a burst — the bare path when the burst is one shot, so the
|
||||
/// single-shot filename (which existing probes and docs name literally) never moves.
|
||||
/// `stem-007.png` for shot 7 of a burst; the bare path for a single shot.
|
||||
fn burst_path(out: &str, index: u32, count: u32) -> String {
|
||||
if count <= 1 {
|
||||
return out.to_string();
|
||||
@@ -116,16 +89,8 @@ fn burst_path(out: &str, index: u32, count: u32) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire the live screenshot(s) once the startup delay has elapsed — one per frame by default, so a
|
||||
/// burst samples *adjacent* frames and a per-frame instability has nowhere to hide.
|
||||
///
|
||||
/// **Refuses to fire while the avatar is dead or a ghost.** `preflight` already reports it, but a
|
||||
/// single WARN in a four-hundred-line log is not a gate: a ghost renders the whole world through the
|
||||
/// death filter, so every image in the burst is a desaturated wash and *every* measurement correlated
|
||||
/// against it — pixel series, hotspot runs, depth readback, the phase census — silently describes the
|
||||
/// filter instead of the scene. That cost a whole B38 session: eight bursts and three retracted
|
||||
/// mechanisms measured on a corpse, because the images still looked like a plausible frame. A loud
|
||||
/// no-op is worth far more than a burst that has to be recognised as garbage after the fact.
|
||||
/// Fires the live shots once the delay has elapsed. Refuses while the avatar is dead or a ghost:
|
||||
/// the world then renders through the death filter, and every frame would measure the filter.
|
||||
#[allow(clippy::type_complexity)] // one Bevy system's full input set
|
||||
fn fire_live_shot(
|
||||
mut shot: ResMut<LiveShot>,
|
||||
@@ -163,11 +128,8 @@ fn fire_live_shot(
|
||||
return;
|
||||
}
|
||||
}
|
||||
// The SUBJECT gate — same contract as the death gate above: a frame that cannot contain the
|
||||
// reported subject is not a measurement of it, and a "clean" burst of such frames is the
|
||||
// costliest false negative a live run produces (decision 0705). Every shot of a burst
|
||||
// re-passes the gate, so a subject that wanders off mid-burst pauses the burst instead of
|
||||
// padding it with blanks.
|
||||
// The subject gate: every shot of a burst re-passes it, so a subject that wanders off pauses
|
||||
// the burst.
|
||||
if let Some(want) = shot.require.as_deref() {
|
||||
let Ok((cam, cam_pose)) = camera.single() else {
|
||||
return;
|
||||
@@ -175,13 +137,11 @@ fn fire_live_shot(
|
||||
let cam_tf = GlobalTransform::from(*cam_pose);
|
||||
let viewport = cam.logical_viewport_size().unwrap_or(Vec2::ZERO);
|
||||
let want_lc = want.to_lowercase();
|
||||
// One pass: find the nearest in-frame match, and keep the neighbourhood for the WARN —
|
||||
// `resolve` sends the name query on a miss, so just scanning fills the cache within frames.
|
||||
// `resolve` sends the name query on a miss, so scanning fills the cache within frames.
|
||||
let mut found: Option<(String, f32, Vec2)> = None;
|
||||
let mut nearby: Vec<(f32, String)> = Vec::new();
|
||||
for (guid, tf) in &subjects {
|
||||
// Only name-bearing families: a GameObject can never resolve (its name rides its own
|
||||
// unmodeled query), so it would sit in the listing as noise and never match the gate.
|
||||
// Only name-bearing families: a GameObject's name rides its own query, not this cache.
|
||||
if !guid::is_player(guid.0)
|
||||
&& !guid::is_creature_or_pet(guid.0)
|
||||
&& guid::pet_number(guid.0).is_none()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,7 @@
|
||||
//! `WOW_PHASE` — **what did the renderer actually do with this batch, frame by frame?**
|
||||
//!
|
||||
//! Every other flicker instrument we have reads the *scene*: `benilla-visual` says which pixels would
|
||||
//! not hold still, `WOW_PICK` says what geometry stands at them and whether it is visible. B38
|
||||
//! exhausted that layer. There, the awning is nearer than the plank behind it, writes depth, compares
|
||||
//! `GreaterEqual`, is not discarded and is not culled, and keeps the same mesh, material and texture
|
||||
//! on every single frame — and on half the frames the plank behind it wins the pixel anyway
|
||||
//! (decisions 0662, 0665). Under those facts that is impossible, so one of them stops being true
|
||||
//! somewhere *after* the scene and before the draw, and nothing we had could see into that gap.
|
||||
//!
|
||||
//! So: `WOW_PHASE=<uniqueId>` watches every model batch of one placed object and reports, per frame,
|
||||
//! which render phase each batch landed in and **where in the draw order** it sits. Absent from every
|
||||
//! phase means it was never submitted, which no amount of looking at pixels can distinguish from
|
||||
//! losing a depth test. Present, but at a different position than last frame, means the draw order
|
||||
//! moved — and for two surfaces that tie, draw order *is* the result.
|
||||
//!
|
||||
//! `WOW_PHASE_AT=<secs>` (default 20) / `WOW_PHASE_COUNT=<n>` (default 1) shape the sampling the same
|
||||
//! way the screenshot burst and the ray pick do, so a phase log and a frame stack line up frame for
|
||||
//! frame and "was it drawn?" can be read against "was this frame dim or bright?".
|
||||
//!
|
||||
//! Only the **main** 3D view's phases are read. Shadow and prepass views bin the same entities and
|
||||
//! would triple every line for nothing; a defect in *those* would show up as the wrong shadow, which
|
||||
//! is a different report.
|
||||
//! `WOW_PHASE=<uniqueId>`: logs, per frame, which render phase each model batch of one placed
|
||||
//! object landed in and where in the draw order it sits; absent from every phase means never
|
||||
//! submitted. `WOW_PHASE_AT=<secs>` (default 20) and `WOW_PHASE_COUNT=<n>` (default 1) shape the
|
||||
//! sampling like the screenshot burst and the ray pick. Only the main 3D view's phases are read.
|
||||
|
||||
use bevy::core_pipeline::core_3d::{AlphaMask3d, Opaque3d, Transparent3d};
|
||||
use bevy::prelude::*;
|
||||
@@ -41,16 +22,9 @@ pub(crate) struct PhaseProbePlugin;
|
||||
impl Plugin for PhaseProbePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
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).
|
||||
// `WOW_PHASE=particles[:<bone>,…]` — the bone list is the ARMING key, not a filter: the
|
||||
// watch list is collected once and then held, and a world scene is full of other emitters
|
||||
// (torches, braziers) that go live long before a fixture subject spawns. Naming the bones
|
||||
// the question is about (`60,61` = the voidwalker's eyes; `44,45,46` = the wisp's three
|
||||
// streamers) makes the probe wait for THAT effect instead of latching onto the first live
|
||||
// one it sees. Ribbon trails arm and report on the same bone key as emitters.
|
||||
// `WOW_PHASE=particles[:<bone>,…]` follows every live particle emitter and ribbon trail
|
||||
// instead. The bone list is an arming key, not a filter: the watch list is collected once,
|
||||
// when every named bone is live, so unrelated emitters that go live first do not latch it.
|
||||
let spec = raw.as_deref().map(str::trim);
|
||||
let particles = spec.is_some_and(|v| v == "particles" || v.starts_with("particles:"));
|
||||
let bones: Vec<u16> = spec
|
||||
@@ -94,14 +68,12 @@ impl Plugin for PhaseProbePlugin {
|
||||
warn!("phase: no render app — inert");
|
||||
return;
|
||||
};
|
||||
// After `PhaseSort`: binning and sorting are both settled, so membership and order are final.
|
||||
// After `PhaseSort`, so membership and order are final.
|
||||
render_app.add_systems(Render, report_phases.after(RenderSystems::PhaseSort));
|
||||
}
|
||||
}
|
||||
|
||||
/// The watch list, and the sampling window. Lives in the main world; the render half reads it through
|
||||
/// the extract boundary as a plain clone (it is a handful of entities once per sampled frame, so the
|
||||
/// cheap thing and the correct thing are the same).
|
||||
/// The watch list and the sampling window, cloned into the render world.
|
||||
#[derive(Resource, Clone)]
|
||||
struct PhaseWatch {
|
||||
/// The placement `uniqueId` whose batches to follow.
|
||||
@@ -112,11 +84,8 @@ struct PhaseWatch {
|
||||
bones: Vec<u16>,
|
||||
at: f32,
|
||||
count: u32,
|
||||
/// The watched batches: main-world entity, its WMO batch order, how it draws, and (particles
|
||||
/// mode) its mesh + texture assets — so the render half can say what the GPU-side copies hold
|
||||
/// at draw time.
|
||||
batches: Vec<WatchedBatch>,
|
||||
/// Have we found the object and started sampling?
|
||||
/// Set once the object is found and sampling has started.
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
@@ -127,8 +96,8 @@ 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.
|
||||
/// One watched batch: entity, WMO batch order (or emitter bone), how it draws, and in particles
|
||||
/// mode the mesh and texture asset ids the render half resolves to GPU-side state.
|
||||
type WatchedBatch = (
|
||||
Entity,
|
||||
i32,
|
||||
@@ -136,15 +105,11 @@ type WatchedBatch = (
|
||||
Option<(Option<AssetId<Mesh>>, AssetId<Image>)>,
|
||||
);
|
||||
|
||||
/// Find the object's batch entities once it has streamed in. Re-run until it is found, then hold:
|
||||
/// the set is per-placement and does not change, and re-collecting every frame would make the
|
||||
/// watch list itself a moving part of the measurement.
|
||||
/// Find the object's batch entities once it has streamed in, then hold the list.
|
||||
fn collect_batches(
|
||||
mut watch: ResMut<PhaseWatch>,
|
||||
time: ProbeClock,
|
||||
// `WorldObject` rides on **each submesh entity**, not on a parent that holds them as children —
|
||||
// the same shape `WOW_PICK` relies on, where a single ray hit carries both its object identity
|
||||
// and its batch. So the watch list is a filter over those entities, not a child walk.
|
||||
// `WorldObject` rides on each submesh entity, not on a parent, so this filters, not walks.
|
||||
parts: Query<(
|
||||
Entity,
|
||||
&WorldObject,
|
||||
@@ -161,9 +126,8 @@ fn collect_batches(
|
||||
.iter()
|
||||
.filter(|(_, obj, _, _)| obj.id == want)
|
||||
.map(|(e, _, part, mat)| {
|
||||
// The WMO authored batch order rides in the material's `sun_scale.y` (`model_render`),
|
||||
// which is also how `WOW_PICK` names a batch — so a phase line and a pick line describe
|
||||
// the same surface by the same number.
|
||||
// The WMO batch order rides in the material's `sun_scale.y` (`model_render`), the
|
||||
// number `WOW_PICK` names a batch by.
|
||||
let order = materials
|
||||
.get(&mat.0)
|
||||
.map_or(-1, |m| m.extension.sun_scale.y as i32);
|
||||
@@ -184,27 +148,10 @@ fn collect_batches(
|
||||
watch.armed = true;
|
||||
}
|
||||
|
||||
/// The `particles` mode's collector: every LIVE **effect** mesh — particle-emitter quad clouds and
|
||||
/// ribbon trails alike — 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) — **and every model batch in
|
||||
/// the scene alongside them**.
|
||||
///
|
||||
/// Ribbons are here because they are the same kind of thing under the same law: a trail is one of
|
||||
/// its owner model's emitters, drawn in that model's own post-batch bracket, and in our renderer it
|
||||
/// is another `NoFrustumCulling` mesh in the one distance-sorted transparent list. A probe that saw
|
||||
/// only quads could say "the eye glow is ordered right" while a wisp's streamers were still
|
||||
/// interleaved with the wisp.
|
||||
///
|
||||
/// The emitters alone answer "was it submitted?". They cannot answer B16's actual question, which
|
||||
/// is *relative*: a particle quad and its own model's blend batches share one `Transparent3d` list
|
||||
/// sorted back-to-front, so which of the two draws last is what decides whether the eyes survive.
|
||||
/// That is only readable if both sides are in the same report, on the same frame, with the sort
|
||||
/// distances that produced the order — so this list holds both, and the batch column says which.
|
||||
///
|
||||
/// One-shot like [`collect_batches`], but gated on a live emitter rather than the clock alone: in
|
||||
/// a fixture capture the subject spawns after streaming settles, so a fixed `WOW_PHASE_AT` either
|
||||
/// fires before it exists or after the shot. Retrying until the pool is live pins the window to
|
||||
/// the subject instead of to a wall-clock guess.
|
||||
/// The `particles` mode's collector: every live particle emitter and ribbon trail, labelled by
|
||||
/// emitter bone and blend, plus the model batches near them. Effects and their model's blend
|
||||
/// batches share one back-to-front `Transparent3d` list, so which draws last decides what shows.
|
||||
/// One-shot, armed once the named bones are live rather than on the clock alone.
|
||||
fn collect_emitters(
|
||||
mut watch: ResMut<PhaseWatch>,
|
||||
time: ProbeClock,
|
||||
@@ -232,14 +179,13 @@ fn collect_emitters(
|
||||
ent,
|
||||
i32::from(def.bone),
|
||||
format!("EMIT {:?}(pool {})", def.blend, e.live()),
|
||||
// No mesh asset since the shared effect lane (0732 P1) — the texture is the
|
||||
// GPU-side link left to check; the vertices ride the lane's one buffer.
|
||||
// The vertices ride the shared effect lane's one buffer; only the texture is
|
||||
// a per-emitter GPU asset.
|
||||
Some((None, e.texture().id())),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
// A trail with fewer than two edges builds an empty mesh, so it is submitted but draws
|
||||
// nothing — the same "live" bar the emitters use (`live() > 0`).
|
||||
// A trail with fewer than two edges builds an empty mesh: submitted, but it draws nothing.
|
||||
found.extend(trails.iter().filter_map(|(ent, t, _)| {
|
||||
let (blend, edges) = t.shape();
|
||||
(edges > 0).then(|| {
|
||||
@@ -254,8 +200,7 @@ fn collect_emitters(
|
||||
if found.is_empty() {
|
||||
return;
|
||||
}
|
||||
// The arming key (see the plugin's parse): every named bone must be live, or this frame is too
|
||||
// early and the list would freeze around the wrong pool.
|
||||
// The arming key: every named bone must be live before the list freezes.
|
||||
if !watch
|
||||
.bones
|
||||
.iter()
|
||||
@@ -263,17 +208,13 @@ fn collect_emitters(
|
||||
{
|
||||
return;
|
||||
}
|
||||
// The model side of the comparison, scoped to the batches that could actually overlap this
|
||||
// pool on screen. A world scene holds tens of thousands of model batches and all of them are
|
||||
// in the same sorted list; the ones that matter are the ones standing where the cloud is, so
|
||||
// the scope is a radius around the watched anchors rather than "every model in the world".
|
||||
// The model batches within this radius of a watched anchor, the ones that can overlap it.
|
||||
const NEAR: f32 = 6.0;
|
||||
let anchors: Vec<Vec3> = emitters
|
||||
.iter()
|
||||
.filter(|(_, e)| e.live() > 0)
|
||||
.map(|(_, e)| e.anchor_world())
|
||||
// A trail's entity translation IS its sort point (the sim writes the live head node
|
||||
// there), so its `GlobalTransform` is the anchor without an accessor of its own.
|
||||
// A trail's translation is its sort point: the sim writes the live head node there.
|
||||
.chain(trails.iter().map(|(_, _, gt)| gt.translation()))
|
||||
.collect();
|
||||
found.extend(
|
||||
@@ -311,13 +252,7 @@ fn report_phases(
|
||||
}
|
||||
let frame = *seen;
|
||||
*seen += 1;
|
||||
// The whole-frame census, before the per-batch lines. Every instrument we have is scoped to the
|
||||
// thing we already suspect — this object's batches, `WorldObject`-tagged ray hits, the depth
|
||||
// between the opaque and transmissive passes — so a draw that is *none of those* is invisible to
|
||||
// all of them at once. An alpha-blended sprite over the awning (a particle, a glow card) would
|
||||
// halve the pixel while leaving depth, material, tag, draw order and the light buffer exactly as
|
||||
// measured, which is precisely the contradiction B38 now sits in. A transparent count that moves
|
||||
// between a bright and a dim frame names that draw; one that does not, rules out the whole family.
|
||||
// The whole-frame census first, which sees draws outside the watch list.
|
||||
let transparent_total: usize = transparent.values().map(|p| p.items.len()).sum();
|
||||
info!(
|
||||
"phase#{frame} CENSUS opaque {} alphamask {} transparent {}",
|
||||
@@ -325,9 +260,7 @@ fn report_phases(
|
||||
binned_total(&alpha_mask),
|
||||
transparent_total,
|
||||
);
|
||||
// `particles` mode: the transparent phase's TAIL — the items drawn LAST, i.e. over everything
|
||||
// else. A watched mesh that is submitted, late, correct and still invisible means someone
|
||||
// even later paints its pixels; this names the suspects with their sort distances.
|
||||
// `particles` mode: the last 25 transparent items, drawn over everything else.
|
||||
if watch.particles {
|
||||
if let Some(phase) = transparent.values().max_by_key(|p| p.items.len()) {
|
||||
let n = phase.items.len();
|
||||
@@ -340,10 +273,8 @@ fn report_phases(
|
||||
}
|
||||
}
|
||||
for &(entity, submesh, ref blend, assets) in &watch.batches {
|
||||
// What the GPU-side copies hold at draw time — the last links between "submitted to a
|
||||
// phase" and "fragments exist": a phase item whose RenderMesh is absent draws nothing,
|
||||
// and one whose material TEXTURE never became a GpuImage has no material bind group, so
|
||||
// the draw command chain aborts silently every frame.
|
||||
// What the GPU-side copies hold: an item with no `RenderMesh`, or whose texture never
|
||||
// became a `GpuImage` (no material bind group), silently draws nothing.
|
||||
let gpu = assets.map(|(mesh_id, tex_id)| {
|
||||
let mesh = match mesh_id {
|
||||
// The shared effect lane: no per-emitter mesh asset exists to check.
|
||||
@@ -361,9 +292,7 @@ fn report_phases(
|
||||
});
|
||||
let main = MainEntity::from(entity);
|
||||
let mut found = Vec::new();
|
||||
// The main view is whichever phase map holds the most entities: shadow and prepass views bin
|
||||
// subsets, so the largest is the one that draws the frame. Picking by view entity would tie
|
||||
// this to the camera's identity in the render world, which is not stable to read from here.
|
||||
// The main view is the phase map holding the most entities; shadow and prepass bin subsets.
|
||||
if let Some(pos) = binned_position::<Opaque3d>(&opaque, main) {
|
||||
found.push(format!("Opaque3d @{pos}"));
|
||||
}
|
||||
@@ -372,22 +301,15 @@ fn report_phases(
|
||||
}
|
||||
for phase in transparent.values() {
|
||||
if let Some(pos) = phase.items.iter().position(|item| item.entity.1 == main) {
|
||||
// The sort key itself, not just the slot: `Transparent3d` orders on view-space z +
|
||||
// the material's depth bias, ascending = farthest first (`benilla_world::sky_order`). Two
|
||||
// slots tell you WHICH drew last; the two distances tell you WHY, and whether the
|
||||
// gap is a real spatial one or a tie the sort broke arbitrarily.
|
||||
// The sort key too: view-space z plus the material's depth bias, ascending =
|
||||
// farthest first (`benilla_world::sky_order`).
|
||||
found.push(format!(
|
||||
"Transparent3d @{pos} d {:.3}",
|
||||
phase.items[pos].distance
|
||||
));
|
||||
}
|
||||
}
|
||||
// "NOT SUBMITTED" is the whole point of the instrument: a batch that reached no phase was
|
||||
// never drawn, and that is indistinguishable in the pixels from one that drew and lost.
|
||||
// The entity, because **batch order does not identify a surface**: a WMO placement is many
|
||||
// groups and the order is per-group, so one placement has several batches sharing an order
|
||||
// (79 entities over 19 orders on the Far Watch Post tower). Keying a report by order alone
|
||||
// silently merges them and can hide the one batch that moved.
|
||||
// Keyed by entity: batch order is per WMO group, so one placement repeats orders.
|
||||
info!(
|
||||
"phase#{frame} {entity} batch order {submesh:3} {blend:10} {} -> {}",
|
||||
gpu.as_deref().unwrap_or(""),
|
||||
@@ -400,8 +322,7 @@ fn report_phases(
|
||||
}
|
||||
}
|
||||
|
||||
/// The entity's ordinal within the largest binned phase for `BPI`, walking bins in iteration order —
|
||||
/// which is the order the pass draws them, so the ordinal is a draw position and not just a yes/no.
|
||||
/// The entity's draw position within the largest binned phase for `BPI`, in bin iteration order.
|
||||
fn binned_position<BPI>(phases: &ViewBinnedRenderPhases<BPI>, main: MainEntity) -> Option<usize>
|
||||
where
|
||||
BPI: bevy::render::render_phase::BinnedPhaseItem,
|
||||
@@ -434,9 +355,7 @@ where
|
||||
None
|
||||
}
|
||||
|
||||
/// How many entities the main binned phase for `BPI` holds this frame — the census counterpart of
|
||||
/// [`binned_position`], picking the same "largest phase map is the main view" way so the two numbers
|
||||
/// describe the same view.
|
||||
/// How many entities the largest binned phase for `BPI` holds this frame.
|
||||
fn binned_total<BPI>(phases: &ViewBinnedRenderPhases<BPI>) -> usize
|
||||
where
|
||||
BPI: bevy::render::render_phase::BinnedPhaseItem,
|
||||
|
||||
@@ -1,40 +1,10 @@
|
||||
//! `WOW_PICK` — the headless **"what is at this pixel, all the way back"** probe.
|
||||
//!
|
||||
//! The flicker instruments (decisions 0653/0656) localise a defect: `benilla-visual hotspot` hands
|
||||
//! back a pixel box and says *this* is what would not hold still. Naming what is actually there was
|
||||
//! then a manual step — read the ADT placements, guess which model the report meant, hope. The
|
||||
//! interactive inspector ([`benilla_world::interact`]) answers it with a cursor, which an unattended probe
|
||||
//! does not have.
|
||||
//!
|
||||
//! So: `WOW_PICK="<x>,<y>[;<x>,<y>…]"` (+ `WOW_PICK_AT=<secs>`, default 20) casts a ray through each
|
||||
//! pixel and logs **every** hit along it, nearest first — not just the front one.
|
||||
//!
|
||||
//! `WOW_PICK_COUNT=<n>` / `WOW_PICK_EVERY=<secs>` (0 = one per frame) repeat the cast, the same shape
|
||||
//! as the screenshot burst — because the cast honours `Visibility`, a hit that *vanishes* between
|
||||
//! adjacent casts is a surface being **culled**, not one losing a depth test. That distinction is
|
||||
//! invisible to a single cast and to the pixels alike: both look like the surface went away.
|
||||
//!
|
||||
//! Reporting the whole ray is the point. A surface that swaps with another between frames has a
|
||||
//! rival *behind* it, and the gap between hit 0 and hit 1 is the number that decides the diagnosis:
|
||||
//! an exact tie is a coplanar authoring tie (no depth precision can break it — only a deterministic
|
||||
//! order or a bias), while a few millimetres is a precision question. The nearest hit alone cannot
|
||||
//! tell those apart, and which one it is decides the fix.
|
||||
//!
|
||||
//! **The gap that decides it is the PERPENDICULAR one, not the distance along the ray** — reading
|
||||
//! the along-ray gap as the separation is how B38 was mis-diagnosed twice. Where the ray meets a
|
||||
//! surface obliquely it travels a long way between two planes that are barely apart; at the awning
|
||||
//! there, hit 0 and hit 1 are 1–2 yd apart *along the ray* and 3–45 cm apart perpendicular. Both
|
||||
//! are reported, along with each hit's **incidence angle**, because a surface nearly edge-on to the
|
||||
//! ray is lost or won by sub-pixel coverage rather than by depth, and that is a different defect
|
||||
//! wearing the same appearance.
|
||||
//!
|
||||
//! Each hit also names its **material and bound texture**, so "the same batch drew something else
|
||||
//! this frame" is visible at all — it is indistinguishable from a depth fight in the pixels, and
|
||||
//! ruling it out is what leaves the depth story standing.
|
||||
//!
|
||||
//! Coordinates are **screenshot pixels** — the same space `benilla-visual` reports boxes in — so a
|
||||
//! hotspot box can be pasted straight in. They are divided by the window's scale factor here, since
|
||||
//! `viewport_to_world` works in logical units and a Retina capture is 2× the logical window.
|
||||
//! `WOW_PICK="<x>,<y>[;<x>,<y>…]"`: the headless inspector. Casts a ray through each screenshot
|
||||
//! pixel after `WOW_PICK_AT=<secs>` (default 20) and logs every hit along it, nearest first, with
|
||||
//! the along-ray and perpendicular gap to the previous hit, the incidence angle, the material,
|
||||
//! bound texture and shading inputs. `WOW_PICK_COUNT=<n>` and `WOW_PICK_EVERY=<secs>` (0 = one
|
||||
//! per frame) repeat the cast; the cast honours `Visibility`, so a hit that vanishes between casts
|
||||
//! was culled. Pixels are in `benilla-visual`'s space and divided by the window scale factor, since
|
||||
//! `viewport_to_world` works in logical units.
|
||||
|
||||
use bevy::mesh::MeshTag;
|
||||
use bevy::platform::collections::HashSet;
|
||||
@@ -103,36 +73,26 @@ fn parse_pixels(spec: &str) -> Vec<Vec2> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The filter deciding what a cast may hit — see [`fire_pick`]'s `objects` for why it is both.
|
||||
/// The filter deciding what a cast may hit; see [`fire_pick`]'s `objects` for why it is both.
|
||||
type Pickable = Or<(
|
||||
With<WorldObject>,
|
||||
With<benilla_world::model_render::ModelPart>,
|
||||
)>;
|
||||
|
||||
/// What a hit entity is asked for: its identity, the batch class it draws as, the material
|
||||
/// carrying the WMO batch order, and its per-instance `MeshTag` (`Option` because a hit need not be a
|
||||
/// model batch at all — and an equipped item's batch carries no [`WorldObject`]).
|
||||
/// What a hit entity is asked for: identity, batch class, the material carrying the WMO batch
|
||||
/// order, the per-instance `MeshTag`, and whether it is a camera-facing card. An equipped item's
|
||||
/// batch carries no [`WorldObject`].
|
||||
type HitIdentity = (
|
||||
Option<&'static WorldObject>,
|
||||
Option<&'static benilla_world::model_render::ModelPart>,
|
||||
Option<&'static MeshMaterial3d<WowModelMaterial>>,
|
||||
Option<&'static MeshTag>,
|
||||
// Is this hit a camera-FACING batch (decision 0153's world-root card) or the model's own
|
||||
// geometry? The two are indistinguishable in the pixels and lead to completely different
|
||||
// diagnoses — a card that should be culled vs a submesh drawn with the wrong texture — so the
|
||||
// cast says which. (`card` in the line below.)
|
||||
Has<benilla_world::billboard::BillboardCard>,
|
||||
);
|
||||
|
||||
/// Every shading input the batch actually has, as text — the five packed uniform rows and the base
|
||||
/// colour, printed per cast so a value that MOVES inside one material is visible at all.
|
||||
///
|
||||
/// This exists because comparing material **handles** across frames — the check that "eliminated" a
|
||||
/// distance-fade material swap for B38 (0665) — is blind to exactly this: the handle is stable while
|
||||
/// the contents move, and three of these are re-sampled per frame *by design* (`sun_scale.zw`'s UV
|
||||
/// animation, the tint lane's per-instance clone, the fade twin's alpha). `tint.w` is the WMO
|
||||
/// interior batch-class lane (0 exterior / 1 interior-unlit / 2 the MOCV lerp) — a term that
|
||||
/// re-lights every surface of one building together, which is B38's whole footprint.
|
||||
/// Every shading input the batch has, as text: the five packed uniform rows and the base colour,
|
||||
/// per cast, since several move inside one material handle. `tint.w` is the WMO batch-class lane
|
||||
/// (0 exterior, 1 interior-unlit, 2 the MOCV lerp).
|
||||
fn shading_of(mat: Option<&WowModelMaterial>) -> String {
|
||||
let Some(m) = mat else {
|
||||
return "<no WowModelMaterial>".to_string();
|
||||
@@ -144,12 +104,8 @@ fn shading_of(mat: Option<&WowModelMaterial>) -> String {
|
||||
sidn {:.3},{:.3},{:.3} win {:.0} sunsel {:.2} bias {:.0} uv {:.4},{:.4} \
|
||||
clutter {:.1},{:.1},{:.1},{:.1} base {:.3},{:.3},{:.3},{:.3} {:?}",
|
||||
e.model_flags.x,
|
||||
// `int` is `model_flags.z` — the LIGHT-LANE flag, and it was the one row of the packed
|
||||
// uniforms this line did not print. Without `is_wmo` it selects the interior-prop lane,
|
||||
// i.e. "this batch is lit by the SH probe its MeshTag names" rather than by the sky; with
|
||||
// it, the WMO interior batch-class lanes `class` then splits. A hit that reads
|
||||
// `wmo 0 int 1` is asking about a probe slot, and the `slot` reading of the tag beside it
|
||||
// is the one that matters — which is the whole diagnosis of B373.
|
||||
// `int`, the light-lane flag: without `wmo` it lights the batch from the SH probe slot its
|
||||
// `MeshTag` names; with it, `class` picks the WMO interior lane.
|
||||
e.model_flags.z,
|
||||
e.model_flags.y,
|
||||
e.tint.w,
|
||||
@@ -176,8 +132,7 @@ fn shading_of(mat: Option<&WowModelMaterial>) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// Everything needed to turn a hit entity into a line of text — bundled so the system keeps one
|
||||
/// parameter for "describe this hit" rather than two that must always travel together.
|
||||
/// Everything needed to turn a hit entity into a line of text.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
struct HitNames<'w, 's> {
|
||||
identity: Query<'w, 's, HitIdentity>,
|
||||
@@ -189,19 +144,11 @@ fn fire_pick(
|
||||
time: ProbeClock,
|
||||
window: Query<&Window, With<PrimaryWindow>>,
|
||||
camera: Query<(&Camera, &GlobalTransform), With<WorldCamera>>,
|
||||
// What the ray may hit. `WorldObject` is the world's identity tag (doodads, WMOs, unit BODY
|
||||
// parts) — but an EQUIPPED item carries none: the item lane spawns its parts and cards without
|
||||
// one, because that component also feeds the mouseover/targeting fallback and a weapon is not a
|
||||
// target. So the probe would answer "what is at this pixel" for a character's skin and silently
|
||||
// skip its helm, its pauldrons, its weapon and every camera-facing card hanging off them —
|
||||
// exactly the geometry a "this bit of my gear looks wrong" report is about (found chasing the
|
||||
// Field Marshal pauldron, decision 0836). `ModelPart` rides every drawn batch in the client, so
|
||||
// the probe keys on it too and stays a pure instrument: nothing outside this file reads it.
|
||||
// What the ray may hit. Equipped items carry no `WorldObject` (it feeds targeting, and a
|
||||
// weapon is not a target), so `ModelPart`, on every drawn batch, is accepted too.
|
||||
objects: Query<Entity, Pickable>,
|
||||
names: HitNames,
|
||||
// Everything drawn, entity-owned or not ([`WorldPick`]). Without the entity-less lanes this
|
||||
// probe reports `0 hits` over every absorbed tree, building and prop — which is exactly what
|
||||
// it did between 1434's default flip and 1534.
|
||||
// Everything drawn, entity-owned or not, including the consolidated lanes ([`WorldPick`]).
|
||||
pick: WorldPick,
|
||||
) {
|
||||
if probe.taken >= probe.count || probe.pixels.is_empty() || time.elapsed_secs() < probe.next_at
|
||||
@@ -209,7 +156,7 @@ fn fire_pick(
|
||||
return;
|
||||
}
|
||||
let (Ok((camera, cam_tf)), Ok(window)) = (camera.single(), window.single()) else {
|
||||
return; // no world camera / window yet — try again next frame
|
||||
return; // no world camera or window yet
|
||||
};
|
||||
let cast = probe.taken;
|
||||
probe.taken += 1;
|
||||
@@ -222,18 +169,10 @@ fn fire_pick(
|
||||
warn!("pick ({}, {}): outside the viewport", pixel.x, pixel.y);
|
||||
continue;
|
||||
};
|
||||
// `all_hits` is the whole reason this exists: the nearest hit alone cannot name the rival
|
||||
// surface behind it, which is exactly what we came for. The cast reads each part's
|
||||
// RESIDENT geometry (decision 0857) — the render meshes are `RENDER_WORLD`-only since
|
||||
// 0834, so a `MeshRayCast` here reports nothing for any static model.
|
||||
// Every hit, not the nearest. The cast reads each part's resident geometry: render meshes
|
||||
// are `RENDER_WORLD`-only, so a `MeshRayCast` would find no static model.
|
||||
let hits = pick.cast(ray, &pickable, true);
|
||||
// The camera's GLOBAL transform, bit-exact, on the line that carries the cast index — so
|
||||
// "did the camera move on this frame?" is answerable against "was this frame dim?" without
|
||||
// aligning two logs by hand. It is deliberately *this* transform: it is the one
|
||||
// `viewport_to_world` just used to build the ray, so a converging hit distance can be
|
||||
// attributed to the camera or to the geometry rather than left ambiguous. A dump inside
|
||||
// `seat_camera` cannot do that job — it prints the local `Transform` before propagation, and
|
||||
// it has no frame index, which is how it got read against the wrong 75 frames once already.
|
||||
// The camera's global transform, bit-exact: the one `viewport_to_world` built the ray from.
|
||||
let eye = cam_tf.translation();
|
||||
info!(
|
||||
"pick#{cast} ({}, {}) [logical {:.1}, {:.1}]: {} hits eye [{:08x},{:08x},{:08x}] {:.6?}",
|
||||
@@ -247,12 +186,8 @@ fn fire_pick(
|
||||
eye.z.to_bits(),
|
||||
eye,
|
||||
);
|
||||
// Distance ALONG THE RAY is not the gap that decides a depth fight, and reading it as if it
|
||||
// were is how B38 got called "not a depth fight" (0662). At a grazing angle the ray travels
|
||||
// nearly parallel to both surfaces, so a yard of ray can separate two planes that are a hair
|
||||
// apart — and near a line where two planes INTERSECT the perpendicular gap goes to zero
|
||||
// while the along-ray gap stays large. So report the perpendicular distance from this hit's
|
||||
// point to the previous hit's plane as well: that is the number a depth buffer sees.
|
||||
// The perpendicular distance to the previous hit's plane is the gap a depth buffer sees;
|
||||
// at a grazing angle the along-ray gap is far larger.
|
||||
let mut previous: Option<(f32, Vec3, Vec3)> = None;
|
||||
for (i, found) in hits.iter().enumerate() {
|
||||
let hit = &found.hit;
|
||||
@@ -265,11 +200,8 @@ fn fire_pick(
|
||||
)
|
||||
});
|
||||
previous = Some((hit.distance, hit.point, hit.normal));
|
||||
// A CONSOLIDATED hit — a merge blob's member or a retained-pass item — has no entity,
|
||||
// so the per-entity columns (material, bound texture, mesh tag, blend) are simply not
|
||||
// there to read: its draw state lives in the lane's baked record, not in a component.
|
||||
// It is still named, posed and ordered like every other hit, which is what a "what is
|
||||
// at this pixel" reading is for; `CONSOLIDATED` says why the columns are bare.
|
||||
// A consolidated hit (a merge blob member or retained-pass item) has no entity, so its
|
||||
// per-entity columns are bare; its draw state lives in the lane's baked record.
|
||||
let (part, mat, tag, card) = match found.entity.map(|e| names.identity.get(e)) {
|
||||
Some(Ok((_, part, mat, tag, card))) => (part, mat, tag, card),
|
||||
Some(Err(_)) => {
|
||||
@@ -284,32 +216,21 @@ fn fire_pick(
|
||||
" CONSOLIDATED"
|
||||
};
|
||||
let obj = found.object.as_ref();
|
||||
// The WMO authored batch order rides in the material's `sun_scale.y` (see
|
||||
// `model_render`): 0 means "no bias applied", which for a WMO batch is itself a finding.
|
||||
// The WMO batch order rides in the material's `sun_scale.y` (`model_render`); 0 means
|
||||
// no bias applied.
|
||||
let resolved = mat.and_then(|m| names.materials.get(&m.0));
|
||||
let batch = resolved
|
||||
.map(|m| m.extension.sun_scale.y as i32)
|
||||
.unwrap_or(-1);
|
||||
// The material + texture the hit actually draws with, per cast. A surface that swaps
|
||||
// appearance while its GEOMETRY stays put (identical hits, identical order) is either
|
||||
// being re-lit or bound to something else; only naming the bound texture each frame
|
||||
// tells those apart, and "the same batch drew a different texture this frame" is
|
||||
// otherwise completely invisible from the pixels.
|
||||
// The texture the hit is bound to this cast.
|
||||
let tex = resolved
|
||||
.and_then(|m| m.base.base_color_texture.as_ref())
|
||||
.map_or("-".to_string(), |h| format!("{:?}", h.id()));
|
||||
// How edge-on is this surface to the ray? 90° is face-on; near 0° the triangle is nearly
|
||||
// parallel to the view and covers a pixel by a hair, so which of it and whatever lies
|
||||
// behind wins a pixel is decided by sub-pixel coverage rather than by depth — a
|
||||
// completely different defect from a depth fight, and indistinguishable in the pixels.
|
||||
// The angle to the ray: 90° is face-on; near 0° sub-pixel coverage, not depth, decides.
|
||||
let incidence = ray.direction.dot(hit.normal).abs().clamp(0.0, 1.0).asin();
|
||||
// An untagged batch — an equipped item's part or its billboard card — has no world
|
||||
// identity of its own; it is named by its blend + material + texture, which is what
|
||||
// separates "the pauldron's plate" from "the pauldron's camera-facing trim".
|
||||
// `detail` is the inspector's second line — the kind-specific facts the model path can't
|
||||
// carry (a WMO prop's lighting lane, an emitter count). Printed here because this probe
|
||||
// IS the inspector without a cursor, and a prop that draws wrong usually differs from a
|
||||
// right one only in that line (decision 0969: the black Booty Bay arch).
|
||||
// An equipped item's batch has no world identity and is named by blend, material and
|
||||
// texture. `detail` is the inspector's second line (a prop's lighting lane, an emitter
|
||||
// count).
|
||||
let (kind, id, label) = obj.map_or_else(
|
||||
|| ("<worn>".to_string(), String::new(), String::new()),
|
||||
|o| {
|
||||
@@ -332,11 +253,8 @@ fn fire_pick(
|
||||
incidence.to_degrees(),
|
||||
mat.map(|m| m.0.id()),
|
||||
);
|
||||
// The contents, not the identity — the whole point (see [`shading_of`]). Printed for
|
||||
// every hit on every cast: with `WOW_PICK_EVERY=0` that is a per-frame record of every
|
||||
// shading input this batch has, which is the only way a term moving *inside* one
|
||||
// material is visible. The per-instance tag rides the same line because bits 16..=29
|
||||
// mean different things under the two material laws, so the pair must be read together.
|
||||
// The material's contents, every hit every cast. The tag shares the line because its
|
||||
// bits 16..=29 mean different things under the two material laws.
|
||||
info!(
|
||||
" {} tag {}",
|
||||
shading_of(resolved),
|
||||
|
||||
@@ -1,66 +1,23 @@
|
||||
//! The auction-arc live probe (`WOW_PROBE_AUCTION=1`) — decision 1511's end-to-end instrument:
|
||||
//! GM-hop to a real Stormwind auctioneer, greet it on the wire, and drive browse / throttle /
|
||||
//! sell / owner-list / cancel through the **live Lua VM** exactly as a click would, printing a
|
||||
//! `PROBE_AUCTION:` trace line with a PASS/FAIL/SKIP verdict per step and a final
|
||||
//! `PROBE_AUCTION: DONE pass=<n> fail=<m>` summary. Modeled closely on [`super::probe_mail`]
|
||||
//! (same phase-machine shape, same trace style, same self-terminating exit).
|
||||
//! The auction live probe (`WOW_PROBE_AUCTION=1`): hops to a Stormwind auctioneer, greets it on
|
||||
//! the wire and drives browse, throttle, sell, owner list and cancel through the live Lua VM as a
|
||||
//! click would, logging one `PROBE_AUCTION:` PASS/FAIL/SKIP line per step, then
|
||||
//! `PROBE_AUCTION: DONE pass=<n> fail=<m>`, and exits. Non-combat; the switches are
|
||||
//! `docs/CONTRIBUTING.md`, "Running it unattended".
|
||||
//!
|
||||
//! ## The one assertion no unit test can make
|
||||
//! The window opens on the server's `MSG_AUCTION_HELLO` reply, not on the click, and the reply's
|
||||
//! house id (checked in `1..=7`) keys the deposit rate the sell pane quotes.
|
||||
//!
|
||||
//! Step (2). **The window is opened by the server, not by the click** ([`crate::ui_auction`]):
|
||||
//! `MSG_AUCTION_HELLO` goes out and nothing happens; the *reply* — the same opcode coming back
|
||||
//! with the auctioneer guid and an `AuctionHouse.dbc` house id — opens the session and fires
|
||||
//! `AUCTION_HOUSE_SHOW`. Every other piece of this arc is downstream of that reply arriving, so it
|
||||
//! is the step this probe exists for. The house id is checked to be in `1..=7` because it keys the
|
||||
//! deposit rate the sell pane quotes, and a zero would silently quote a free listing.
|
||||
//! Server behaviour the phases follow (vmangos `AuctionHouseHandler.cpp`):
|
||||
//! - One list request at a time: a second while one is in flight is dropped silently (`:710`).
|
||||
//! - `etime` is minutes, one of 1, 4 or 12 times the 2 h `MIN_AUCTION_TIME` (`:287-291`).
|
||||
//! - The deposit is `SellPrice × count × (etime / MIN_AUCTION_TIME) × depositPercent / 100`
|
||||
//! (`AuctionHouseMgr.cpp:98`); at 120 minutes the client's `CalculateAuctionDeposit` agrees
|
||||
//! with it to the copper.
|
||||
//! - A cancel with no bidder is free and returns the item by mail.
|
||||
//! - A GM account is refused with `RESTRICTED_ACCOUNT` unless `GM.AllowTrades` is on (`:249`).
|
||||
//!
|
||||
//! ## The auctioneer (live-DB verified against the local vmangos, `mangos`)
|
||||
//!
|
||||
//! Auctioneer Fitch, creature entry 8719, spawn guid 12696, map 0 (Stormwind, Trade District),
|
||||
//! pos `(-8821.53, 659.886, 97.4645)`. Her `creature_template.npc_flags` is **4096** exactly —
|
||||
//! bit 12 only (`UNIT_NPC_FLAG_AUCTIONEER`), no gossip bit — so the right-click route sends
|
||||
//! `AuctionHello` directly with no gossip pre-empt (`target::click::interact_command`'s own note
|
||||
//! on the service ladder), which is precisely the command this probe sends.
|
||||
//!
|
||||
//! ## What the server does that shapes the phases (VERIFIED, vmangos `AuctionHouseHandler.cpp`)
|
||||
//!
|
||||
//! - **One AH list request at a time.** `HandleAuctionListItems`/`ListOwnerItems`/`ListBidderItems`
|
||||
//! all open with `if (ReceivedAHListRequest()) return;` — a second query while one is in flight
|
||||
//! is dropped *silently*. Every list phase here therefore waits for its answer before asking
|
||||
//! again, and the owner phase re-asks on a timer rather than spamming.
|
||||
//! - **`etime` is minutes on the wire**, converted server-side (`etime * MINUTE`) and switched
|
||||
//! against 1/4/12 × `MIN_AUCTION_TIME` (2 h). 120 is the shortest legal listing.
|
||||
//! - **The deposit** is `uint32(SellPrice × count × (etime / MIN_AUCTION_TIME) × depositPercent /
|
||||
//! 100)` (`AuctionHouseMgr::GetAuctionDeposit`), with `Auction.Deposit.Min = 0` and
|
||||
//! `Rate.Auction.Deposit = 1` in this deploy's `mangosd.conf`. At **120 minutes the unit count
|
||||
//! is 1**, which is exactly where the client's own arithmetic (`CalculateAuctionDeposit`, whose
|
||||
//! intermediate truncation disagrees with the server on longer listings — decision 1511 §7) and
|
||||
//! the server's agree to the copper. That is why step (5) lists for 120 minutes: it makes "money
|
||||
//! fell by exactly the quoted deposit" a real assertion instead of a flaky one.
|
||||
//! - **A cancel with no bidder is free** and returns the item **by mail**, not to the bag
|
||||
//! (`HandleAuctionRemoveItem`) — so a clean run leaves the auction house as it found it and one
|
||||
//! letter in the probe's own mailbox. Vanilla's behaviour, not litter we could avoid.
|
||||
//! - `GM.AllowTrades = 1` in this deploy, so the gmlevel-6 probe accounts are not refused with
|
||||
//! `AUCTION_ERR_RESTRICTED_ACCOUNT` (the guard at the top of `HandleAuctionSellItem`).
|
||||
//!
|
||||
//! ## The row-identity rule this probe obeys
|
||||
//!
|
||||
//! [`super::probe_mail`]'s header records the defect worth not repeating: a probe that re-finds
|
||||
//! its row **by predicate** each tick reads its own success as absence, because a successful
|
||||
//! action is exactly what stops the row matching. So the auction created in step (5) is
|
||||
//! remembered by the **auction id the server's own `STARTED` result carried**, once, and every
|
||||
//! later step tracks that id — never "the row whose item is Linen Cloth".
|
||||
//!
|
||||
//! ## The run recipe
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_PROBE_AUCTION=1 WOW_NOSOUND=1 WOW_USER=probe5 WOW_PASS=pprobe5 WOW_CHAR=Probefive \
|
||||
//! cargo run -p benilla
|
||||
//! ```
|
||||
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
|
||||
//! skill).
|
||||
//! Non-combat. An outer grep on `PROBE_AUCTION:` is the whole harness; the probe self-exits (the
|
||||
//! [`super::probes::ProbeExitPlugin`] pattern) once DONE.
|
||||
//! The auction is tracked by the id the server's `STARTED` result carried, never re-found by a
|
||||
//! predicate over the rows, since a successful action is what stops a row matching.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -73,37 +30,25 @@ use crate::net::{ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectSt
|
||||
use crate::player::Player;
|
||||
use crate::ui_auction::AuctionOpen;
|
||||
|
||||
/// Auctioneer Fitch's spawn (vmangos `creature` guid 12696, entry 8719, map 0) — the `.go xyz`
|
||||
/// target; the auctioneer itself is then scanned out of the streamed world by its npc flag, never
|
||||
/// by a hardcoded guid (the taxi/bank probes' idiom).
|
||||
/// Auctioneer Fitch's spawn (vmangos `creature` guid 12696, map 0), the `.go xyz` target; the
|
||||
/// unit itself is found by its npc flag.
|
||||
const AUCTIONEER_AT: [f32; 3] = [-8821.53, 659.886, 97.4645];
|
||||
/// Her creature template entry — reported, not required (the flag below is the identity test).
|
||||
/// Her creature template entry, reported only; her `npc_flags` is 4096, auctioneer only, so the
|
||||
/// right-click route sends `AuctionHello` with no gossip in between.
|
||||
const AUCTIONEER_ENTRY: u32 = 8719;
|
||||
/// `UNIT_NPC_FLAG_AUCTIONEER` (bit 12) — the same bit the cursor classifier and the click router
|
||||
/// key on (`target::cursor_mode::npc_flags::AUCTIONEER`).
|
||||
/// `UNIT_NPC_FLAG_AUCTIONEER` (bit 12).
|
||||
const NPC_FLAG_AUCTIONEER: u32 = 0x1000;
|
||||
/// How wide the streamed world is searched for an auctioneer. Generous, so a slightly-off `.go`
|
||||
/// landing still finds one — but the NEAREST match is the one greeted, never the first the ECS
|
||||
/// happens to yield.
|
||||
///
|
||||
/// **That distinction cost this probe its first run.** Stormwind's Trade District holds several
|
||||
/// auctioneers a few yards apart; a plain `find` picked one 7.0 yd away and the greeting came back
|
||||
/// as *nothing at all*, because the server's `CanInteractWithNPC` ends in
|
||||
/// `IsWithinDistInMap(this, INTERACTION_DISTANCE)` and refuses silently — the exact failure mode
|
||||
/// step (2) exists to detect, arrived at by our own aim rather than by a defect.
|
||||
/// Wider than the interact range, so a slightly-off `.go` landing still finds one; the nearest is
|
||||
/// greeted, as several auctioneers stand a few yards apart.
|
||||
const SCAN_RANGE: f32 = 12.0;
|
||||
/// vmangos `INTERACTION_DISTANCE` (`ObjectDefines.h:24`) — 5.0 yd between centres, plus each
|
||||
/// side's bounding radius. Anything past it is refused with no packet, so the probe would rather
|
||||
/// say so than send a greeting it knows will be ignored.
|
||||
/// vmangos `INTERACTION_DISTANCE` (`ObjectDefines.h:24`); past it a greeting gets no reply.
|
||||
const INTERACT_MAX_YD: f32 = 5.0;
|
||||
|
||||
/// The fixture listed in step (5): Linen Cloth — cheap, stackable, no durability/bind/equip
|
||||
/// complications, and a `sell_price` of 13 c (live-DB verified), so a stack of five has a
|
||||
/// nonzero deposit. A ZERO deposit would make the money assertion vacuous.
|
||||
/// The step (5) fixture: Linen Cloth, whose 13 c sell price gives a stack of five a nonzero
|
||||
/// deposit.
|
||||
const ITEM_ENTRY: u32 = 2589;
|
||||
const ITEM_COUNT: u32 = 5;
|
||||
/// The listing length. 120 minutes is the one duration where the client's deposit arithmetic and
|
||||
/// the server's agree exactly (module doc).
|
||||
/// 120 minutes, where the client's deposit quote and the server's charge agree exactly.
|
||||
const DURATION_MINUTES: u32 = auction_duration::SHORT_MINUTES;
|
||||
|
||||
const SETTLE_SECS: f64 = 3.0;
|
||||
@@ -112,22 +57,17 @@ const HELLO_TIMEOUT_SECS: f64 = 10.0;
|
||||
const LIST_TIMEOUT_SECS: f64 = 15.0;
|
||||
const ACTION_TIMEOUT_SECS: f64 = 10.0;
|
||||
const ITEM_TIMEOUT_SECS: f64 = 10.0;
|
||||
/// How long the refusal is watched before it is believed — the throttle drops the query with no
|
||||
/// event at all, so the only honest reading is "nothing went out, and nothing came back, for this
|
||||
/// long" (`AuctionWireLog`'s own reason for existing).
|
||||
/// How long nothing must go out or come back before the throttle's silent refusal is believed.
|
||||
const REFUSAL_WATCH_SECS: f64 = 2.0;
|
||||
/// The client throttle is 5 s; give the recovery a generous but bounded window.
|
||||
/// The client throttle is 5 s.
|
||||
const RECOVER_TIMEOUT_SECS: f64 = 12.0;
|
||||
/// The owner list is re-asked on this cadence (the server drops a second in-flight list request
|
||||
/// silently, so a re-ask has to be spaced, not spammed).
|
||||
/// The owner list re-ask cadence, spaced because the server drops an in-flight second request.
|
||||
const OWNER_REASK_SECS: f64 = 3.0;
|
||||
const OWNER_TIMEOUT_SECS: f64 = 20.0;
|
||||
/// How long a UI EVENT is given to trail the state change it answers. Nothing orders this probe
|
||||
/// against `feed_auction`, which is what fires them, so an event can legitimately land a frame
|
||||
/// after the wire state it reports — but not ninety of them.
|
||||
/// How long a UI event may trail its wire state: `feed_auction` fires it, unordered with this
|
||||
/// probe.
|
||||
const EVENT_GRACE_SECS: f64 = 1.5;
|
||||
/// How long the sell slot is given to empty itself after the listing is away, before (5c) calls
|
||||
/// it. Generous: it is a one-line clear, not a round trip.
|
||||
/// How long the sell slot has to empty after the listing is away.
|
||||
const SELL_SLOT_GRACE_SECS: f64 = 3.0;
|
||||
|
||||
pub(crate) struct ProbeAuctionPlugin;
|
||||
@@ -139,61 +79,45 @@ impl Plugin for ProbeAuctionPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe's phase machine + the identities it discovers along the way (kept resource-level, not
|
||||
/// per-variant, since several later phases re-resolve the same auction by its stable id — the
|
||||
/// module doc's row-identity rule).
|
||||
/// The probe's phase machine and the identities it discovers along the way.
|
||||
#[derive(Resource, Default)]
|
||||
struct AuctionProbe {
|
||||
phase: Phase,
|
||||
/// The auctioneer's guid, once streamed in.
|
||||
auctioneer: Option<u64>,
|
||||
/// **The** handle: the auction id the server's own `STARTED` result carried. Everything after
|
||||
/// step (5) tracks this, never a predicate over the rows.
|
||||
/// The auction id the server's `STARTED` result carried; every later step tracks it.
|
||||
auction_id: Option<u32>,
|
||||
/// The vendor value of the stack in the sell slot — what both deposits are computed from.
|
||||
/// The sell-slot stack's vendor value, which both deposits are computed from.
|
||||
stack_value: i64,
|
||||
/// `CalculateAuctionDeposit(120)` — the client's own quote, which step (5) then holds the
|
||||
/// server's charge against.
|
||||
/// The client's `CalculateAuctionDeposit` quote, held against the server's charge.
|
||||
deposit: i64,
|
||||
min_bid: i64,
|
||||
buyout: i64,
|
||||
/// The purse immediately before `StartAuction` went in.
|
||||
/// The purse just before `StartAuction`.
|
||||
baseline_money: u32,
|
||||
/// `AUCTION_OWNED_LIST_UPDATE`'s count immediately before `StartAuction` went in.
|
||||
///
|
||||
/// Taken THERE and not when step (6) starts, so that an owner re-query queued by the STARTED
|
||||
/// result cannot fire the event before the baseline is read — a baseline taken after it would
|
||||
/// wait for a second event that a repeated identical page will never produce. (Since 2308 that
|
||||
/// re-query only happens for a list we already hold, and step 6 is what first asks for this
|
||||
/// one, so the ordering is belt and braces rather than load-bearing.)
|
||||
/// `AUCTION_OWNED_LIST_UPDATE`'s count just before `StartAuction`, so an owner re-query the
|
||||
/// `STARTED` result queues cannot fire the event before the baseline is read.
|
||||
owned_event_baseline: i64,
|
||||
/// How many red `UI_ERROR_MESSAGE` lines the run had already raised when the auction window
|
||||
/// opened — the login environment's own, not the arc's.
|
||||
///
|
||||
/// Step (6d) asserts the arc raised **no** red line, and it used to read the whole tally: on a
|
||||
/// `probeN` account that is never zero, because the preflight's `GM mode is ON` and
|
||||
/// `GOD mode is ON` banners (0679) are red lines too, and they land before the greeting. The
|
||||
/// assertion was therefore FAILING on every clean run, which is worse than not making it.
|
||||
/// Red `UI_ERROR_MESSAGE` lines raised before the window opened: on a probe account the GM
|
||||
/// and GOD mode banners, not the arc's.
|
||||
red_baseline: i64,
|
||||
passes: u32,
|
||||
fails: u32,
|
||||
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
|
||||
/// Latched once [`Phase::Done`] has fired its exit.
|
||||
exited: bool,
|
||||
}
|
||||
|
||||
/// Every field is `Copy` — the phase is snapshotted out of the resource each tick
|
||||
/// ([`auction_probe`]'s `let phase = probe.phase;`), which frees the match arms to mutate `probe`
|
||||
/// without fighting the borrow checker over `probe.phase`; an arm that wants to "keep waiting"
|
||||
/// simply never writes it.
|
||||
/// `Copy`, so each tick snapshots it and the arms can mutate `probe`; an arm that keeps waiting
|
||||
/// never writes it.
|
||||
#[derive(Default, Clone, Copy, PartialEq)]
|
||||
enum Phase {
|
||||
#[default]
|
||||
Wait,
|
||||
/// `.go` + `.additem` issued; settling before the world streams the auctioneer in (step 1).
|
||||
/// `.go` sent; settling while the world streams the auctioneer in (step 1).
|
||||
Settling {
|
||||
sent_at: f64,
|
||||
},
|
||||
/// `AuctionHello` sent; waiting for the **reply** to open the session (step 2).
|
||||
/// `AuctionHello` sent; waiting for the reply to open the session (step 2).
|
||||
Greet {
|
||||
sent_at: f64,
|
||||
show_baseline: i64,
|
||||
@@ -210,15 +134,15 @@ enum Phase {
|
||||
since: f64,
|
||||
event_baseline: i64,
|
||||
},
|
||||
/// The second query, which the client throttle must refuse outright (step 4a).
|
||||
/// A second query, which the client throttle must refuse (step 4a).
|
||||
Refuse {
|
||||
since: f64,
|
||||
sent: bool,
|
||||
sent_baseline: u32,
|
||||
results_baseline: u32,
|
||||
},
|
||||
/// Waiting for `CanSendAuctionQuery()` to come back, then proving it with a real third query
|
||||
/// that actually goes out (step 4b).
|
||||
/// Waiting for `CanSendAuctionQuery()` to return true, then a third query that must go out
|
||||
/// (step 4b).
|
||||
Recover {
|
||||
since: f64,
|
||||
sent: bool,
|
||||
@@ -229,8 +153,7 @@ enum Phase {
|
||||
since: f64,
|
||||
sent: bool,
|
||||
},
|
||||
/// Picked up and dropped in the sell slot; waiting for the slot to read back a priced item
|
||||
/// (step 5 prep).
|
||||
/// Dropped in the sell slot; waiting for it to read back a priced item (step 5 prep).
|
||||
Attach {
|
||||
since: f64,
|
||||
},
|
||||
@@ -238,9 +161,7 @@ enum Phase {
|
||||
Sell {
|
||||
since: f64,
|
||||
},
|
||||
/// The money assertion (which trails the verdict by however long the descriptor takes) and
|
||||
/// the sell-slot one. Each is LATCHED, because they land at different moments and neither may
|
||||
/// re-print while the other is still waiting.
|
||||
/// The money and sell-slot assertions, each latched since they land at different moments.
|
||||
SellMoney {
|
||||
since: f64,
|
||||
money_done: bool,
|
||||
@@ -268,8 +189,7 @@ enum Phase {
|
||||
Done,
|
||||
}
|
||||
|
||||
/// One Lua-side event counter (`ProbeAuctionEvents`), or `0` on any eval hiccup — treated as
|
||||
/// "nothing observed yet", never a panic.
|
||||
/// One Lua-side event counter (`ProbeAuctionEvents`), or 0 on an eval error.
|
||||
fn event_count(script: &UiScript, event: &str) -> i64 {
|
||||
script
|
||||
.eval::<i64>(&format!(
|
||||
@@ -278,24 +198,13 @@ fn event_count(script: &UiScript, event: &str) -> i64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The newest `UI_ERROR_MESSAGE` text the hook logged (the arc's failure channel — a refused
|
||||
/// auction command surfaces here and nowhere else).
|
||||
/// The newest `UI_ERROR_MESSAGE` text the hook logged, where a refused auction command surfaces.
|
||||
fn last_error(script: &UiScript) -> String {
|
||||
script
|
||||
.eval::<String>("return ProbeAuctionErrors[table.getn(ProbeAuctionErrors)] or \"\"")
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// (6d) Where the STARTED verdict was SAID — the live half of the director's 2026-08-22 report that
|
||||
/// an auction outcome arrived as a red centre-screen line.
|
||||
///
|
||||
/// Two halves, and the second is the one that would have caught the bug: the "Auction created." line
|
||||
/// has to be in the **chat** log (catalog row `0x178`, kind 0 → `CHAT_MSG_SYSTEM`), and a clean run
|
||||
/// has to have raised **no red line at all**. Asserting only the first would still pass if we
|
||||
/// printed it in both places.
|
||||
///
|
||||
/// Compared against `getglobal("ERR_AUCTION_STARTED")` rather than against English: the string is
|
||||
/// the player's own, and this file must not carry a copy of it.
|
||||
/// Red `UI_ERROR_MESSAGE` lines raised so far, as the hook's own array counts them.
|
||||
fn red_count(script: &UiScript) -> i64 {
|
||||
script
|
||||
@@ -303,6 +212,9 @@ fn red_count(script: &UiScript) -> i64 {
|
||||
.unwrap_or(-1)
|
||||
}
|
||||
|
||||
/// (6d) The `STARTED` verdict prints as a chat line (catalog row `0x178`, kind 0,
|
||||
/// `CHAT_MSG_SYSTEM`) and never as a red line; matched against the player's own
|
||||
/// `ERR_AUCTION_STARTED`.
|
||||
fn started_chat_check(script: &UiScript, probe: &mut AuctionProbe) {
|
||||
let said = script
|
||||
.eval::<i64>(
|
||||
@@ -331,8 +243,7 @@ fn started_chat_check(script: &UiScript, probe: &mut AuctionProbe) {
|
||||
probe.fails += 1;
|
||||
}
|
||||
(said, reds) => {
|
||||
// The reds are listed from the baseline on, for the same reason `reds` counts from
|
||||
// there: the login environment's own banners are not this arc's output.
|
||||
// Listed from the baseline on: the login banners are not this arc's output.
|
||||
let lines = script
|
||||
.eval::<String>(&format!(
|
||||
"local t = {{}} \
|
||||
@@ -351,23 +262,10 @@ fn started_chat_check(script: &UiScript, probe: &mut AuctionProbe) {
|
||||
}
|
||||
}
|
||||
|
||||
/// (6c) The owner row's money frame, read out of the live VM — the live half of the director's
|
||||
/// 2026-08-22 report that the price columns were dropping their zeros.
|
||||
///
|
||||
/// The law under test is `MoneyTypeInfo["AUCTION"]`'s `showSmallerCoins`: it collapses only the
|
||||
/// **leading** zero denominations, so a 1-silver minimum bid reads `1s 0c` and not a lone silver
|
||||
/// coin. Derived from `probe.min_bid` rather than hardcoded, because the listing price is computed
|
||||
/// from the item's own vendor value and a fixed expectation would be a lie the day the item changes.
|
||||
///
|
||||
/// `IsShown`, not `IsVisible`: the coin buttons' own shown-ness is the subject, not whatever the
|
||||
/// window is doing around them.
|
||||
///
|
||||
/// The pane is the *front* tab by the time this runs, and that is load-bearing rather than
|
||||
/// incidental: the row is painted by `AuctionFrameAuctions_Update`, which cannot complete before
|
||||
/// the tab's `OnShow` has given it a `page` (decision 2308). This check read the player's purse
|
||||
/// out of the row's money frame for as long as the probe asked for the owned list without opening
|
||||
/// the tab — the frame was still on its `MoneyTypeInfo["PLAYER"]` default because nothing had ever
|
||||
/// repainted it.
|
||||
/// (6c) The owner row's money frame: `MoneyTypeInfo["AUCTION"]`'s `showSmallerCoins` hides only
|
||||
/// leading zero denominations, so a 1-silver bid reads `1s 0c` (`MoneyFrame.lua:223-253`). The
|
||||
/// Auctions tab must be in front: its `OnShow` sets the `page` that `AuctionFrameAuctions_Update`
|
||||
/// paints the row with.
|
||||
fn owner_money_check(script: &UiScript, probe: &mut AuctionProbe) {
|
||||
let (gold, silver, copper) = (
|
||||
probe.min_bid / 10_000,
|
||||
@@ -415,10 +313,8 @@ fn owner_money_check(script: &UiScript, probe: &mut AuctionProbe) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The 1-based **display** index our auction currently sits at in the owner list, or `None` if it
|
||||
/// is not on the page. Read off the app-side page rather than by predicate: `CancelAuction(index)`
|
||||
/// is mapped back through the same sorted view the feed pushed, and with no header ever clicked
|
||||
/// that view is the wire order (`ui_auction::sort`: an empty stack leaves the order alone).
|
||||
/// The 1-based display index of our auction in the owner list. `CancelAuction(index)` maps back
|
||||
/// through the same sorted view, which is wire order while no header is clicked.
|
||||
fn owner_index_of(auction: &AuctionOpen, auction_id: u32) -> Option<u32> {
|
||||
auction.lists[OWNER]
|
||||
.entries
|
||||
@@ -427,7 +323,7 @@ fn owner_index_of(auction: &AuctionOpen, auction_id: u32) -> Option<u32> {
|
||||
.map(|i| i as u32 + 1)
|
||||
}
|
||||
|
||||
/// Step (5)'s entry — the next phase every step-4 exit, pass or fail, funnels into.
|
||||
/// Step (5)'s entry, where every step-4 exit lands.
|
||||
fn ensure_item(now: f64) -> Phase {
|
||||
Phase::EnsureItem {
|
||||
since: now,
|
||||
@@ -435,8 +331,7 @@ fn ensure_item(now: f64) -> Phase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Step (7)'s entry. Reached from step (6) either way: a listing we created is cancelled even
|
||||
/// when the assertion about it failed, because leaving it is the one thing this probe must not do.
|
||||
/// Step (7)'s entry, reached from step (6) pass or fail so a created listing is never left.
|
||||
fn cancel_at(now: f64) -> Phase {
|
||||
Phase::Cancel {
|
||||
since: now,
|
||||
@@ -487,18 +382,15 @@ fn auction_probe(
|
||||
return; // not in-world yet
|
||||
};
|
||||
let Some(script) = script else {
|
||||
return; // no UI VM this build (headless net-only) — nothing this probe can drive
|
||||
return; // no UI VM in a headless build
|
||||
};
|
||||
let now = time.elapsed_secs_f64();
|
||||
// A cheap `Copy` snapshot (see [`Phase`]'s doc).
|
||||
let phase = probe.phase;
|
||||
|
||||
match phase {
|
||||
Phase::Wait => {
|
||||
// The observation channel: one hidden frame counting every event this arc fires, plus
|
||||
// a log of the `UI_ERROR_MESSAGE` texts (the mail probe's `ProbeMailEvents` idiom,
|
||||
// widened to a per-event tally because this arc fires six different events and the
|
||||
// question is always "did THIS one fire").
|
||||
// One hidden frame counting each event this arc fires, logging the red and system
|
||||
// chat texts.
|
||||
if let Err(e) = script.run(
|
||||
r#"
|
||||
if not ProbeAuctionHooked then
|
||||
@@ -533,11 +425,8 @@ fn auction_probe(
|
||||
}
|
||||
let [x, y, z] = AUCTIONEER_AT;
|
||||
info!("PROBE_AUCTION: hopping to Auctioneer Fitch (entry {AUCTIONEER_ENTRY}) at ({x}, {y}, {z})");
|
||||
// The teleport (the `ProbeChatPlugin`/`probe_taxi` idiom: GM dot-commands ride as
|
||||
// plain Say lines). The sell fixture is NOT stocked here: step (5) looks in the bags
|
||||
// first and only `.additem`s when there is nothing to reuse, because a cancelled
|
||||
// auction returns its stack by MAIL and an unconditional grant would mint five more
|
||||
// linen into the world on every run.
|
||||
// GM commands ride as Say lines. The fixture is not granted here: step (5) reuses a
|
||||
// stack from the bags first, since each cancel returns its stack by mail.
|
||||
let _ = net.0.send(ClientCommand::Chat {
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
@@ -550,7 +439,7 @@ fn auction_probe(
|
||||
return;
|
||||
}
|
||||
let me = player.pos;
|
||||
// The NEAREST auctioneer, not the first one the ECS yields — see [`SCAN_RANGE`].
|
||||
// The nearest auctioneer, not the first the ECS yields.
|
||||
let nearest = units
|
||||
.iter()
|
||||
.filter(|(_, net_e, store, tf)| {
|
||||
@@ -575,9 +464,7 @@ fn auction_probe(
|
||||
);
|
||||
probe.passes += 1;
|
||||
probe.auctioneer = Some(guid);
|
||||
// The exact command a right-click on a pure auctioneer builds
|
||||
// (`target::click::interact_command`, CursorKind::Buy + bit 12). It opens
|
||||
// NOTHING on its own — the reply does.
|
||||
// What a right-click on a pure auctioneer sends; the reply opens the window.
|
||||
let _ = net.0.send(ClientCommand::AuctionHello { auctioneer: guid });
|
||||
info!("PROBE_AUCTION: (2 greet) MSG_AUCTION_HELLO sent — the window opens on the REPLY, not on this");
|
||||
probe.phase = Phase::Greet {
|
||||
@@ -625,8 +512,7 @@ fn auction_probe(
|
||||
auction.auctioneer.unwrap_or(0)
|
||||
);
|
||||
probe.passes += 1;
|
||||
// Everything red before this instant is the login environment (the GM/GOD
|
||||
// banners); the arc's own tally starts here.
|
||||
// The arc's red-line tally starts here.
|
||||
probe.red_baseline = red_count(&script);
|
||||
} else {
|
||||
error!(
|
||||
@@ -666,9 +552,8 @@ fn auction_probe(
|
||||
event_baseline,
|
||||
} => {
|
||||
if !sent {
|
||||
// The Browse pane's own call, with every filter at its default: empty name, empty
|
||||
// level boxes (the pane hands strings), no class/subclass/invtype, page 0, not
|
||||
// usable-only, quality ALL (-1).
|
||||
// The Browse pane's call with every filter at its default: empty name and level
|
||||
// strings, no class/subclass/invtype, page 0, not usable-only, quality all (-1).
|
||||
if let Err(e) =
|
||||
script.run(r#"QueryAuctionItems("", "", "", nil, nil, nil, 0, nil, -1)"#)
|
||||
{
|
||||
@@ -866,8 +751,7 @@ fn auction_probe(
|
||||
}
|
||||
}
|
||||
Phase::EnsureItem { since, sent } => {
|
||||
// The bag scan the bag UI itself would do — encoded bag*100+slot, -1 = not there
|
||||
// (the mail probe's own `GetContainerItemLink` idiom).
|
||||
// A bag scan by item link, returning bag*100+slot or -1.
|
||||
let found = script
|
||||
.eval::<i64>(&format!(
|
||||
"for bag=0,4 do local n=GetContainerNumSlots(bag) or 0 \
|
||||
@@ -878,8 +762,7 @@ fn auction_probe(
|
||||
.unwrap_or(-1);
|
||||
if found >= 0 {
|
||||
let (bag, lslot) = (found / 100, found % 100);
|
||||
// Pick it up and drop it in the sell slot — the two calls the sell button's own
|
||||
// OnClick chain makes (`AuctionsItemButton_OnClick` → ClickAuctionSellItemButton).
|
||||
// The two calls the sell button's `AuctionsItemButton_OnClick` chain makes.
|
||||
if let Err(e) = script.run(&format!(
|
||||
"PickupContainerItem({bag}, {lslot}) ClickAuctionSellItemButton()"
|
||||
)) {
|
||||
@@ -914,9 +797,8 @@ fn auction_probe(
|
||||
}
|
||||
}
|
||||
Phase::Attach { since } => {
|
||||
// Six values ALWAYS; the first is nil for an empty slot. `price` is the stack's
|
||||
// vendor value, which is what both deposits are computed from — so wait for it to be
|
||||
// nonzero rather than quoting a deposit off an item template still in flight.
|
||||
// Six values, the first nil for an empty slot. Waits for a nonzero `price` (the
|
||||
// stack's vendor value) so the deposit is not quoted off a template still in flight.
|
||||
let (name, count, price) = script
|
||||
.eval::<(String, i64, i64)>(
|
||||
"local n, _, c, _, _, p = GetAuctionSellItemInfo() return n or \"\", c or 0, p or 0",
|
||||
@@ -928,8 +810,8 @@ fn auction_probe(
|
||||
"return CalculateAuctionDeposit({DURATION_MINUTES})"
|
||||
))
|
||||
.unwrap_or(-1);
|
||||
// The sell pane's own suggested opening price (`AuctionSellItemButton_OnEvent`):
|
||||
// half again the stack's vendor value, floored at one silver.
|
||||
// The sell pane's suggested opening price: `max(100, floor(price * 1.5))`
|
||||
// (`AuctionSellItemButton_OnEvent`).
|
||||
let min_bid = (price * 3 / 2).max(100);
|
||||
let buyout = min_bid * 2;
|
||||
let money = store.0.player_money().unwrap_or(0);
|
||||
@@ -1056,8 +938,7 @@ fn auction_probe(
|
||||
money_done = true;
|
||||
}
|
||||
}
|
||||
// The slot itself: with the item gone from the bag, the pane must not still be
|
||||
// showing it.
|
||||
// With the item gone from the bag, the pane must not still show it.
|
||||
if !slot_done {
|
||||
let occupied = script
|
||||
.eval::<bool>("return GetAuctionSellItemInfo() ~= nil")
|
||||
@@ -1110,12 +991,8 @@ fn auction_probe(
|
||||
return n or \"\", b or -1, o or -1, c or -1"
|
||||
))
|
||||
.unwrap_or_default();
|
||||
// The WIRE side lands a frame before the feed pushes the snapshot, so reading Lua
|
||||
// the moment the entry appears gets the twelve-value null tail — zeros and an
|
||||
// empty name — for a row that genuinely exists. Wait for the VM to agree rather
|
||||
// than printing that as if it were the row's content: a PASS whose own message
|
||||
// shows empty data is the "probe that lies" this file's header warns about, and it
|
||||
// is the reason the Lua read is part of the assertion below and not decoration.
|
||||
// The wire entry lands a frame before the feed pushes the snapshot, so Lua reads
|
||||
// an empty row until the VM agrees; wait for it.
|
||||
let lua_ready = min_bid == probe.min_bid && buyout == probe.buyout;
|
||||
if !lua_ready {
|
||||
if now - since > OWNER_TIMEOUT_SECS {
|
||||
@@ -1131,9 +1008,8 @@ fn auction_probe(
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The item's NAME is allowed to still be empty — it fills in from the ask-once
|
||||
// template cache and the window shows a placeholder until it does. The numbers
|
||||
// come straight off the wire and must agree on both sides.
|
||||
// The name may still be empty until the template cache fills it; the numbers
|
||||
// come off the wire and must agree.
|
||||
let right = entry.item_entry == ITEM_ENTRY
|
||||
&& i64::from(entry.start_bid) == probe.min_bid
|
||||
&& i64::from(entry.buyout) == probe.buyout;
|
||||
@@ -1178,22 +1054,12 @@ fn auction_probe(
|
||||
probe.phase = cancel_at(now);
|
||||
return;
|
||||
}
|
||||
// Re-ask on a cadence: the server drops a second in-flight list request silently.
|
||||
//
|
||||
// **The FIRST ask is the tab click, not the raw binding** (decision 2308). Every
|
||||
// caller of `GetOwnerAuctionItems` in the stock files lives inside the Auctions pane —
|
||||
// its `OnShow` and its two page turners — and the `OnShow` is also the one place that
|
||||
// assigns `AuctionFrameAuctions.page`. Calling the binding cold puts the window in a
|
||||
// state no click can reach, and the reply's `AUCTION_OWNED_LIST_UPDATE` then repaints
|
||||
// a tab whose `page` is still nil: this probe raised
|
||||
// `Blizzard_AuctionUI.lua:837: attempt to perform arithmetic on field 'page'` twice a
|
||||
// run doing exactly that, at WARN, where nothing was looking. Clicking the tab is both
|
||||
// the faithful drive ("exactly as a click would", this file's header) and what makes
|
||||
// step (6c) a real assertion instead of a permanently red one.
|
||||
// Re-ask on a cadence. The first ask is the tab click: every stock caller of
|
||||
// `GetOwnerAuctionItems` is in the Auctions pane, and its `OnShow` sets the
|
||||
// `AuctionFrameAuctions.page` the repaint needs (`Blizzard_AuctionUI.lua:813-837`).
|
||||
if last_ask == 0.0 || now - last_ask > OWNER_REASK_SECS {
|
||||
let ask = if last_ask == 0.0 {
|
||||
// The tab's own OnShow issues the query, once per window session
|
||||
// (`AuctionFrame.gotAuctions`), and sets the page the repaint needs.
|
||||
// The tab's OnShow queries once per window session (`gotAuctions`).
|
||||
"AuctionFrameTab_OnClick(3)"
|
||||
} else {
|
||||
"GetOwnerAuctionItems()"
|
||||
@@ -1301,7 +1167,7 @@ fn auction_probe(
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
// ...and it has to actually leave the page the window is showing.
|
||||
// The row must also leave the page the window shows.
|
||||
if owner_index_of(&auction, auction_id).is_none() {
|
||||
info!("PROBE_AUCTION: PASS (7b cancel-list) — auction {auction_id} is gone from the \"owner\" list");
|
||||
probe.passes += 1;
|
||||
@@ -1317,8 +1183,7 @@ fn auction_probe(
|
||||
probe.phase = Phase::Done;
|
||||
return;
|
||||
}
|
||||
// The re-ask cadence, plus the write-back that keeps `removed` from re-printing its
|
||||
// verdict every tick.
|
||||
// Re-ask on the cadence; writing `removed` back keeps its verdict from re-printing.
|
||||
let ask = last_ask == 0.0 || now - last_ask > OWNER_REASK_SECS;
|
||||
if ask {
|
||||
crate::ui_script::run_or_warn(&script, "GetOwnerAuctionItems()");
|
||||
@@ -1339,9 +1204,7 @@ fn auction_probe(
|
||||
"PROBE_AUCTION: DONE pass={} fail={}",
|
||||
probe.passes, probe.fails
|
||||
);
|
||||
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
|
||||
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
|
||||
// client holding the probe account.
|
||||
// AppExit plus a hard-exit backstop, so a teardown hang cannot keep the account held.
|
||||
exit.write(AppExit::Success);
|
||||
std::thread::spawn(|| {
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
|
||||
@@ -1,45 +1,14 @@
|
||||
//! The bank-arc live probe (`WOW_PROBE_BANK=1`) — decision 0604's end-to-end instrument: log in,
|
||||
//! GM-hop to a PURE banker, drive the whole six-opcode bank wire
|
||||
//! (`CMSG_BANKER_ACTIVATE`→`SMSG_SHOW_BANK`, deposit/withdraw via `CMSG_AUTOBANK_ITEM`/
|
||||
//! `CMSG_AUTOSTORE_BANK_ITEM`, `CMSG_BUY_BANK_SLOT` and its refusal), and print a
|
||||
//! `PROBE_BANK: <step> PASS/FAIL/SKIP <detail>` line per step plus a final
|
||||
//! `PROBE_BANK: DONE pass=<n> fail=<m>` summary. Modeled closely on [`super::probe_mail`] (same
|
||||
//! phase-machine shape, trace style, self-terminating exit) — but unlike mail, the bank steps ride
|
||||
//! [`ClientCommand`]/the descriptor directly (decision 0604: the vault is already streamed, the
|
||||
//! window is first-party, no Lua click surface to drive); the live Lua VM is only touched for the
|
||||
//! bonus refusal step's `UI_ERROR_MESSAGE` observation, the mail probe's own idiom.
|
||||
//! The bank live probe (`WOW_PROBE_BANK=1`): hops to a pure banker, drives the six bank opcodes
|
||||
//! (activate, deposit, withdraw, buy a slot and its out-of-range refusal) and logs one
|
||||
//! `PROBE_BANK: <step> PASS/FAIL/SKIP <detail>` line per step, then `PROBE_BANK: DONE pass=<n>
|
||||
//! fail=<m>`, and exits. Non-combat; the switches are `docs/CONTRIBUTING.md`, "Running it
|
||||
//! unattended".
|
||||
//!
|
||||
//! ## The banker (live-DB verified against the local vmangos, `characters`/
|
||||
//! `mangos` DBs)
|
||||
//! The banker is Soleil Stonemantle (entry 5099, Ironforge), whose `npc_flags` is 256, banker
|
||||
//! only, so `CMSG_BANKER_ACTIVATE` opens the bank with no gossip in between.
|
||||
//!
|
||||
//! Soleil Stonemantle, creature entry 5099, spawn guid 12629, map 0 (Ironforge, The Vault), pos
|
||||
//! `(-4895.64, -1004.66, 504.024)`. Her `creature_template.npc_flags` is **256** exactly — bit 8
|
||||
//! only (`UNIT_NPC_FLAG_BANKER`), no gossip bits — so she is a *pure* banker: the direct
|
||||
//! `CMSG_BANKER_ACTIVATE` route applies with no gossip pre-empt (decision 0604's interact-routing
|
||||
//! note).
|
||||
//!
|
||||
//! ## The funding note — `.modify money` is NOT within a probe account's reach (verified, corrects
|
||||
//! the task brief)
|
||||
//!
|
||||
//! vmangos `Chat.cpp`'s `modifyCommandTable` pins `.modify money` at `SEC_BASIC_ADMIN` (4,
|
||||
//! `Common.h` `AccountTypes`) — one level ABOVE `SEC_GAMEMASTER` (3), the level every probe account
|
||||
//! is provisioned at (docs/METHOD.md). So `.modify money` is refused server-side for a probe login,
|
||||
//! exactly the same floor [`super::probe_taxi`]'s module doc already found for the taxi fare. This
|
||||
//! probe never sends it: step (e) reads the character's live `money` field instead (DB-verified
|
||||
//! this session: `Probeone` carries 100000 copper, `bank_bag_slots` 0 — comfortably funds the
|
||||
//! first purchase-ladder rung, 1000 copper) and SKIPs the purchase gracefully if the live balance
|
||||
//! ever falls short of the next rung's price, rather than sending a command known to be refused.
|
||||
//!
|
||||
//! ## The run recipe
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_DATA=WoW/Data WOW_USER=probe1 WOW_PASS=pprobe1 WOW_CHAR=Probeone \
|
||||
//! WOW_PROBE_BANK=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
|
||||
//! skill).
|
||||
//! Non-combat; GM mode is left exactly as found. An outer `timeout` + grep on `PROBE_BANK:` is the
|
||||
//! whole harness; the probe self-exits ([`super::probes::ProbeExitPlugin`]'s pattern) once DONE.
|
||||
//! The slot purchase funds itself with `.modify money <n>`, which adds `n` copper and needs
|
||||
//! `SEC_BASIC_ADMIN` (vmangos `Chat.cpp:586`).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -52,32 +21,27 @@ use crate::net::{ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectSt
|
||||
use crate::player::Player;
|
||||
use crate::ui_bank::{BankOpen, BankPrices};
|
||||
|
||||
/// Soleil Stonemantle's spawn (vmangos `creature` guid 12629, entry 5099, map 0) — live-DB
|
||||
/// verified this session (module doc).
|
||||
/// Soleil Stonemantle's spawn (vmangos `creature` guid 12629, map 0).
|
||||
const BANKER_AT: [f32; 3] = [-4895.64, -1004.66, 504.024];
|
||||
/// Her creature template entry — the streamed-unit identity check.
|
||||
/// Her creature template entry, one of the two identity checks.
|
||||
const BANKER_ENTRY: u32 = 5099;
|
||||
/// `UNIT_NPC_FLAG_BANKER` (bit 8) — the fallback identity check (either suffices; decision 0604).
|
||||
/// `UNIT_NPC_FLAG_BANKER` (bit 8), the other identity check.
|
||||
const NPC_FLAG_BANKER: u32 = 0x100;
|
||||
/// The server's `CheckBanker`/`GetNPCIfCanInteractWith` range is a few yards; scan generously wide
|
||||
/// so a slightly-off `.go` landing still finds her (the mail probe's `MAILBOX_SCAN_RANGE` idiom).
|
||||
/// Wider than the server's interact range, so a slightly-off `.go` landing still finds her.
|
||||
const SCAN_RANGE: f32 = 12.0;
|
||||
/// The probe's deposit/withdraw fixture: Linen Cloth (cheap, stackable, no durability/equip
|
||||
/// complications).
|
||||
/// The deposit/withdraw fixture: Linen Cloth.
|
||||
const ITEM_ENTRY: u32 = 2589;
|
||||
/// The first player-array bank slot (`PLAYER_FIELD_BANK_SLOT_1`'s wire index — `SLOT_PACK_FIRST`
|
||||
/// (23) + the backpack's 16 slots, decision 0604's addressing note: bank slots are wire 39-62).
|
||||
/// The first bank slot's wire index: `SLOT_PACK_FIRST` (23) plus the backpack's 16; bank slots
|
||||
/// are 39-62.
|
||||
const SLOT_BANK_FIRST: u8 = SLOT_PACK_FIRST + 16;
|
||||
/// The client-side purchase ladder (`BankBagSlotPrices.dbc`, decision 0604) — the fallback used
|
||||
/// only if [`BankPrices`] failed to load; index = slots already purchased (0-based).
|
||||
/// `BankBagSlotPrices.dbc`'s ladder, indexed by slots already bought; used only if
|
||||
/// [`BankPrices`] failed to load.
|
||||
const PRICE_LADDER: [u32; 6] = [1_000, 10_000, 100_000, 250_000, 500_000, 1_000_000];
|
||||
/// Slack added on top of the exact shortfall when the buy-slot step funds itself — the purse can
|
||||
/// move under it (a repair, a vendor sale) between the read and the purchase.
|
||||
/// Slack over the exact shortfall when the buy-slot step funds itself.
|
||||
const FUND_MARGIN_COPPER: u32 = 10_000;
|
||||
/// The purchasable-slot ceiling (decision 0604: `GetNumBankSlots()` reports full at 6).
|
||||
/// The purchasable-slot ceiling (`GetNumBankSlots()` reports full at 6).
|
||||
const MAX_BANK_BAGS: u8 = 6;
|
||||
/// How far past the banker's service range the refusal step teleports (yd, WoW space) — well
|
||||
/// past the handful of yards `GetNPCIfCanInteractWith` checks.
|
||||
/// How far (yd, WoW x) the refusal step hops, well past `GetNPCIfCanInteractWith`'s range.
|
||||
const REFUSAL_OFFSET_X: f32 = 150.0;
|
||||
|
||||
const SETTLE_SECS: f64 = 3.0;
|
||||
@@ -85,8 +49,7 @@ const SCAN_TIMEOUT_SECS: f64 = 15.0;
|
||||
const ACTIVATE_TIMEOUT_SECS: f64 = 10.0;
|
||||
const ITEM_TIMEOUT_SECS: f64 = 10.0;
|
||||
const ACTION_TIMEOUT_SECS: f64 = 8.0;
|
||||
/// The bonus refusal step's event wait — generous but bounded; a miss SKIPs, never FAILs (task
|
||||
/// brief: "if flaky, SKIP with a note rather than FAIL").
|
||||
/// The refusal step's event wait; a miss SKIPs, never FAILs.
|
||||
const REFUSAL_GRACE_SECS: f64 = 6.0;
|
||||
|
||||
pub(crate) struct ProbeBankPlugin;
|
||||
@@ -98,33 +61,26 @@ impl Plugin for ProbeBankPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe's phase machine + the identities discovered along the way (the mail probe's shape:
|
||||
/// `Copy` phase, resource-level bulk state so an arm can freely mutate `probe` without fighting
|
||||
/// the borrow checker over `probe.phase`).
|
||||
/// The probe's phase machine and the identities it discovers.
|
||||
#[derive(Resource, Default)]
|
||||
struct BankProbe {
|
||||
phase: Phase,
|
||||
/// The banker's guid, once streamed in.
|
||||
banker: Option<u64>,
|
||||
/// The item round-tripped through deposit/withdraw (same guid both ways — an item's object
|
||||
/// identity survives a bag/slot move server-side).
|
||||
/// The item round-tripped through deposit/withdraw; its guid survives the move.
|
||||
item_guid: Option<u64>,
|
||||
/// The bank slot index (0-based) the fixture item landed in after deposit.
|
||||
bank_idx: Option<u8>,
|
||||
/// Step (e)'s baseline purchased-slot count `B`, latched once so the verdict can compare
|
||||
/// against it after the buy.
|
||||
/// The buy-slot step's baseline purchased count, latched before the buy.
|
||||
baseline_purchased: u8,
|
||||
baseline_money: u32,
|
||||
/// The next rung's price, resolved once (DBC if loaded, else [`PRICE_LADDER`]).
|
||||
next_cost: u32,
|
||||
passes: u32,
|
||||
fails: u32,
|
||||
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
|
||||
exited: bool,
|
||||
}
|
||||
|
||||
/// Every field is `Copy` — the phase is snapshotted out of the resource each tick (the mail
|
||||
/// probe's `let phase = probe.phase;` idiom), freeing the match arms to mutate `probe` freely.
|
||||
/// `Copy`, so each tick snapshots it and the match arms can mutate `probe`.
|
||||
#[derive(Default, Clone, Copy, PartialEq)]
|
||||
enum Phase {
|
||||
#[default]
|
||||
@@ -137,8 +93,7 @@ enum Phase {
|
||||
Activating {
|
||||
sent_at: f64,
|
||||
},
|
||||
/// Ensuring a backpack item exists to deposit — `.additem` if the bags are empty (step 3
|
||||
/// prep).
|
||||
/// Ensuring a backpack item to deposit, `.additem` if the bags are empty (step 3).
|
||||
EnsureItem {
|
||||
since: f64,
|
||||
sent: bool,
|
||||
@@ -151,18 +106,15 @@ enum Phase {
|
||||
Withdraw {
|
||||
since: f64,
|
||||
},
|
||||
/// `BuyBankSlot` sent (funds permitting); waiting for the purchased-count descriptor delta
|
||||
/// (step 5).
|
||||
/// `BuyBankSlot` sent; waiting for the purchased-count descriptor delta (step 5).
|
||||
BuySlot {
|
||||
since: f64,
|
||||
sent: bool,
|
||||
/// Whether this step has already granted itself the fare with `.modify money`. One shot:
|
||||
/// if the purse is still short after a grant, that is a real defect, not a permission wall.
|
||||
/// Whether the one `.modify money` grant has gone out; still short after it is a FAIL.
|
||||
funded: bool,
|
||||
},
|
||||
/// Step 6 (bonus): teleport out of range, then `BuyBankSlot` the now-stale guid, expecting
|
||||
/// `SMSG_BUY_BANK_SLOT_RESULT` NOTBANKER. `teleported`/`bought` gate the two sends in order;
|
||||
/// `since` resets at each send so the following wait is measured from it, not from entry.
|
||||
/// Step 6: hop out of range, then `BuyBankSlot` the stale guid, expecting
|
||||
/// `SMSG_BUY_BANK_SLOT_RESULT` NOTBANKER; `since` resets at each send.
|
||||
Refusal {
|
||||
since: f64,
|
||||
teleported: bool,
|
||||
@@ -172,22 +124,21 @@ enum Phase {
|
||||
Done,
|
||||
}
|
||||
|
||||
/// Read the Lua-side `ProbeBankEvents` log length (the `UI_ERROR_MESSAGE` hook, the mail probe's
|
||||
/// idiom) — `0` on any eval hiccup (treated as "nothing observed yet", never a panic).
|
||||
/// The length of the Lua `ProbeBankEvents` log (the `UI_ERROR_MESSAGE` hook); `0` on an eval
|
||||
/// error.
|
||||
fn events_len(script: &UiScript) -> i64 {
|
||||
script
|
||||
.eval::<i64>("return table.getn(ProbeBankEvents or {})")
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Read the newest `ProbeBankEvents` entry (the just-fired `UI_ERROR_MESSAGE` text).
|
||||
/// The newest `ProbeBankEvents` entry.
|
||||
fn last_event(script: &UiScript) -> String {
|
||||
script
|
||||
.eval::<String>("return ProbeBankEvents[table.getn(ProbeBankEvents)] or \"\"")
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The first backpack slot holding a nonzero item guid, if any.
|
||||
fn find_pack_item(store: &ObjectStore) -> Option<(u8, u64)> {
|
||||
(0..16u8).find_map(|i| {
|
||||
store
|
||||
@@ -198,7 +149,6 @@ fn find_pack_item(store: &ObjectStore) -> Option<(u8, u64)> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The bank slot index currently holding `guid`, if any.
|
||||
fn find_bank_slot(store: &ObjectStore, guid: u64) -> Option<u8> {
|
||||
(0..24u8).find(|&i| store.0.player_bank_slot(i) == Some(guid))
|
||||
}
|
||||
@@ -220,7 +170,7 @@ fn bank_probe(
|
||||
return; // not in-world yet
|
||||
}
|
||||
let Some(script) = script else {
|
||||
return; // no UI VM this build (headless net-only) — nothing this probe can drive
|
||||
return; // no UI VM in a headless build
|
||||
};
|
||||
let now = time.elapsed_secs_f64();
|
||||
let phase = probe.phase;
|
||||
@@ -230,8 +180,7 @@ fn bank_probe(
|
||||
|
||||
match phase {
|
||||
Phase::Wait => {
|
||||
// The UI_ERROR_MESSAGE hook (step 6's observation channel — the mail probe's exact
|
||||
// pattern), installed up front so it's live well before the refusal step needs it.
|
||||
// Step 6's `UI_ERROR_MESSAGE` hook, installed up front.
|
||||
if let Err(e) = script.run(
|
||||
r#"
|
||||
if not ProbeBankHooked then
|
||||
@@ -439,12 +388,8 @@ fn bank_probe(
|
||||
rung costs {cost}c"
|
||||
);
|
||||
if money < cost && !funded {
|
||||
// Fund the rung and come back next tick. `.modify money <n>` ADDS `n` copper
|
||||
// (vmangos `HandleModifyMoneyCommand`: the arg is `addmoney`, not a set) and
|
||||
// needs SEC_BASIC_ADMIN(4) — which probe accounts have had since they were
|
||||
// actually raised to 6 (0651). This step used to SKIP here, because the
|
||||
// accounts were gmlevel 3 and the grant would have been refused; the whole
|
||||
// buy-slot leg was therefore unverified whenever the purse ran dry.
|
||||
// Fund the rung and come back next tick; `.modify money <n>` adds `n`
|
||||
// copper (vmangos `CharacterCommands.cpp:4477`).
|
||||
let grant = cost - money + FUND_MARGIN_COPPER;
|
||||
info!("PROBE_BANK: (5 buy_slot) purse {money}c < {cost}c — granting {grant}c with `.modify money`");
|
||||
let _ = net.0.send(ClientCommand::Chat {
|
||||
@@ -547,8 +492,7 @@ fn bank_probe(
|
||||
return;
|
||||
}
|
||||
if !bought {
|
||||
// A settle window before firing the stale-guid buy — mirrors step 1's
|
||||
// SETTLE_SECS, long enough for the `.go` + the range-guard close to land.
|
||||
// Let the `.go` and the bank window's range close land first.
|
||||
if now - since < SETTLE_SECS {
|
||||
return;
|
||||
}
|
||||
@@ -595,9 +539,7 @@ fn bank_probe(
|
||||
"PROBE_BANK: DONE pass={} fail={}",
|
||||
probe.passes, probe.fails
|
||||
);
|
||||
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
|
||||
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
|
||||
// client holding the probe account.
|
||||
// `AppExit` plus a hard backstop, so a teardown hang cannot keep the account held.
|
||||
exit.write(AppExit::Success);
|
||||
std::thread::spawn(|| {
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
|
||||
@@ -1,99 +1,27 @@
|
||||
//! The **inside-a-battleground** live probe (`WOW_PROBE_BG=wsg|ab|av`) — the instrument that turns
|
||||
//! "does a battleground work?" from a question nobody can answer into a census anyone can read.
|
||||
//! Inert without the env.
|
||||
//! The inside-a-battleground live probe (`WOW_PROBE_BG=wsg|ab|av`): level, greet a battlemaster,
|
||||
//! queue, take the port through the stock `AcceptBattlefieldPort`, print a census every
|
||||
//! [`CENSUS_EVERY`] from inside, then take the flag and die for the graveyard leg. Inert without
|
||||
//! the env.
|
||||
//!
|
||||
//! ## Why this exists
|
||||
//! One body can never fill a battleground (`battleground_template.min_players_per_team`), so the
|
||||
//! probe sends `.debug bg`, which toggles `BattleGroundMgr::m_testing` and lets a match form with
|
||||
//! one player on either side (`BattleGroundMgr.cpp:557/623/756`). It is a realm-wide, in-memory
|
||||
//! toggle announced to every player, so the probe counts its sends ([`BgProbe::toggles_sent`]) and
|
||||
//! [`leave_testing_as_found`] restores the parity on every road to [`Phase::Done`]. It goes back
|
||||
//! off once inside: while testing is on, the premature-finish countdown never decrements
|
||||
//! (`BattleGround.cpp:337`).
|
||||
//!
|
||||
//! Decisions 1963 / 1972 / 1974 / 1980 built the whole battleground **interface** — the queue, the
|
||||
//! list window, the scoreboard, the always-up readout, the map blips, forty-odd Lua verbs — and
|
||||
//! every one of them off the reference's own FrameXML. What none of them could build is the thing
|
||||
//! on the far side of the port button: **nothing in this tree has ever entered map 489, 529 or
|
||||
//! 30.** `ProbeBgQueuePlugin` stops at the queue by design (it is 2232's login-burst fixture);
|
||||
//! the terrain, the world states, the objects, the spirit guides and the match's own end have
|
||||
//! never been observed at all. So the gaps are not *known* to be gaps — they are unmeasured, and
|
||||
//! an unmeasured subsystem is where a "should work by construction" quietly isn't.
|
||||
//! A census reports the map, area and position, terrain streaming, entity counts by kind, the
|
||||
//! gameobject entries, the raw world-state table, `GetNumWorldStateUI()`,
|
||||
//! `GetBattlefieldStatus(1..3)`, the scoreboard, the nearest spirit guide, the dropped opcodes and
|
||||
//! the Lua event tap's window.
|
||||
//!
|
||||
//! This probe is the measurement. It walks the real player road — level, greet a battlemaster on
|
||||
//! the wire, queue, take the port through the **stock Lua verb** the reference's own dialog calls
|
||||
//! — and then, from inside, prints a structured census every [`CENSUS_EVERY`] until
|
||||
//! [`census_samples`] are in. Every later battleground round is judged against these lines.
|
||||
//! The probe stamps input every frame, so it never exercises the idle handler.
|
||||
//!
|
||||
//! ## The one server-side lever, and why it is needed — and why it is turned back off
|
||||
//!
|
||||
//! A battleground does not start until `min_players_per_team` bodies are queued on each side —
|
||||
//! four for Warsong Gulch on this server's patch, twenty for Alterac Valley
|
||||
//! (`mangos.battleground_template`). One probe can never reach that. vmangos has the lever built
|
||||
//! in: **`.debug bg`** toggles `BattleGroundMgr::m_testing`, and `BattleGroundQueue::CheckNormalMatch`
|
||||
//! then starts a battleground as soon as *either* side has one player
|
||||
//! (`BattleGroundMgr.cpp:557/623/756`). It is `SEC_ADMINISTRATOR` (6), exactly what a probe account
|
||||
//! holds, and it is **in-memory only** — `m_testing` is initialised `false` in the manager's
|
||||
//! constructor, so a server restart clears it whatever happens here.
|
||||
//!
|
||||
//! `.debug bg` is a **toggle, not a set**, and it announces itself to the whole realm
|
||||
//! (`LANG_DEBUG_BG_ON`/`_OFF`). Both facts are reported on the probe's own lines so a run that
|
||||
//! started from the wrong state reads as such rather than as a broken queue.
|
||||
//!
|
||||
//! **The lever is tracked by PARITY, not by state** ([`BgProbe::toggles_sent`]), and put back by
|
||||
//! [`leave_testing_as_found`] — which every road to [`Phase::Done`] goes through, failures
|
||||
//! included. "What is it now?" can only be answered by a world-text reply that may never arrive;
|
||||
//! "how far from where I found it?" needs no reply at all. It is restored **as soon as the probe
|
||||
//! is inside the instance**, because that is the moment the lever has finished its job: it is
|
||||
//! needed for `CheckNormalMatch` to *form* the match, not to run one.
|
||||
//!
|
||||
//! Leaving it on also makes a solo battleground unendable: vmangos decrements its premature-finish
|
||||
//! countdown inside an `else if (!sBattleGroundMgr.isTesting())` branch (`BattleGround.cpp:337`),
|
||||
//! so while testing is on the countdown is armed and then **frozen**. Decision 2290 said the
|
||||
//! opposite, from reading `GetPrematureFinishTime()` without the branch that consumes it; 2296
|
||||
//! corrects it, having watched a run sit in Warsong Gulch for 492 s with `GetBattlefieldWinner()`
|
||||
//! nil the whole way.
|
||||
//!
|
||||
//! ## What the census reports, and why each column is there
|
||||
//!
|
||||
//! | column | the question it answers |
|
||||
//! |---|---|
|
||||
//! | `map` / `area` | did the worldport land, and does the terrain know where we are? |
|
||||
//! | `pos` | is the body at the battleground's own start location, or at the origin? |
|
||||
//! | `terrain` | [`WorldLoadProgress`] — did map 489's ADTs stream, or is the ground missing? |
|
||||
//! | `ents` | units / players / gameobjects / dynamic / corpses actually mirrored into the ECS |
|
||||
//! | `gos` | the gameobject **entries** in range — the flags, the doors, the banners |
|
||||
//! | `states` | the raw world-state table and its scope: the score, the flag carriers, the timer |
|
||||
//! | `alwaysup` | `GetNumWorldStateUI()` — what the reference's readout would actually draw |
|
||||
//! | `status` | `GetBattlefieldStatus(1..3)`, straight out of the VM |
|
||||
//! | `score` | `GetNumBattlefieldScores()` / `GetBattlefieldWinner()` |
|
||||
//! | `guide` | the nearest `SPIRITGUIDE`-flagged unit — the graveyard's resurrect wave |
|
||||
//! | `dropped` | every opcode the codec threw on the floor, by name |
|
||||
//! | `events` | the Lua event tap's window — or an explicit "the tap is broken", never silence |
|
||||
//! | `mapinfo` | (at Report) `GetMapInfo()` and the overlay/landmark counts — whether the battle
|
||||
//! map has anything to **draw**, which `IsShown()` cannot tell you |
|
||||
//!
|
||||
//! There is deliberately **no `lua` column**; see the note at the end of [`census`] for why the one
|
||||
//! that used to be here could not measure what it claimed.
|
||||
//!
|
||||
//! ## What this probe cannot see
|
||||
//!
|
||||
//! It calls [`crate::ui_chat::idle::LastInput::stamp_present`] every frame, which suppresses the
|
||||
//! whole idle band — auto-sit, auto-AFK and camp alike (`ui_chat::idle::idle_action`). That is
|
||||
//! deliberate and necessary (see the note at the top of [`bg_probe`]), but it means the one
|
||||
//! long-running instrument that otherwise sits still for minutes can never exercise the idle
|
||||
//! handler. A regression there has to be caught somewhere else.
|
||||
//!
|
||||
//! ## The run recipe
|
||||
//!
|
||||
//! ```sh
|
||||
//! cd <slot> && WOW_USER=probe7 WOW_PASS=pprobe7 WOW_CHAR=Probeseven \
|
||||
//! WOW_UNATTENDED=1 WOW_NOSOUND=1 WOW_GM=off WOW_PROBE_BG=wsg \
|
||||
//! timeout 600 cargo run -p benilla 2>&1 | grep -E 'PROBE bg:'
|
||||
//! ```
|
||||
//!
|
||||
//! Raise the `timeout` with [`census_samples`]: a default run needs ~5 min including login, and a
|
||||
//! run sized to reach a match's ending needs ~10.
|
||||
//!
|
||||
//! `WOW_GM=off` is not optional here: GM mode re-templates the body's faction to 35, and a
|
||||
//! battleground is the one place where every reaction, every objective and the server's own team
|
||||
//! assignment reads off it (0649, 0679).
|
||||
//!
|
||||
//! Non-combat — the probe never attacks and never stands anywhere contested; with one player in
|
||||
//! the instance there is nobody to fight. Pair with the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill).
|
||||
//! Run it with `WOW_GM=off` (GM mode re-templates the body's faction to 35, and a battleground
|
||||
//! reads every reaction and the team assignment off it) under a `timeout` of about 5 min, or 10
|
||||
//! for a run sized by `WOW_PROBE_BG_SAMPLES` to reach a match's end; the rest is
|
||||
//! `docs/CONTRIBUTING.md`, "Running it unattended". Non-combat: the probe never attacks.
|
||||
|
||||
use bevy::ecs::system::NonSendMut;
|
||||
use bevy::prelude::*;
|
||||
@@ -112,31 +40,24 @@ use crate::ui_battlefield::Battlefield;
|
||||
use crate::ui_dialog_verbs::BattlefieldQueue;
|
||||
use crate::world_state::WorldStates;
|
||||
|
||||
/// One battleground's fixtures: what to queue for, and which battlemaster to greet.
|
||||
///
|
||||
/// All three battlemasters stand within ~35 yd of each other in Stormwind's PvP alcove, so one
|
||||
/// `.go` reaches any of them — which is why the probe finds its NPC by **creature entry** off the
|
||||
/// guid rather than by "the nearest battlemaster", a predicate that would pick whichever of the
|
||||
/// three streamed in first.
|
||||
/// One battleground's fixtures. The three battlemasters stand within ~35 yd of each other in
|
||||
/// Stormwind, so the probe finds its NPC by creature entry, not by nearness.
|
||||
struct Arena {
|
||||
/// `WOW_PROBE_BG`'s value.
|
||||
key: &'static str,
|
||||
/// The Map.dbc row — what `CMSG_BATTLEMASTER_JOIN` carries and what the worldport must land on.
|
||||
/// The Map.dbc row the join carries and the worldport lands on.
|
||||
map: u32,
|
||||
/// The battlemaster's `creature_template.entry` (vmangos `battlemaster_entry`).
|
||||
/// The battlemaster's `creature_template.entry`.
|
||||
npc_entry: u32,
|
||||
/// That NPC's display name, for the probe's own lines.
|
||||
npc_name: &'static str,
|
||||
/// Its spawn — the `.go xyz` target (vmangos `creature`, map 0, Stormwind).
|
||||
/// The battlemaster's spawn on map 0, the `.go xyz` target.
|
||||
at: [f32; 3],
|
||||
/// The objective this battleground's **flag leg** goes for: the gameobject entry to click and
|
||||
/// where it stands on the battleground map (vmangos `gameobject`). `None` for a battleground
|
||||
/// whose objective is not a single clickable flag.
|
||||
/// The flag leg's gameobject entry and its position on the battleground map; `None` where the
|
||||
/// objective is not one clickable flag.
|
||||
flag: Option<(u32, [f32; 3])>,
|
||||
}
|
||||
|
||||
/// The three 1.12 battlegrounds. Levels are not per-arena: [`QUEUE_LEVEL`] clears every bracket
|
||||
/// floor on every content patch, so the probe never has to know which patch the server runs.
|
||||
/// The three 1.12 battlegrounds; [`QUEUE_LEVEL`] clears every bracket floor on every patch.
|
||||
const ARENAS: [Arena; 3] = [
|
||||
Arena {
|
||||
key: "wsg",
|
||||
@@ -144,8 +65,8 @@ const ARENAS: [Arena; 3] = [
|
||||
npc_entry: 14981,
|
||||
npc_name: "Elfarran",
|
||||
at: [-8454.62, 318.85, 120.97],
|
||||
// The **Warsong Flag** at the Horde base — the one an Alliance body carries. Our own
|
||||
// Silverwing Flag (179830) is the capture point, not the pickup.
|
||||
// The Warsong Flag at the Horde base, the one an Alliance body carries; the Silverwing
|
||||
// Flag (179830) is the capture point.
|
||||
flag: Some((179831, [916.02, 1434.40, 345.41])),
|
||||
},
|
||||
Arena {
|
||||
@@ -154,7 +75,7 @@ const ARENAS: [Arena; 3] = [
|
||||
npc_entry: 15008,
|
||||
npc_name: "Lady Hoteshem",
|
||||
at: [-8420.48, 328.71, 120.89],
|
||||
// Arathi Basin's objective is five capturable banners, not a carried flag.
|
||||
// Arathi Basin's objectives are five banners, not a carried flag.
|
||||
flag: None,
|
||||
},
|
||||
Arena {
|
||||
@@ -168,81 +89,42 @@ const ARENAS: [Arena; 3] = [
|
||||
},
|
||||
];
|
||||
|
||||
/// The level the probe body is raised to.
|
||||
///
|
||||
/// 60 rather than the 25 `probe_bg_queue` uses, because Alterac Valley's floor is **51** on every
|
||||
/// patch that ships it and the bracket is checked at the HELLO — a body under it is refused there,
|
||||
/// silently, so the list never arrives and the failure reads like a broken client.
|
||||
/// The probe body's level: Alterac Valley's floor is 51, and a body under the bracket is refused
|
||||
/// at the hello with no reply.
|
||||
const QUEUE_LEVEL: u32 = 60;
|
||||
|
||||
/// How many times to drop and re-take the queue before giving up (see the `Joined` arm).
|
||||
/// How many times to drop and re-take the queue before giving up.
|
||||
const MAX_REJOINS: u32 = 3;
|
||||
|
||||
/// The `.go`'s settle radius before the battlemaster scan. Not a gate — the server applies its own
|
||||
/// interaction check; this only keeps the probe from greeting a battlemaster it has not reached.
|
||||
/// The settle radius before greeting; the server applies its own interaction range.
|
||||
const NEAR_YD: f32 = 20.0;
|
||||
|
||||
/// `PLAYER_FLAGS_GM` — the bit that says the body carries GM mode's faction-35 re-template
|
||||
/// (`crate::probe_shield` names the same constant).
|
||||
/// `PLAYER_FLAGS_GM`: the body carries GM mode's faction-35 re-template.
|
||||
const PLAYER_FLAGS_GM: u32 = 0x0000_0008;
|
||||
|
||||
/// How long the exit waits for the battleground leave to land before going anyway. The teleport
|
||||
/// out is a server round trip plus a worldport; five seconds is far more than a local server needs
|
||||
/// and is a backstop, not a budget.
|
||||
/// How long the exit waits for the leave's worldport before going anyway.
|
||||
const LEAVE_GRACE: f64 = 5.0;
|
||||
|
||||
/// How long to wait for `probe_shield`'s `.gm off` to land before refusing the run.
|
||||
///
|
||||
/// **Sized by that module's own sequencing, not by guesswork** — five seconds was measured as too
|
||||
/// tight and refused a run that had `WOW_GM=off` set and was about to comply. `probe_shield` waits
|
||||
/// up to `NAME_WAIT_SECS` (3 s) for the body's name to resolve, then spaces its commands
|
||||
/// `STEP_SECS` (0.8 s) apart with the god line *before* the `.gm off` (the shield must be up first,
|
||||
/// 0679), and the flag only clears once the server's field update comes back. Twenty seconds
|
||||
/// clears all of that with room, and still fails fast against a run that is never going to comply.
|
||||
/// How long to wait for `probe_shield`'s `.gm off` to land: it waits up to 3 s for the body's
|
||||
/// name, sends its commands 0.8 s apart with `.gm off` after the god line, and the flag clears
|
||||
/// only on the server's field update.
|
||||
const GM_DROP_WAIT: f64 = 20.0;
|
||||
|
||||
/// How long to keep listening for `.debug bg`'s world-text reply before giving up and saying so.
|
||||
///
|
||||
/// The reply crosses the network, the chat pipeline and the Lua event dispatch before it reaches
|
||||
/// the tap. The old code took **one** destructive look 1.5 s after the send, which is both too
|
||||
/// narrow to be reliable and, being a single sample, unable to tell "not yet" from "never".
|
||||
/// How long to listen for `.debug bg`'s world-text reply, which crosses the network, the chat
|
||||
/// pipeline and the Lua event dispatch before it reaches the tap.
|
||||
const TOGGLE_REPLY_WINDOW: f64 = 6.0;
|
||||
|
||||
/// How often a census line is printed once inside.
|
||||
const CENSUS_EVERY: f64 = 12.0;
|
||||
|
||||
/// How many census samples to take before reporting and leaving.
|
||||
///
|
||||
/// **Sized by the preparation phase, not by taste.** A battleground does not begin when you land
|
||||
/// in it: vmangos holds every arrival behind closed doors for `BG_START_DELAY_2M` = **120 s**
|
||||
/// (`BattleGround.cpp:248`, the four `BG_STARTING_EVENT_*` steps at 2 min / 1 min / 30 s / go),
|
||||
/// and `.debug bg` does not shorten it. A window that ended before then would census nothing but
|
||||
/// the pen and report it as the whole battleground.
|
||||
///
|
||||
/// Twelve × 12 s = **144 s**: the full prep, the doors dropping at ~116 s, and two samples past
|
||||
/// them, which leaves room for the flag and graveyard legs inside the same run. Reaching the
|
||||
/// *ending* is a different window — see [`census_samples`].
|
||||
/// 12 × 12 s = 144 s: vmangos holds arrivals behind closed doors for `BG_START_DELAY_2M` (120 s,
|
||||
/// `BattleGround.cpp:248`), which `.debug bg` does not shorten, plus two samples past the doors.
|
||||
const CENSUS_SAMPLES_DEFAULT: u32 = 12;
|
||||
|
||||
/// The census sample count, overridable with `WOW_PROBE_BG_SAMPLES`.
|
||||
///
|
||||
/// The default covers the preparation phase and the doors. **The reason it is a dial** is the end
|
||||
/// of a match: with one body inside, vmangos starts its `BattleGround.PrematureFinishTimer` (5 min,
|
||||
/// because `CreateNewBattleGround` reads `min_players_per_team` from the template and not from the
|
||||
/// testing override, so a solo match is permanently under-populated) and then ends the battleground
|
||||
/// **with no winner** — `WINNER_NONE` = 2, not a team (`BattleGroundDefines.h:204`).
|
||||
///
|
||||
/// **Sizing it is arithmetic, not taste.** The countdown is armed when the battleground reaches
|
||||
/// `STATUS_IN_PROGRESS` — the doors, ≈T+120 s — and runs 300 s from there, so the ending lands at
|
||||
/// ≈T+420 s ≈ **35 samples**. `WOW_PROBE_BG_SAMPLES=36` reaches it; the 30 this doc used to
|
||||
/// recommend stops 60 s short and reports "the match never ended", which reads as a defect rather
|
||||
/// than as a window that was too small.
|
||||
///
|
||||
/// **Alterac Valley can never be ended this way at all**: the whole premature-finish block is
|
||||
/// guarded by `GetTypeID() != BATTLEGROUND_AV` (`BattleGround.cpp:318`).
|
||||
///
|
||||
/// A value below 1 is meaningless and is clamped to 1 — the first sample is also where the testing
|
||||
/// lever is put back, and a run that never censuses never restores it.
|
||||
/// The census sample count, `WOW_PROBE_BG_SAMPLES`, at least 1 (the first sample restores the
|
||||
/// testing lever). A solo match stays under the template's `min_players_per_team`, so vmangos ends
|
||||
/// it with no winner (`WINNER_NONE` = 2, `BattleGroundDefines.h:204`) 300 s after the doors,
|
||||
/// about T+420 s: 36 samples reach it. Alterac Valley never ends this way (`BattleGround.cpp:318`).
|
||||
fn census_samples() -> u32 {
|
||||
std::env::var("WOW_PROBE_BG_SAMPLES")
|
||||
.ok()
|
||||
@@ -251,9 +133,8 @@ fn census_samples() -> u32 {
|
||||
.max(1)
|
||||
}
|
||||
|
||||
/// How often the ghost leg samples, and for how long. The battleground resurrect wave is a 30 s
|
||||
/// server cycle (`BattleGround::Update`'s `m_lastResurrectTime`), so eleven 4 s samples cover a
|
||||
/// full wave and a little of the next — enough to see the clock arm, tick down, and fire.
|
||||
/// The ghost leg's sample period: the resurrect wave is a 30 s server cycle
|
||||
/// (`BattleGround::Update`'s `m_lastResurrectTime`), so eleven 4 s samples cover one full wave.
|
||||
const GHOST_SAMPLE_EVERY: f64 = 4.0;
|
||||
/// See [`GHOST_SAMPLE_EVERY`].
|
||||
const GHOST_SAMPLES: u32 = 11;
|
||||
@@ -261,22 +142,14 @@ const GHOST_SAMPLES: u32 = 11;
|
||||
/// How many 3 s samples the flag leg takes after the use.
|
||||
const FLAG_SAMPLES: u32 = 5;
|
||||
|
||||
/// `SPIRITGUIDE` — `UNIT_NPC_FLAGS` bit 6, the flag the reference's area-spirit-healer acquire
|
||||
/// scan `0x4924c0` keys on.
|
||||
/// `SPIRITGUIDE`, `UNIT_NPC_FLAGS` bit 6: the flag the reference's area-spirit-healer scan
|
||||
/// `0x4924c0` keys on.
|
||||
const NPC_FLAG_SPIRITGUIDE: u32 = 1 << 6;
|
||||
|
||||
/// The **event tap** — a Lua frame the probe installs on entry that records every battleground
|
||||
/// event the reference's own interface listens for, with its `arg1`.
|
||||
///
|
||||
/// This exists because the first run's finding could not be read off the log at all. vmangos sends
|
||||
/// three `CHAT_MSG_BG_SYSTEM_NEUTRAL` lines during the countdown (`BattleGround.cpp:383/392/400`,
|
||||
/// the one-minute / half-minute / has-begun `m_startMessageIds`) and a `SMSG_PLAY_SOUND`
|
||||
/// (`PlaySoundToAll(SOUND_BG_START)`), and **nothing appeared** — but an absent log line is not an
|
||||
/// absent event, because a chat kind that reaches the VM logs nothing on the way. Silence in a log
|
||||
/// is not evidence; a tap on the event itself is.
|
||||
///
|
||||
/// It is written in the reference's own Lua dialect (`this`/`event`/`arg1` globals in `OnEvent`,
|
||||
/// no `ipairs` over a literal) so it exercises the same road a 1.12 addon would.
|
||||
/// A Lua frame that records every battleground event the stock interface listens for, with its
|
||||
/// `arg1`: a chat kind that reaches the VM logs nothing, so only a tap shows the countdown's
|
||||
/// `CHAT_MSG_BG_SYSTEM_NEUTRAL` lines (`BattleGround.cpp:383-418`). Written in 1.12 dialect
|
||||
/// (`this`/`event`/`arg1` globals, no `ipairs` over a literal).
|
||||
const EVENT_TAP: &str = r#"
|
||||
BenillaBgLog = {};
|
||||
BenillaBgTap = CreateFrame("Frame");
|
||||
@@ -299,18 +172,12 @@ BenillaBgTap:RegisterEvent("PLAYER_ALIVE");
|
||||
BenillaBgTap:RegisterEvent("ZONE_CHANGED_NEW_AREA");
|
||||
"#;
|
||||
|
||||
/// Drain the tap: everything it recorded since the last census, then empty it.
|
||||
///
|
||||
/// **It reports the tap's own liveness first**, and that is not decoration. `BenillaBgLog` is set
|
||||
/// on `EVENT_TAP`'s first line, so a tap that raised *after* that line — or a tap installed into a
|
||||
/// VM that has since been thrown away and minted fresh — leaves a table that drains cleanly to `""`
|
||||
/// forever. An empty string would then be indistinguishable from "the battleground fired nothing",
|
||||
/// which is the one answer this instrument must never give by accident. The sentinel makes the
|
||||
/// difference readable, and [`bg_probe`] re-installs on seeing it.
|
||||
/// What the drain returns when the tap's globals are gone (the VM was minted fresh), so a dead tap
|
||||
/// never reads as "no events"; [`bg_probe`] re-installs on seeing it.
|
||||
const TAP_GONE: &str = "<tap-gone>";
|
||||
|
||||
/// See [`TAP_GONE`]: both globals are checked, because the frame is what actually receives events
|
||||
/// and the table is what holds them — either one missing means the readings are not trustworthy.
|
||||
/// Drain the tap since the last census and empty it; both globals are checked, the frame that
|
||||
/// receives and the table that holds.
|
||||
const EVENT_DRAIN: &str = r#"
|
||||
local out = "";
|
||||
if not BenillaBgLog or not BenillaBgTap then return "<tap-gone>" end
|
||||
@@ -331,52 +198,33 @@ impl Plugin for ProbeBgPlugin {
|
||||
#[derive(Resource, Default)]
|
||||
struct BgProbe {
|
||||
phase: Phase,
|
||||
/// The Lua event tap is in and believed live. **Not a latch** — the drain re-checks the tap's
|
||||
/// own global every sample and clears this if the VM went away underneath it, because the
|
||||
/// interface VM is minted fresh on every world entry (`ui_script::lifecycle::mint_entry_vm`)
|
||||
/// and a tap installed into the boot VM is silently gone after the port. A dead tap that still
|
||||
/// reads `tap_installed` prints `EVENTS (none)` forever, which is the exact inversion this
|
||||
/// instrument exists to avoid.
|
||||
/// The Lua event tap is in and believed live; cleared when a drain finds it gone, because the
|
||||
/// interface VM is minted fresh on every world entry (`ui_script::lifecycle::mint_entry_vm`).
|
||||
tap_installed: bool,
|
||||
/// The tap raised on install and cannot be trusted. Kept apart from `tap_installed` so an
|
||||
/// `EVENTS` line can say *"the tap is broken"* rather than *"the battleground fired nothing"*.
|
||||
/// The tap raised on install, so an `EVENTS` line says the tap is broken, not that nothing
|
||||
/// fired.
|
||||
tap_broken: bool,
|
||||
/// **How many `.debug bg` toggles this run has sent — parity, not state.**
|
||||
///
|
||||
/// `.debug bg` is a toggle, so "what is it now?" is the wrong question and the one the old code
|
||||
/// asked: it can only be answered by reading a world-text reply that may never arrive. "How far
|
||||
/// from where I found it?" is always answerable, needs no reply, and is what a restore actually
|
||||
/// needs. An odd count means this run left the lever flipped and owes the realm one more send;
|
||||
/// an even count means it is already as found — including the normal road, where the run turns
|
||||
/// testing on to queue and off again once inside.
|
||||
/// How many `.debug bg` toggles this run has sent: an odd count owes the realm one more send.
|
||||
/// Parity needs no reply; the lever's state can only be read from a reply that may never come.
|
||||
toggles_sent: u32,
|
||||
/// What the tap last said the lever reads: `Some(true)` = on ("…for debugging"), `Some(false)` =
|
||||
/// off ("…normal playercount"), `None` = no reply seen yet. Consumed once so a second pass
|
||||
/// cannot re-decide on a stale marker.
|
||||
/// What the tap last said the lever reads: on ("…for debugging"), off ("…normal
|
||||
/// playercount"), or no reply yet; consumed once.
|
||||
testing_seen: Option<bool>,
|
||||
/// The lever was **read back** as on before the join, rather than merely assumed. The `Joined`
|
||||
/// failure text says which, because "the flag was confirmed ON, so look elsewhere" sends the
|
||||
/// reader past the actual suspect when nothing was ever confirmed.
|
||||
/// The lever was read back as on before the join, not assumed; the `Joined` failure says which.
|
||||
testing_confirmed: bool,
|
||||
/// How many times the queue has been dropped and re-taken (see the `Joined` arm).
|
||||
rejoins: u32,
|
||||
/// The `AppExit` has been written — `Done` is re-entered every frame until the app actually
|
||||
/// stops, and a second exit write would be noise.
|
||||
/// The `AppExit` has been written; `Done` is re-entered every frame until the app stops.
|
||||
exited: bool,
|
||||
/// When [`Phase::Done`] was first seen, so the exit can wait for the leave to land.
|
||||
done_at: Option<f64>,
|
||||
/// When the `Wait` arm first saw a body, so the GM-mode gate can time out rather than hang.
|
||||
/// When the `Wait` arm first saw a body, so the GM-mode gate can time out.
|
||||
waiting_since: Option<f64>,
|
||||
/// Events drained before the first census (during the toggle read-back and the queue road).
|
||||
/// Carried rather than discarded: the old code threw them away and then attributed them to the
|
||||
/// first 12 s census window, which is a reading about the wrong interval.
|
||||
/// Events drained before the first census, reported with it rather than dropped.
|
||||
pending_events: String,
|
||||
}
|
||||
|
||||
/// `Wait` → (levelled, hop sent) `Hopped` → (`.debug bg` sent) `Toggled` → (reply read, testing
|
||||
/// confirmed on, battlemaster greeted) `Greeted` →
|
||||
/// (join sent) `Joined` → (the port taken) `Ported` → (map 489 reached) `Inside` →
|
||||
/// (`.die` sent) `Dying` → (released, ghost at the graveyard) `Ghost` → `Done`.
|
||||
/// `Wait` → `Hopped` → `Toggled` → `Scanning` → `Greeted` → `Joined` (↔ `Rejoining`) → `Ported` →
|
||||
/// `Inside` → `FlagHop` → `FlagUsed` (a flag arena only) → `Dying` → `Ghost` → `Done`.
|
||||
#[derive(Default, PartialEq)]
|
||||
enum Phase {
|
||||
#[default]
|
||||
@@ -388,10 +236,7 @@ enum Phase {
|
||||
sent_at: f64,
|
||||
corrected: bool,
|
||||
},
|
||||
/// Looking for the battlemaster, **after** the testing lever is settled. Split out of `Toggled`
|
||||
/// so the reply read happens once: the old code re-ran its destructive drain every frame of the
|
||||
/// 15 s scan, which threw away every tap event that arrived during it and re-printed a
|
||||
/// "TESTING unread" warning over the "TESTING on" line it had already printed.
|
||||
/// Looking for the battlemaster once the testing lever is settled.
|
||||
Scanning {
|
||||
since: f64,
|
||||
},
|
||||
@@ -431,8 +276,7 @@ enum Phase {
|
||||
Done,
|
||||
}
|
||||
|
||||
/// Which battleground this run is for — `WOW_PROBE_BG`'s value, defaulting to Warsong Gulch (the
|
||||
/// smallest, fastest and the only one whose objectives fit in one census window).
|
||||
/// The battleground `WOW_PROBE_BG` names, Warsong Gulch by default.
|
||||
fn arena() -> &'static Arena {
|
||||
let want = std::env::var("WOW_PROBE_BG").unwrap_or_default();
|
||||
ARENAS
|
||||
@@ -441,8 +285,7 @@ fn arena() -> &'static Arena {
|
||||
.unwrap_or(&ARENAS[0])
|
||||
}
|
||||
|
||||
/// A GM dot-command on the real chat wire; every reply is echoed by `net` as
|
||||
/// `server says — …`, so a command that did nothing is visible as such.
|
||||
/// A GM dot-command on the chat wire; `net` logs every reply.
|
||||
fn gm(net: &NetCommands, text: impl Into<String>) {
|
||||
let _ = net.0.send(ClientCommand::Chat {
|
||||
kind: ChatKind::Say,
|
||||
@@ -451,12 +294,8 @@ fn gm(net: &NetCommands, text: impl Into<String>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Drain the event tap, distinguishing its three outcomes rather than flattening them to `""`.
|
||||
///
|
||||
/// Returns the events, or a bracketed reason. A `TAP_GONE` sentinel also **clears
|
||||
/// `tap_installed`**, so the next frame re-installs into whatever VM is live now — which is the
|
||||
/// self-heal for the one-frame world-entry window where the tap can land in a boot VM that is
|
||||
/// about to be discarded (`ui_script::lifecycle`).
|
||||
/// Drain the event tap: the events, or a bracketed reason. [`TAP_GONE`] clears `tap_installed` so
|
||||
/// the next frame re-installs into the live VM.
|
||||
fn drain_events(probe: &mut BgProbe, script: &mut UiScript) -> String {
|
||||
match script.eval::<String>(EVENT_DRAIN) {
|
||||
Ok(s) if s.trim() == TAP_GONE => {
|
||||
@@ -468,15 +307,8 @@ fn drain_events(probe: &mut BgProbe, script: &mut UiScript) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the realm's `.debug bg` lever back if **this run** is the one that moved it.
|
||||
///
|
||||
/// Every road to [`Phase::Done`] goes through this, and that is the whole point. `.debug bg` is a
|
||||
/// realm-global, in-memory toggle (`BattleGroundMgr::ToggleTesting`) that announces itself to every
|
||||
/// player in the world, and it is a **toggle, not a set** — so a run that ends without restoring it
|
||||
/// hands the next run the opposite of what it expects. That is not hypothetical: it is how runs 2,
|
||||
/// 4 and 5 of this probe failed (2290 §2). The old code restored it at exactly one place, an
|
||||
/// equality test on the census counter, which every failure path and every
|
||||
/// `WOW_PROBE_BG_SAMPLES < DOORS_SAMPLE` run skipped silently.
|
||||
/// Put the realm's `.debug bg` lever back if this run moved it. Every road to [`Phase::Done`] goes
|
||||
/// through here: the command is a realm-wide toggle (`BattleGroundMgr::ToggleTesting`), not a set.
|
||||
fn leave_testing_as_found(probe: &mut BgProbe, net: &NetCommands) {
|
||||
if probe.toggles_sent % 2 == 1 {
|
||||
info!(
|
||||
@@ -493,8 +325,7 @@ fn leave_testing_as_found(probe: &mut BgProbe, net: &NetCommands) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Send `.debug bg` and count it. **Every** send goes through here — the parity in
|
||||
/// [`leave_testing_as_found`] is only as good as the counting.
|
||||
/// Send `.debug bg` and count it; every send must go through here for the restore's parity.
|
||||
fn toggle_testing(probe: &mut BgProbe, net: &NetCommands) {
|
||||
probe.toggles_sent += 1;
|
||||
gm(net, ".debug bg");
|
||||
@@ -506,14 +337,9 @@ fn finish(probe: &mut BgProbe, net: &NetCommands) {
|
||||
probe.phase = Phase::Done;
|
||||
}
|
||||
|
||||
/// A world state, distinguishing **absent** from **zero**.
|
||||
///
|
||||
/// [`WorldStates::get`] is reference-faithful: a key the server never sent reads `0`, because that
|
||||
/// is what the real client's table does. That is right for the client and wrong for an instrument —
|
||||
/// `ws2338=0` reads as a well-formed "the flag is at its base" when it actually means "no
|
||||
/// `SMSG_INIT_WORLD_STATES` ever carried this key". vmangos writes only 1 or 2 into the flag rows
|
||||
/// (`BattleGroundWS.cpp:509-515`, all eight call sites), so a `0` there is *always* a missing
|
||||
/// reading — and the person reading the log is exactly the person who does not know that yet.
|
||||
/// A world state, or `absent`: [`WorldStates::get`] reads a never-sent key as `0`, as the
|
||||
/// reference's table does, and vmangos writes only 1 or 2 into the flag rows
|
||||
/// (`BattleGroundWS.cpp:509-515`).
|
||||
fn ws(states: &WorldStates, key: u32) -> String {
|
||||
states
|
||||
.pairs()
|
||||
@@ -521,7 +347,6 @@ fn ws(states: &WorldStates, key: u32) -> String {
|
||||
.map_or_else(|| "absent".to_string(), |(_, v)| v.to_string())
|
||||
}
|
||||
|
||||
// One Bevy system's full input set — the guard-poi probe's shape, plus the census reads.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn bg_probe(
|
||||
time: ProbeClock,
|
||||
@@ -547,20 +372,12 @@ fn bg_probe(
|
||||
let arena = arena();
|
||||
let now = time.elapsed_secs_f64();
|
||||
|
||||
// **This probe stands in for a player who is AT the keyboard.** benilla implements the
|
||||
// reference's idle handler faithfully (`ui_chat::idle`: auto-sit then auto-AFK at 300 000 ms
|
||||
// of no input), and vmangos removes an AFK player from a battleground outright
|
||||
// (`Player::ToggleAFK` → `LeaveBattleground`). Two correct behaviours, one on each side, and
|
||||
// together they eject an unattended probe five minutes in — which is exactly what the first
|
||||
// long run hit: at t=300 s the census read Stormwind, not Warsong Gulch, and the chat said
|
||||
// "You are now AFK". A battleground match cannot be watched to its end without this.
|
||||
// The probe stands in for a player at the keyboard: auto-AFK comes at 300 s of no input
|
||||
// (`ui_chat::idle`), and vmangos removes an AFK player from a battleground
|
||||
// (`Player.cpp:1741-1742`).
|
||||
idle.stamp_present(std::time::Duration::from_secs_f64(now));
|
||||
|
||||
// **The tap goes in as soon as there is a VM to put it in, and not before.** The probe's own
|
||||
// first frame is a world-entry frame: the in-game interface has not materialized yet, so an
|
||||
// install at Setup gets `no VM` and every later `EVENTS` line reads `(none)` — which looks
|
||||
// exactly like "the battleground fired no events" and is the most misleading answer this
|
||||
// instrument could give. Retried every frame until it lands; one bool test once it has.
|
||||
// The tap goes in on the first frame with a VM; the world-entry frame has none yet.
|
||||
if !probe.tap_installed {
|
||||
if let Some(script) = script.as_deref_mut() {
|
||||
match script.eval::<()>(EVENT_TAP) {
|
||||
@@ -569,8 +386,8 @@ fn bg_probe(
|
||||
info!("PROBE bg: TAP installed");
|
||||
}
|
||||
Err(e) => {
|
||||
probe.tap_installed = true; // a raise will not fix itself; say so once
|
||||
probe.tap_broken = true; // …and every later EVENTS line says so too
|
||||
probe.tap_installed = true; // a raise will not fix itself
|
||||
probe.tap_broken = true;
|
||||
error!("PROBE bg: TAP FAILURE — {e}");
|
||||
}
|
||||
}
|
||||
@@ -579,20 +396,9 @@ fn bg_probe(
|
||||
|
||||
match probe.phase {
|
||||
Phase::Wait => {
|
||||
// **GM mode makes every objective in this probe fail silently, so refuse to start in
|
||||
// it.** vmangos's `Player::CanUseBattleGroundObject` opens with
|
||||
// `if (IsGameMaster()) return false;` (`Player.cpp:20576`), which means the flag click,
|
||||
// the Arathi Basin banners and Alterac Valley's towers are all rejected server-side
|
||||
// with no reply — while the queue, the port, the census, the world states and the
|
||||
// scoreboard all keep working perfectly. That is the worst possible failure shape: a
|
||||
// run that looks 90% right and is wrong exactly where the objectives are.
|
||||
//
|
||||
// MEASURED, by getting it wrong: a run without `WOW_GM=off` reached the Warsong Flag at
|
||||
// 1.8 yd, sent `CMSG_GAMEOBJ_USE`, and reported `ws2339=1` with no banner aura and no
|
||||
// `CHAT_MSG_BG_SYSTEM_ALLIANCE` — indistinguishable, from the probe's own lines, from a
|
||||
// client that cannot pick up flags. `probe_shield` drops GM mode when `WOW_GM=off` is
|
||||
// set (0679), so this WAITS for the flag to clear rather than refusing outright: the
|
||||
// shield arms and drops GM a few hundred ms into the world, after this arm first runs.
|
||||
// No start in GM mode: `CanUseBattleGroundObject` refuses a GM every objective with no
|
||||
// reply (`Player.cpp:20576`) while the queue and census still work. Under
|
||||
// `WOW_GM=off`, `probe_shield` drops GM mode a moment into the world, so this waits.
|
||||
if store.0.player_flags() & PLAYER_FLAGS_GM != 0 {
|
||||
let since = *probe.waiting_since.get_or_insert(now);
|
||||
if now - since < GM_DROP_WAIT {
|
||||
@@ -610,28 +416,20 @@ fn bg_probe(
|
||||
finish(&mut probe, &net);
|
||||
return;
|
||||
}
|
||||
// Revive first, unconditionally: `CanInteractWithNPC` refuses a dead player outright,
|
||||
// and the refusal surfaces as an anticheat line about an "invalid creature" — which
|
||||
// reads like a wrong guid, not like a corpse (`probe_bg_queue`'s note).
|
||||
// Revive first: `CanInteractWithNPC` refuses a dead player, logged as an "invalid
|
||||
// creature".
|
||||
gm(&net, ".revive");
|
||||
// **Wait for the real level rather than assuming one.** `unit_level()` is `None` until
|
||||
// `UNIT_FIELD_LEVEL` is in the store, and this arm fires on the first frame the
|
||||
// `SelfPlayer` entity is queryable. Reading a missing field as `0` sends
|
||||
// `.levelup 60` to a body that may already be 60 — which makes it **120**, above every
|
||||
// bracket ceiling (`battleground_template.max_lvl = 60`), and the run then dies at
|
||||
// `Greeted` blaming a level *under* the floor. A missing reading is not a zero.
|
||||
// Wait for the real level: a missing one read as 0 would level a 60 to 120, above
|
||||
// every bracket ceiling (`battleground_template.max_lvl = 60`).
|
||||
let Some(level) = store.0.unit_level() else {
|
||||
return;
|
||||
};
|
||||
if level < QUEUE_LEVEL {
|
||||
gm(&net, format!(".levelup {}", QUEUE_LEVEL - level));
|
||||
}
|
||||
// **Deserter first.** Leaving a battleground any way but "the match ended" earns
|
||||
// spell **26013** (vmangos `Player.cpp:2178/18816`, `Battleground.CastDeserter`
|
||||
// defaults true), and `HandleBattlemasterJoinOpcode` then refuses the join outright
|
||||
// (`BG_GROUPJOIN_DESERTERS`) — so the *second* run of this probe never queues at all,
|
||||
// and reads as "the queue is broken" rather than "the last run left a debuff". Found
|
||||
// the hard way; cleared unconditionally, because a body without it does not care.
|
||||
// Clear Deserter (26013): leaving before the match ends casts it
|
||||
// (`Player.cpp:2178/18816`), and the join handler then refuses the queue
|
||||
// (`BG_GROUPJOIN_DESERTERS`, `BattleGroundHandler.cpp:176`).
|
||||
gm(&net, ".unaura 26013");
|
||||
let [x, y, z] = arena.at;
|
||||
info!(
|
||||
@@ -645,15 +443,9 @@ fn bg_probe(
|
||||
if now - sent_at < 3.0 {
|
||||
return; // post-teleport settle: let the battlemasters stream in
|
||||
}
|
||||
// **The lever goes here, not in Setup, and its reply is read before anything queues.**
|
||||
// `.debug bg` is a TOGGLE (`BattleGroundMgr::ToggleTesting`), so a blind send is not
|
||||
// idempotent and a run that died before its own undo flips the next one the wrong way.
|
||||
// Correcting it *after* joining does not work either, and that is the part worth
|
||||
// writing down: vmangos evaluates a queue only when something SCHEDULES an update
|
||||
// (`BattleGroundMgr::Update` drains `m_queueUpdateScheduler`, which a join or a leave
|
||||
// fills — `BattleGroundMgr.cpp:1005-1028`). Flipping the flag under a queue entry that
|
||||
// is already sitting there schedules nothing, so the entry waits forever. Hence: get
|
||||
// the flag right first, then join once.
|
||||
// The lever is settled before the join: vmangos evaluates a queue only when a join or
|
||||
// a leave schedules it (`BattleGroundMgr.cpp:1005-1028`), so flipping it under a
|
||||
// waiting entry does nothing.
|
||||
toggle_testing(&mut probe, &net);
|
||||
probe.phase = Phase::Toggled {
|
||||
sent_at: now,
|
||||
@@ -661,18 +453,10 @@ fn bg_probe(
|
||||
};
|
||||
}
|
||||
Phase::Toggled { sent_at, corrected } => {
|
||||
// **Drain every frame and keep what comes back**, rather than taking one destructive
|
||||
// look 1.5 s in. The reply is a world text that crosses the network, reaches the chat
|
||||
// pipeline and then the tap; a single window wide enough to be reliable is wider than
|
||||
// one worth waiting on every run, and a single window narrow enough to be quick misses
|
||||
// the reply often enough to matter — a missed reply is how this probe misdiagnoses
|
||||
// itself. Anything drained here that is *not* the reply is kept for the first census
|
||||
// instead of being thrown on the floor.
|
||||
// Drain every frame until the reply shows; the rest is kept for the first census.
|
||||
if let Some(s) = script.as_deref_mut() {
|
||||
let seen = drain_events(&mut probe, s);
|
||||
// The LAST marker in the chunk wins, because both can appear in one drain when the
|
||||
// correcting send's reply arrives alongside the first. `contains` cannot express
|
||||
// that and was the reason a corrected run could read its own first reply again.
|
||||
// The last marker wins: a correcting send's reply can share a drain with the first.
|
||||
let on = seen.rfind("debugging");
|
||||
let off = seen.rfind("normal playercount");
|
||||
match (on, off) {
|
||||
@@ -708,10 +492,7 @@ fn bg_probe(
|
||||
};
|
||||
}
|
||||
None if now - sent_at > TOGGLE_REPLY_WINDOW => {
|
||||
// Unread is a THIRD outcome, and it is carried forward rather than rounded to
|
||||
// "on": the `Joined` failure text used to assert the flag "was confirmed ON
|
||||
// before the join" even on this path, sending the reader to look at Deserter
|
||||
// and brackets when the lever was the actual suspect.
|
||||
// Unread stays unread: `testing_confirmed` is left false for the failure text.
|
||||
warn!(
|
||||
"PROBE bg: TESTING unread — no `.debug bg` reply reached the tap in \
|
||||
{TOGGLE_REPLY_WINDOW:.0}s; continuing, but if the queue never pops, this \
|
||||
@@ -719,13 +500,12 @@ fn bg_probe(
|
||||
);
|
||||
probe.phase = Phase::Scanning { since: now };
|
||||
}
|
||||
// Still inside the window: stay in `Toggled` and look again next frame.
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
Phase::Scanning { since: sent_at } => {
|
||||
let here = player.pos;
|
||||
// By ENTRY, not by "the nearest battlemaster": all three stand in one alcove.
|
||||
// By entry, not nearness: all three stand in one alcove.
|
||||
let master = units.iter().find(|(guid, kind, store, tf)| {
|
||||
kind.kind == benilla_protocol::EntityKind::Unit
|
||||
&& store.0.unit_npc_flags() & npc_flags::BATTLEMASTER != 0
|
||||
@@ -751,8 +531,7 @@ fn bg_probe(
|
||||
}
|
||||
}
|
||||
Phase::Greeted { master, sent_at } => {
|
||||
// Waiting for the LIST, not for a clock: its guid is the one the join must quote, and
|
||||
// its arrival is the proof the level gate was cleared.
|
||||
// The list's guid is the one the join quotes; its arrival proves the level gate passed.
|
||||
if battlefield.battlemaster() == Some(master) {
|
||||
info!("PROBE bg: LISTED — queueing for map {}", arena.map);
|
||||
let _ = net.0.send(ClientCommand::BattlemasterJoin {
|
||||
@@ -771,15 +550,8 @@ fn bg_probe(
|
||||
}
|
||||
}
|
||||
Phase::Joined { sent_at } => {
|
||||
// Status 2 is "your battleground is ready, confirm within the deadline". The probe
|
||||
// takes it through the STOCK VERB, not through a hand-built packet: the whole point
|
||||
// is that the reference's own dialog road works.
|
||||
// **Matched on the MAP as well as the status**, because a slot is not ours just because
|
||||
// it is ready. A killed run can leave this character queued for another battleground,
|
||||
// and vmangos re-sends that `SMSG_BATTLEFIELD_STATUS` at login — so a status-2 slot for
|
||||
// Arathi Basin would be taken here, port the body into map 529, and the `Ported` arm
|
||||
// would then time out complaining that the map "is still 529, not 489". The cause would
|
||||
// read as a broken worldport; it is a stale queue entry the probe confirmed on purpose.
|
||||
// Status 2 is "ready, confirm", taken through the stock verb. Matched on the map too:
|
||||
// vmangos re-sends a stale queue's status at login.
|
||||
let ready = queue.slots().iter().position(|s| {
|
||||
s.as_ref()
|
||||
.is_some_and(|(st, _)| st.status == 2 && st.map_id == arena.map)
|
||||
@@ -801,14 +573,8 @@ fn bg_probe(
|
||||
}
|
||||
probe.phase = Phase::Ported { sent_at: now };
|
||||
} else if now - sent_at > 12.0 && probe.rejoins < MAX_REJOINS {
|
||||
// **Leave the queue and join again.** vmangos evaluates a queue only when
|
||||
// something SCHEDULES an update, and a queue entry that was scheduled once and
|
||||
// did not match just sits there — no timer re-examines it
|
||||
// (`BattleGroundMgr::Update` drains `m_queueUpdateScheduler` and nothing refills
|
||||
// it on its own, `BattleGroundMgr.cpp:1005`). Whatever left the server's
|
||||
// bookkeeping unable to match this entry, a fresh one gets a fresh evaluation.
|
||||
// Both halves are real client verbs: `AcceptBattlefieldPort(slot, 0)` is the
|
||||
// queue's own decline, which no other probe or test here has ever walked.
|
||||
// Decline and re-join: an entry that did not match is never re-examined until a
|
||||
// join or a leave schedules its queue (`BattleGroundMgr.cpp:1005`).
|
||||
probe.rejoins += 1;
|
||||
let slot = queue
|
||||
.slots()
|
||||
@@ -888,9 +654,7 @@ fn bg_probe(
|
||||
finish(&mut probe, &net);
|
||||
return;
|
||||
};
|
||||
// Drained HERE, not inside `census`, so the tap's self-heal can reach `probe` — and so
|
||||
// everything the tap caught on the queue road is attributed to the window it actually
|
||||
// happened in rather than being folded into the first census.
|
||||
// Drained here, not in `census`, so the tap's self-heal can reach `probe`.
|
||||
let mut events = std::mem::take(&mut probe.pending_events);
|
||||
events.push_str(&drain_events(&mut probe, &mut script));
|
||||
let tap_broken = probe.tap_broken;
|
||||
@@ -912,18 +676,8 @@ fn bg_probe(
|
||||
);
|
||||
let samples = samples + 1;
|
||||
|
||||
// **Once inside, the lever has done its job — put it back immediately.**
|
||||
//
|
||||
// It was an equality test on `DOORS_SAMPLE` (11), and that was wrong twice over. Any
|
||||
// `WOW_PROBE_BG_SAMPLES` below 11 — a documented, user-facing dial — skipped it
|
||||
// entirely and left the realm-global flag flipped for the next run, which is the exact
|
||||
// breakage 2290 §2 records as having cost runs 2, 4 and 5. And there was no reason to
|
||||
// wait: the flag is needed only for `CheckNormalMatch` to *form* the match, which has
|
||||
// already happened by the time we are standing in it. vmangos arms the premature
|
||||
// countdown when the battleground reaches `STATUS_IN_PROGRESS` (the doors) and
|
||||
// decrements it inside `else if (!sBattleGroundMgr.isTesting())` (`BattleGround.cpp:337`),
|
||||
// so restoring it now simply lets that clock run from the doors rather than from
|
||||
// sample 11 — twelve seconds *earlier* to an ending, not later.
|
||||
// Inside, the lever has formed the match; off again so the premature-finish countdown
|
||||
// runs (it only decrements while testing is off, `BattleGround.cpp:337`).
|
||||
if samples == 1 {
|
||||
info!(
|
||||
"PROBE bg: TESTING — inside the instance; the lever has done its job and the \
|
||||
@@ -933,11 +687,8 @@ fn bg_probe(
|
||||
}
|
||||
|
||||
if samples >= census_samples() {
|
||||
// **The graveyard leg.** `.die` is the one thing that clears the probe's god
|
||||
// shield by design (0677), so a probe CAN die on purpose; a battleground death is
|
||||
// also the only place the area-spirit-healer arc (2291) is reachable at all, and
|
||||
// a mechanism that has never been watched fire is not a mechanism anyone should
|
||||
// claim. Non-combat: nothing kills us, we ask to be dead.
|
||||
// The flag leg, then the graveyard: `.die` clears the probe's god shield, and a
|
||||
// battleground death is the only way to reach the area spirit healer.
|
||||
match arena.flag {
|
||||
Some((entry, [x, y, z])) => {
|
||||
info!("PROBE bg: FLAG — hopping to {entry} at ({x}, {y}, {z}) to take it");
|
||||
@@ -960,7 +711,7 @@ fn bg_probe(
|
||||
}
|
||||
Phase::Rejoining { at } => {
|
||||
if now - at < 2.0 {
|
||||
return; // let the decline land and the slot clear
|
||||
return; // the decline lands and the slot clears
|
||||
}
|
||||
let Some(master) = battlefield.battlemaster() else {
|
||||
error!("PROBE bg: FAILURE — the battlemaster list went away before the rejoin");
|
||||
@@ -977,7 +728,7 @@ fn bg_probe(
|
||||
}
|
||||
Phase::FlagHop { at } => {
|
||||
if now - at < 4.0 {
|
||||
return; // the hop crosses the map; let the far base stream in
|
||||
return; // the hop crosses the map; the far base streams in
|
||||
}
|
||||
let Some((entry, _)) = arena.flag else {
|
||||
finish(&mut probe, &net);
|
||||
@@ -990,9 +741,8 @@ fn bg_probe(
|
||||
});
|
||||
match found {
|
||||
Some((guid, _, _, tf)) => {
|
||||
// What the click's own GameObject ladder would decide, reported rather than
|
||||
// assumed: a FLAGSTAND carries no lock, so `resolve_go_action` takes its
|
||||
// `Use` arm and the packet is `CMSG_GAMEOBJ_USE` — the one sent here.
|
||||
// A flag stand has no lock, so a click's `resolve_go_action` sends
|
||||
// `CMSG_GAMEOBJ_USE`, as here; the lock is logged, not assumed.
|
||||
let lock = go_templates.get(guid.0).map_or(u32::MAX, |t| t.lock_id);
|
||||
info!(
|
||||
"PROBE bg: FLAG {entry} streamed at {:.1} yd, lock_id={lock} — CMSG_GAMEOBJ_USE",
|
||||
@@ -1021,13 +771,10 @@ fn bg_probe(
|
||||
finish(&mut probe, &net);
|
||||
return;
|
||||
};
|
||||
// **The two readings that say the flag is on the body.** World state 2339 is the
|
||||
// ALLIANCE readout row's icon selector, and it is only ever 1 or 2 — 2 meaning the
|
||||
// flag that row is about is being carried (vmangos `BattleGroundWS.cpp:512` and
|
||||
// `FillInitialWorldStates` 713-722). It is **not** the four-value `m_flagState` enum,
|
||||
// which never reaches the client at all; that distinction is why a census reading
|
||||
// `2338=1 2339=1` for a whole match is correct rather than a stuck value. The aura is
|
||||
// the other half: what the reference actually draws on the body.
|
||||
// World state 2339 is the Alliance row's icon selector: 1, or 2 while the flag that
|
||||
// row is about is carried (`BattleGroundWS.cpp:512`, `:714-722`), never the four-value
|
||||
// `m_flagState`, so `2339=1` held all match is correct, not stuck. The buffs are the
|
||||
// other half.
|
||||
let auras = script
|
||||
.eval::<String>(
|
||||
r#"
|
||||
@@ -1092,10 +839,8 @@ fn bg_probe(
|
||||
finish(&mut probe, &net);
|
||||
return;
|
||||
};
|
||||
// The whole point of this leg, in four readings: are we a ghost, is a spirit guide in
|
||||
// range, did the engine adopt it (`GetAreaSpiritHealerTime` is non-zero only once
|
||||
// `SMSG_AREA_SPIRIT_HEALER_TIME` answered the query the adopt sent), and did the
|
||||
// stock dialog's event fire.
|
||||
// Ghost, nearest spirit guide, the wave clock (non-zero only once
|
||||
// `SMSG_AREA_SPIRIT_HEALER_TIME` answers) and the stock popup.
|
||||
let ghost = store.0.player_is_ghost();
|
||||
let nearest = units
|
||||
.iter()
|
||||
@@ -1128,8 +873,7 @@ fn bg_probe(
|
||||
);
|
||||
let samples = samples + 1;
|
||||
if samples >= GHOST_SAMPLES {
|
||||
// Take the wave if one is offered — the verb that was permanently silent before
|
||||
// 2291, and the only way back to the world from a battleground graveyard.
|
||||
// Take the wave if one is offered.
|
||||
match script.eval::<()>("AcceptAreaSpiritHeal()") {
|
||||
Ok(()) => info!("PROBE bg: GHOST AcceptAreaSpiritHeal() sent"),
|
||||
Err(e) => error!("PROBE bg: GHOST AcceptAreaSpiritHeal() raised: {e}"),
|
||||
@@ -1148,26 +892,10 @@ fn bg_probe(
|
||||
}
|
||||
}
|
||||
|
||||
/// **Reaching `Done` ends the process.** This probe was the one live probe here without it
|
||||
/// (`probe_auction`'s tail and `ProbeExitPlugin::fire_probe_exit` are the established shape), so
|
||||
/// the client simply idled at `Done` until whatever external `timeout` wrapped the run killed it:
|
||||
/// a four-minute Warsong Gulch census cost a fifteen-minute wall clock, and a sequence of runs
|
||||
/// spent most of its time watching a finished probe stand still.
|
||||
///
|
||||
/// It is its own system rather than an arm of [`bg_probe`] only because that system is already at
|
||||
/// Bevy's parameter ceiling — there is nothing conditional about the split.
|
||||
///
|
||||
/// **It waits for the leave to land before it goes**, and that wait is the whole reason this is not
|
||||
/// a one-liner. [`report`] ends by teleporting the body out to map 0, and `AppExit` stops the
|
||||
/// Update schedule — so exiting on the next frame can cut the connection before the worldport
|
||||
/// completes and strand the body **inside a live battleground**, which is precisely the poisoned
|
||||
/// state this probe warns the next run about. So: leave first, confirmed by the map actually
|
||||
/// changing, then exit; with [`LEAVE_GRACE`] as the backstop for a leave that never lands, because
|
||||
/// hanging forever would be worse than logging out somewhere awkward.
|
||||
///
|
||||
/// The polite `AppExit` plus a hard backstop on its own OS thread is the pattern, not belt and
|
||||
/// braces: `AppExit` stops the Update schedule, so an in-schedule backstop could never fire, and a
|
||||
/// net/winit teardown hang would otherwise leave a zombie client holding the probe account.
|
||||
/// Ends the process once [`Phase::Done`] is reached and the body has left the battleground map
|
||||
/// (or [`LEAVE_GRACE`] passed): exiting before the worldport completes leaves the body inside a
|
||||
/// live battleground. A separate system because [`bg_probe`] is at Bevy's parameter ceiling; the
|
||||
/// hard-exit thread fires even though `AppExit` stops the Update schedule.
|
||||
fn bg_probe_exit(
|
||||
time: ProbeClock,
|
||||
mut probe: ResMut<BgProbe>,
|
||||
@@ -1200,9 +928,7 @@ fn bg_probe_exit(
|
||||
});
|
||||
}
|
||||
|
||||
/// One census sample — eleven greppable `PROBE bg:` lines describing everything the client can see
|
||||
/// from inside the battleground. Deliberately verbose: this is the first look anyone has had, and
|
||||
/// a column nobody needed is cheaper than a round trip for one that was left out.
|
||||
/// One census sample: greppable `PROBE bg:` lines for everything the client sees from inside.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn census(
|
||||
t: f64,
|
||||
@@ -1233,9 +959,7 @@ fn census(
|
||||
},
|
||||
);
|
||||
info!(
|
||||
// `map=none` rather than `map=0`: 0 is Eastern Kingdoms, a real answer, and printing it
|
||||
// for "there is no `CurrentMap` resource" is the same missing-reads-as-a-value mistake
|
||||
// the world-state column had.
|
||||
// `map=none`, not `map=0`: 0 is Eastern Kingdoms.
|
||||
"PROBE bg: CENSUS t={t:.0}s map={} area={:?} pos=({:.0},{:.0},{:.0}) terrain={terrain}",
|
||||
map.map_or_else(|| "none".to_string(), |m| m.0.to_string()),
|
||||
area.and_then(|a| a.0),
|
||||
@@ -1244,7 +968,7 @@ fn census(
|
||||
p.z,
|
||||
);
|
||||
|
||||
// Entities, by kind — "is there a world here at all?"
|
||||
// Entities, by kind.
|
||||
let (mut u, mut pl, mut go, mut dy, mut co) = (0, 0, 0, 0, 0);
|
||||
let mut go_entries: Vec<(u32, Option<String>)> = Vec::new();
|
||||
let mut nearest_guide: Option<(u64, f32)> = None;
|
||||
@@ -1263,10 +987,7 @@ fn census(
|
||||
K::GameObject => {
|
||||
go += 1;
|
||||
if let Some(e) = benilla_protocol::guid::entry(guid.0) {
|
||||
// The NAME, not just the entry: "179918" is a number to look up in a database
|
||||
// and `Doodad_PortcullisActive01` is the battleground's gate. The cache is the
|
||||
// ask-once `GAMEOBJECT_QUERY` store (0239) the client already fills for every
|
||||
// GO that streams in, so this costs nothing and answers for free.
|
||||
// With its name from the `GAMEOBJECT_QUERY` cache, already filled per stream.
|
||||
go_entries.push((e, go_templates.get(guid.0).map(|t| t.name.clone())));
|
||||
}
|
||||
}
|
||||
@@ -1308,7 +1029,7 @@ fn census(
|
||||
)
|
||||
);
|
||||
|
||||
// The world-state table — the battleground's whole score, as raw dwords.
|
||||
// The world-state table, the battleground's whole score, as raw dwords.
|
||||
let mut pairs: Vec<(u32, i32)> = states.pairs().collect();
|
||||
pairs.sort_unstable_by_key(|(k, _)| *k);
|
||||
info!(
|
||||
@@ -1322,7 +1043,7 @@ fn census(
|
||||
.join(" ")
|
||||
);
|
||||
|
||||
// What the reference's own readout would draw, straight out of the VM.
|
||||
// What the stock readout would draw, out of the VM.
|
||||
let alwaysup = script
|
||||
.eval::<String>(
|
||||
r#"
|
||||
@@ -1365,11 +1086,8 @@ fn census(
|
||||
.unwrap_or_else(|e| format!("<raised: {e}>"));
|
||||
info!("PROBE bg: SCORE n={score}");
|
||||
|
||||
// **The wire-coverage tally** — every opcode the codec threw on the floor, by name. This is
|
||||
// the column the first run did not have and most needed: a battleground that sends something
|
||||
// benilla has no arm for is invisible in every other reading here, and reads as "nothing
|
||||
// happened" rather than as a gap. `DroppedOpcodes` is never cleared, so the numbers are
|
||||
// cumulative over the whole session — a row that grows between samples is the finding.
|
||||
// Every opcode the codec dropped, by name; `DroppedOpcodes` is never cleared, so the counts
|
||||
// are cumulative over the session.
|
||||
let mut drops: Vec<(u16, u64, u64)> = dropped
|
||||
.0
|
||||
.iter()
|
||||
@@ -1393,11 +1111,7 @@ fn census(
|
||||
}
|
||||
);
|
||||
|
||||
// The event tap's window (see `EVENT_TAP`): what the VM was actually told since the last
|
||||
// sample. An empty window while the server is running the start countdown is a finding —
|
||||
// **provided the tap is alive**, which is why a broken tap says so instead of printing
|
||||
// `(none)`. "The battleground fired nothing" and "nobody was listening" are opposite
|
||||
// conclusions and used to produce identical lines.
|
||||
// The event tap's window since the last sample; a broken tap says so instead of `(none)`.
|
||||
info!(
|
||||
"PROBE bg: EVENTS{}",
|
||||
if tap_broken {
|
||||
@@ -1409,34 +1123,16 @@ fn census(
|
||||
}
|
||||
);
|
||||
|
||||
// **No `LUA` column, deliberately.** It used to print `LUA clean` off `take_errors()`, which
|
||||
// reads as "the interface raised nothing inside a battleground for the last 12 s". It cannot
|
||||
// mean that: `take_errors` is a DRAIN, and two systems already drain it every frame
|
||||
// (`ui_script::input` and `ui_script::extract::extract`), so this call could only ever see
|
||||
// whatever our own `eval`s in this same frame had just raised. A column that reports a 12 s
|
||||
// window while measuring a fraction of one frame is worse than no column, because it reads as
|
||||
// evidence. Engine-side Lua raises appear as `WARN ui_script:` lines — grep for those.
|
||||
// No Lua-error column: `take_errors` is drained every frame by `ui_script::input` and
|
||||
// `ui_script::extract`, so Lua raises show as `WARN ui_script:` lines instead.
|
||||
let _ = (arena, script);
|
||||
}
|
||||
|
||||
/// The run's last act: drive the two battleground windows the census cannot reach by watching
|
||||
/// (they are opened by a keybind, never by an event), then put the server's testing toggle back
|
||||
/// and leave the battleground the way a player does.
|
||||
/// The run's last act: read the battle map, toggle the scoreboard and the battlefield minimap
|
||||
/// (opened by a keybind, never by an event), then leave the battleground.
|
||||
fn report(arena: &Arena, net: &NetCommands, queue: &BattlefieldQueue, script: &mut UiScript) {
|
||||
// The scoreboard and the battlefield minimap: both are LoadOnDemand-shaped roads nothing in
|
||||
// this tree has ever walked. A raise here is the finding.
|
||||
// **Read both windows BEFORE toggling them.** The scoreboard shows itself when a match ends —
|
||||
// `WorldStateScoreFrame_Update` does `if (GetBattlefieldWinner()) then ShowUIPanel(...)`,
|
||||
// driven by `UPDATE_BATTLEFIELD_SCORE` — so after a real ending the toggle below *hides* it,
|
||||
// and reading only the post-toggle state reports `IsShown=nil` for the run where the feature
|
||||
// worked. That is the wrong way round, and it cost a round of inference to notice.
|
||||
//
|
||||
// Through `getglobal`, because `BattlefieldMinimap` is LoadOnDemand: indexing it before the
|
||||
// toggle that loads it is a nil-index raise, and "not loaded yet" is a reading, not an error.
|
||||
// **Does the battle map have anything to draw?** `IsShown()` alone cannot say: the stock
|
||||
// `BattlefieldMinimap_Update` bails on its fourth line when `GetMapInfo()` is nil, so a window
|
||||
// that is up and completely empty reports exactly the same `IsShown=1` as one full of terrain.
|
||||
// These four readings are the difference, and they are the ones the minimap round never took.
|
||||
// Whether the battle map has anything to draw: `BattlefieldMinimap_Update` returns early when
|
||||
// `GetMapInfo()` is nil (`Blizzard_BattlefieldMinimap.lua:84`), which `IsShown()` cannot see.
|
||||
let mapinfo = script
|
||||
.eval::<String>(
|
||||
r#"
|
||||
@@ -1451,6 +1147,9 @@ fn report(arena: &Arena, net: &NetCommands, queue: &BattlefieldQueue, script: &m
|
||||
.unwrap_or_else(|e| format!("<raised: {e}>"));
|
||||
info!("PROBE bg: MAPINFO {mapinfo}");
|
||||
|
||||
// Read both before toggling: `WorldStateScoreFrame_Update` shows the scoreboard once there is
|
||||
// a winner (`WorldStateFrame.lua:192-194`), so the toggle would hide it. Through `getglobal`
|
||||
// because `BattlefieldMinimap` is load-on-demand.
|
||||
for name in ["WorldStateScoreFrame", "BattlefieldMinimap"] {
|
||||
let shown = script
|
||||
.eval::<String>(&format!(
|
||||
@@ -1484,24 +1183,16 @@ fn report(arena: &Arena, net: &NetCommands, queue: &BattlefieldQueue, script: &m
|
||||
error!("PROBE bg: LUA ERROR {e}");
|
||||
}
|
||||
|
||||
// `LeaveBattlefield` is gated client-side on the scoreboard's "ended" byte (1972), so while a
|
||||
// match is live the verb is correctly silent — which means the honest way out is the same one
|
||||
// a player uses when they give up on a battleground: the GM teleport home. The queue slot the
|
||||
// server still holds is cleared by the leave it sends on the map change.
|
||||
// `LeaveBattlefield` sends nothing until the scoreboard's "ended" byte (`0x4abe66`), so a live
|
||||
// match is left by a GM teleport; the server clears the slot on the map change.
|
||||
info!(
|
||||
"PROBE bg: LEAVING map {} (active_map={:?})",
|
||||
arena.map,
|
||||
queue.active_map()
|
||||
);
|
||||
let _ = script.eval::<()>("LeaveBattlefield()");
|
||||
// **NOT `.recall`.** vmangos saves the recall point on *every* `.go`
|
||||
// (`HandleGoHelper` → `SaveRecallPosition()`, `TeleportCommands.cpp:755`), so the flag leg's
|
||||
// `.go xyz … 489` overwrote it with a position **inside the battleground**. `.recall` then
|
||||
// resolves to the same map and `Player::TeleportTo` takes its near-teleport arm — the probe
|
||||
// printed `LEAVING map 489` and went nowhere, leaving the body logged out inside a live
|
||||
// instance and the queue slot uncleared. That is the poisoned state the `Joined` failure text
|
||||
// warns the *next* run about, manufactured by this one. An explicit hop to the battlemaster
|
||||
// alcove on map 0 is a real map change, which is what clears the slot.
|
||||
// Not `.recall`: every `.go` saves the recall point (`TeleportCommands.cpp:755`), so after
|
||||
// the flag leg it lies inside the battleground. A hop to map 0 is a real map change.
|
||||
let [x, y, z] = arena.at;
|
||||
gm(net, format!(".go xyz {x} {y} {z} 0"));
|
||||
info!("PROBE bg: DONE");
|
||||
|
||||
@@ -1,37 +1,17 @@
|
||||
//! The battleground-queue live probe (`WOW_PROBE_BGQUEUE=1`) — the instrument that makes
|
||||
//! "**log in while queued for a battleground**" a shape anyone can reproduce, inert without the
|
||||
//! env.
|
||||
//! The battleground-queue live probe (`WOW_PROBE_BGQUEUE=1`): the fixture for a login while
|
||||
//! queued. It levels the body past the bracket floor, hops to the Stormwind Warsong Gulch
|
||||
//! battlemaster, greets him (`CMSG_BATTLEMASTER_HELLO`, `SMSG_BATTLEFIELD_LIST`), joins through
|
||||
//! the listed guid (`CMSG_BATTLEMASTER_JOIN`) and logs `PROBE bgqueue:` with the slot it got.
|
||||
//!
|
||||
//! Decision 2232 audited the 29 drains that could still lose a login burst and argued this one
|
||||
//! safe on paper: `SMSG_BATTLEFIELD_STATUS` is published as `UPDATE_BATTLEFIELD_STATUS` off a
|
||||
//! `mem::take` latch that no `VmMemo` re-arms, so a message consumed in 2214's one-frame window
|
||||
//! is gone — but [`crate::ui_battlefield::reset_on_world_enter`] re-sends
|
||||
//! `CMSG_BATTLEFIELD_STATUS` on entry and the server answers slot by slot, so the queue heals
|
||||
//! itself. That record named the argument **unexercised**, and the reason it stayed unexercised
|
||||
//! is that the setup is three server-side gates deep, not that nobody thought to look.
|
||||
//! The body stays queued: vmangos only marks a logged-out entry offline
|
||||
//! (`BattleGroundMgr.cpp:1768`) and sends its status again at login
|
||||
//! (`BattleGroundMgr.cpp:1728`), so the next plain login is the measurement. Dequeue it with
|
||||
//! `WOW_PROBE_LUA='AcceptBattlefieldPort(1,0)'` on a plain login.
|
||||
//!
|
||||
//! So: level past the bracket floor, GM-hop to the Stormwind Warsong Gulch battlemaster, greet
|
||||
//! him on the real wire (`CMSG_BATTLEMASTER_HELLO` → `SMSG_BATTLEFIELD_LIST`), queue through the
|
||||
//! guid that list carried (`CMSG_BATTLEMASTER_JOIN`), and report the slot the server put us in.
|
||||
//! The probe then **leaves the body queued** — vmangos keeps the queue entry across a logout
|
||||
//! (`BattleGroundQueue::PlayerLoggedOut` only marks it offline) and re-sends the status from
|
||||
//! `Player::LoadFromDB`, i.e. inside the login burst. The NEXT plain login is the measurement;
|
||||
//! this run is its fixture.
|
||||
//! The bodyless `CMSG_BATTLEFIELD_JOIN` cannot queue: vmangos passes an empty battlemaster guid
|
||||
//! (`BattleGroundHandler.cpp:78`), which the NPC interaction check never resolves.
|
||||
//!
|
||||
//! **Why the guid and not `CMSG_BATTLEFIELD_JOIN`.** vmangos routes the bodyless join through
|
||||
//! `RequestBgJoinQueue(ObjectGuid{}, …)`, whose `GetNPCIfCanInteractWith(battlemaster,
|
||||
//! UNIT_NPC_FLAG_BATTLEMASTER)` cannot resolve an empty guid — it logs an anticheat line and
|
||||
//! returns, silently. Only the battlemaster leg queues anyone, which is why this probe has to
|
||||
//! stand next to one.
|
||||
//!
|
||||
//! **The undo is one line**, and it belongs here rather than in whatever record cites the probe:
|
||||
//! the body stays queued until something dequeues it, so leave it clean with
|
||||
//! `WOW_PROBE_LUA='AcceptBattlefieldPort(1,0)'` on a plain login — `GetBattlefieldStatus(1)` then
|
||||
//! answers `none`. A body left queued is not harmful, only surprising: every later login on that
|
||||
//! account carries an extra `SMSG_BATTLEFIELD_STATUS` in its burst.
|
||||
//!
|
||||
//! Non-combat. Pair with the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill), and `WOW_NOSOUND=1` when
|
||||
//! it runs unattended. One `timeout`'d run plus a grep for `PROBE bgqueue:` is the whole harness.
|
||||
//! Non-combat; the switches are `docs/CONTRIBUTING.md`, "Running it unattended".
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -42,22 +22,15 @@ use crate::target::cursor_mode::npc_flags;
|
||||
use crate::ui_battlefield::Battlefield;
|
||||
use crate::ui_dialog_verbs::BattlefieldQueue;
|
||||
|
||||
/// Elfarran's spawn (vmangos `creature` guid 54614, entry 14981, map 0) — Stormwind's Warsong
|
||||
/// Gulch battlemaster, `battlemaster_entry` row `14981 → bg_template 2`. The `.go xyz` target;
|
||||
/// the battlemaster himself is then found in the streamed world by his npc flag, never by a guid.
|
||||
/// Elfarran's spawn (vmangos `creature` guid 54614, entry 14981, map 0), Stormwind's Warsong
|
||||
/// Gulch battlemaster; he is then found in the streamed world by his npc flag.
|
||||
const BATTLEMASTER_AT: [f32; 3] = [-8454.62, 318.85, 120.97];
|
||||
/// Warsong Gulch's Map.dbc row — what `GetBattleGroundTypeIdByMapId` turns back into template 2.
|
||||
/// Warsong Gulch's Map.dbc row, which `GetBattleGroundTypeIdByMapId` maps to template 2.
|
||||
const WSG_MAP: u32 = 489;
|
||||
/// The level the probe body is raised to before queueing.
|
||||
///
|
||||
/// `battleground_template` carries one row per content patch, and the server picks the highest at
|
||||
/// or below its own: WSG opens at **10** on patch 6, **20** on patch 5 and **21** on patch 3. 25
|
||||
/// clears all three, so the probe never has to know which patch the local server is running —
|
||||
/// a wrong guess there fails as a bare `LANG_YOUR_BG_LEVEL_REQ_ERROR` notification with no queue,
|
||||
/// which reads exactly like a broken client.
|
||||
/// The level the body is raised to: `battleground_template` opens WSG at 10, 20 or 21 by content
|
||||
/// patch, and 25 clears all three.
|
||||
const QUEUE_LEVEL: u32 = 25;
|
||||
/// `UNIT_NPC_FLAG_BATTLEMASTER`'s service range is the ordinary NPC one; 15 yd is the hop's
|
||||
/// settle radius, not a gate — the server applies its own interaction check.
|
||||
/// The hop's search radius, not a gate: the server applies its own interaction range.
|
||||
const NEAR_YD: f32 = 15.0;
|
||||
|
||||
pub(crate) struct ProbeBgQueuePlugin;
|
||||
@@ -74,8 +47,7 @@ struct BgQueueProbe {
|
||||
phase: Phase,
|
||||
}
|
||||
|
||||
/// `Wait` → (levelled + GM hop sent) `Hopped` → (battlemaster found, hello sent) `Greeted` →
|
||||
/// (the list landed, join sent) `Joined` → verdict → `Done`.
|
||||
/// `Wait`, `Hopped` (levelled, hop sent), `Greeted` (hello sent), `Joined` (join sent), `Done`.
|
||||
#[derive(Default, PartialEq)]
|
||||
enum Phase {
|
||||
#[default]
|
||||
@@ -93,7 +65,6 @@ enum Phase {
|
||||
Done,
|
||||
}
|
||||
|
||||
// One Bevy system's full input set (the guard-poi probe's shape).
|
||||
fn bg_queue_probe(
|
||||
time: ProbeClock,
|
||||
mut probe: ResMut<BgQueueProbe>,
|
||||
@@ -110,18 +81,14 @@ fn bg_queue_probe(
|
||||
let now = time.elapsed_secs_f64();
|
||||
match probe.phase {
|
||||
Phase::Wait => {
|
||||
// **Revive first, unconditionally.** `CanInteractWithNPC` refuses a dead player
|
||||
// outright (`!IsAlive() && !VISIBLE_TO_GHOSTS`), and the refusal surfaces as an
|
||||
// anticheat line about an "invalid creature" — which reads like a wrong guid, not
|
||||
// like a corpse. A probe body parks where the last run left it, and this one is
|
||||
// easy to leave drowned; `.revive` on a living character is a no-op.
|
||||
// Revive first: `CanInteractWithNPC` refuses a dead player (vmangos `Player.cpp:2529`)
|
||||
// with a log line that reads like a wrong guid; `.revive` on the living is a no-op.
|
||||
let _ = net.0.send(ClientCommand::Chat {
|
||||
kind: crate::net::ChatKind::Say,
|
||||
target: None,
|
||||
text: ".revive".to_string(),
|
||||
});
|
||||
// The level next: a body under the bracket floor is refused at the HELLO, before the
|
||||
// list is ever built, so there would be nothing to queue through.
|
||||
// A body under the bracket floor is refused at the hello, with no list to join from.
|
||||
let level = store.0.unit_level().unwrap_or(0);
|
||||
if level < QUEUE_LEVEL {
|
||||
info!("PROBE bgqueue: level {level} — raising to {QUEUE_LEVEL} for the bracket");
|
||||
@@ -142,7 +109,7 @@ fn bg_queue_probe(
|
||||
}
|
||||
Phase::Hopped { sent_at } => {
|
||||
if now - sent_at < 3.0 {
|
||||
return; // post-teleport settle: let the battlemaster stream in
|
||||
return; // let the battlemaster stream in
|
||||
}
|
||||
let here = player.pos;
|
||||
let master = units.iter().find(|(_, net_e, store, tf)| {
|
||||
@@ -166,8 +133,7 @@ fn bg_queue_probe(
|
||||
}
|
||||
}
|
||||
Phase::Greeted { master, sent_at } => {
|
||||
// Waiting for the LIST, not for a clock: the guid it carries is the one the join has
|
||||
// to quote, and its arrival is the proof the level gate was cleared.
|
||||
// Wait for the list: the join quotes its guid, and it proves the level gate cleared.
|
||||
if battlefield.battlemaster() == Some(master) {
|
||||
info!("PROBE bgqueue: list landed — queueing for map {WSG_MAP}");
|
||||
let _ = net.0.send(ClientCommand::BattlemasterJoin {
|
||||
|
||||
@@ -1,88 +1,19 @@
|
||||
//! The innkeeper-bind live probe (`WOW_PROBE_BINDER=1`) — decision 1331's end-to-end instrument and
|
||||
//! the evidence that closes **B249** ("setting your Hearthstone shows a wrong icon and doesn't
|
||||
//! take"): log in, GM-hop to the exact innkeeper in the bug's screenshot, open her gossip menu on
|
||||
//! the real wire, assert the bind row's icon reads **binder** (half 1), select it, assert the
|
||||
//! server's `SMSG_BINDER_CONFIRM` arrived and reached the Lua dialog (half 2's question), answer it
|
||||
//! through the reference's own `ConfirmBinder()`, and assert the hearthstone actually moved (half
|
||||
//! 2's answer). One `PROBE_BINDER: <step> PASS/FAIL/SKIP <detail>` line per step, then a final
|
||||
//! `PROBE_BINDER: DONE pass=<n> fail=<m>`. Modeled closely on [`super::probe_bank`] — same phase
|
||||
//! machine, same trace style, same self-terminating exit ([`super::probes::ProbeExitPlugin`]'s
|
||||
//! pattern), same live-VM observation idiom (a small Lua hook appending to a probe table, read back
|
||||
//! with `script.eval`).
|
||||
//! The innkeeper-bind live probe (`WOW_PROBE_BINDER=1`): hops to Innkeeper Keldamyr (entry 6736,
|
||||
//! Dolanaar, map 1), opens her gossip on the real wire, checks the bind row's icon maps to
|
||||
//! `binder`, selects it, checks `SMSG_BINDER_CONFIRM` reaches `CONFIRM_BINDER`, answers with
|
||||
//! `ConfirmBinder()`, and checks the hearthstone moved and the bind announced itself. One
|
||||
//! `PROBE_BINDER: <step> PASS/FAIL/SKIP <detail>` line per step, then `PROBE_BINDER: DONE
|
||||
//! pass=<n> fail=<m>`, and it exits. An environmental problem SKIPs; a wrong value FAILs.
|
||||
//! Non-combat; the switches are `docs/CONTRIBUTING.md`, "Running it unattended".
|
||||
//!
|
||||
//! Unit tests cannot close B249. Half 1's old mapping was self-consistent (it just described a
|
||||
//! *later* client's icon art), and half 2's packet was never parsed at all — both defects are only
|
||||
//! visible against a real server sending real bytes, which is what this probe puts in front of
|
||||
//! them.
|
||||
//!
|
||||
//! ## The innkeeper (live-DB verified against the local vmangos, `mangos` DB)
|
||||
//!
|
||||
//! Innkeeper Keldamyr — `creature_template.entry = 6736`, spawn `creature.guid = 46343`, **map 1**
|
||||
//! (Teldrassil, Dolanaar), position `(9802.21, 982.608, 1313.98)`,
|
||||
//! `creature_template.npc_flags = 135` = `0x87` = GOSSIP|QUESTGIVER|VENDOR|**INNKEEPER**,
|
||||
//! `gossip_menu_id = 1293`. She is the NPC in the bug's screenshot, which is why the probe hops to
|
||||
//! her rather than to whichever innkeeper is nearest.
|
||||
//!
|
||||
//! **`UNIT_NPC_FLAG_INNKEEPER` is `0x80` (128) on 1.12** — vmangos `Objects/UnitDefines.h:610`,
|
||||
//! read this session. `GossipDef.h:45`'s comment beside `GOSSIP_OPTION_INNKEEPER` says `(65536)`,
|
||||
//! which is a *later* client's value for the same flag: exactly the stale-comment trap decision
|
||||
//! 1331 is about (half 1 was a hand-written icon map that trusted that same header's enum *names*).
|
||||
//! [`NPC_FLAG_INNKEEPER`] below is the verified `0x80`, and it is only the scan's fallback — the
|
||||
//! primary identity check is the template entry.
|
||||
//!
|
||||
//! ## The bind row (live-DB verified this session)
|
||||
//!
|
||||
//! `gossip_menu_option` for menu 1293 carries five rows; the innkeeper's is
|
||||
//! `option_icon = 5`, `option_id = 8` (`GOSSIP_OPTION_INNKEEPER`), `npc_option_npcflag = 128`,
|
||||
//! text *"Make this inn my home."* — and **every one of the 21 `option_id = 8` rows in the whole
|
||||
//! world DB uses `option_icon = 5`** (`SELECT option_icon, COUNT(*) … WHERE option_id = 8 GROUP BY
|
||||
//! option_icon` → a single row, `5 | 21`). So icon 5 is *the* innkeeper icon, and 5 was precisely
|
||||
//! the byte the pre-1331 map had no entry for: it fell through to the chat bubble. Step 3 FAILs
|
||||
//! loudly on `"gossip"` for that reason — that string is the B249 regression itself.
|
||||
//!
|
||||
//! Two things a live reading shows that the DB row does not, both verified on the first run:
|
||||
//!
|
||||
//! - **The label on the wire is not `option_text`.** vmangos prefers the row's
|
||||
//! `option_broadcast_text` (2822 -> *"Make this inn your home."*) over the `option_text` column
|
||||
//! (*"Make this inn **my** home."*). Step 4's guard is therefore a lowercase `"home"` substring,
|
||||
//! not an equality — the exact wording is the server's to choose.
|
||||
//! - **A probe login sees five rows here, not the three in the bug's screenshot.** GM mode is the
|
||||
//! probe default (decision 0679; the preflight banner says so every run), and vmangos does not
|
||||
//! condition-filter a GM's menu — it appends `"(GM mode is ON)"` to the two holiday rows and
|
||||
//! sends them anyway. So the bind row sits at list position 2 with wire index 1 on a probe, and
|
||||
//! would sit at position 1 on a player. That is exactly why step 3 finds the row by its **wire
|
||||
//! icon byte** and step 4 selects by its **wire index**, and why neither ever counts positions.
|
||||
//!
|
||||
//! ## What each step can and cannot conclude
|
||||
//!
|
||||
//! Step 5 is the load-bearing one. Before 1331 `SMSG_BINDER_CONFIRM` (0x2eb) had no const, no parse
|
||||
//! arm and no dialog: it fell through `parse.rs`'s tail into `ServerPacket::Other` and the click
|
||||
//! produced a closing gossip window and nothing else. A PASS there means the packet is parsed, the
|
||||
//! session state took it, and the `CONFIRM_BINDER` event reached the live VM carrying a real area
|
||||
//! name.
|
||||
//!
|
||||
//! Step 6 answers it the way a player does — `ConfirmBinder()` run in the live VM, so the whole
|
||||
//! dialog→engine→drain→wire chain (`CMSG_BINDER_ACTIVATE`, 0x1b5) is exercised rather than a
|
||||
//! synthesized packet. **A rebind to the place you are already bound is invisible unless you make
|
||||
//! it visible**: vmangos's `EffectBind` (`SpellEffects.cpp`) writes the homebind and sends
|
||||
//! `SMSG_BINDPOINTUPDATE` *unconditionally*, but our [`HomeBind`] would simply be re-set to the
|
||||
//! value it already held. So the probe clears `HomeBind` to `None` immediately before answering
|
||||
//! and waits for it to become `Some` again — that transition is the fresh packet, and it is the
|
||||
//! only reading that survives a probe character who already hearths in Dolanaar from an earlier
|
||||
//! run.
|
||||
//!
|
||||
//! ## The run recipe
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_DATA=WoW/Data WOW_USER=probe1 WOW_PASS=pprobe1 WOW_CHAR=Probeone \
|
||||
//! WOW_PROBE_BINDER=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
|
||||
//! skill). Non-combat, and GM mode is left exactly as found. An
|
||||
//! outer `timeout` + grep on `PROBE_BINDER:` is the whole harness; the probe self-exits once DONE.
|
||||
//!
|
||||
//! Every step SKIPs with a note rather than FAILing for an environmental problem (the NPC never
|
||||
//! streamed, a GM command refused, no UI VM in this build). A genuine wrong value is a FAIL.
|
||||
//! - `UNIT_NPC_FLAG_INNKEEPER` is `0x80` (vmangos `UnitDefines.h:664`); the `(65536)` beside
|
||||
//! `GOSSIP_OPTION_INNKEEPER` in `GossipDef.h:45` is a later client's value.
|
||||
//! - Every `GOSSIP_OPTION_INNKEEPER` row in the world DB sends icon 5.
|
||||
//! - The wire label is the row's broadcast text ("Make this inn your home."), not `option_text`.
|
||||
//! - vmangos does not condition-filter a GM's menu, so a probe in GM mode sees extra rows: the
|
||||
//! row is found by its wire icon and selected by its wire index, never by position.
|
||||
//! - `EffectBind` sends `SMSG_BINDPOINTUPDATE` even for a rebind to the same place
|
||||
//! (`SpellEffects.cpp:5806`), so the probe clears `HomeBind` first and waits for it to return.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -98,45 +29,36 @@ use crate::ui_binder::BinderState;
|
||||
use crate::ui_gossip::GossipState;
|
||||
use crate::ui_session::NpcSession;
|
||||
|
||||
/// Innkeeper Keldamyr's spawn (vmangos `creature` guid 46343, entry 6736) — the `.go xyz` target.
|
||||
/// Innkeeper Keldamyr's spawn (vmangos `creature` guid 46343), the `.go xyz` target.
|
||||
const INNKEEPER_AT: [f32; 3] = [9802.21, 982.608, 1313.98];
|
||||
/// Her map — Teldrassil. `.go xyz` takes the map id as its fourth argument.
|
||||
/// Her map, Kalimdor; `.go xyz` takes the map id as its fourth argument.
|
||||
const INNKEEPER_MAP: u32 = 1;
|
||||
/// Her creature template entry — the streamed-unit identity check (module doc).
|
||||
/// Her creature template entry, the primary identity check.
|
||||
const INNKEEPER_ENTRY: u32 = 6736;
|
||||
/// `UNIT_NPC_FLAG_INNKEEPER` — `0x80` on 1.12, NOT the `65536` `GossipDef.h`'s comment names
|
||||
/// (module doc). The scan's fallback when the entry read is unavailable.
|
||||
/// `UNIT_NPC_FLAG_INNKEEPER` on 1.12, the scan's fallback identity check.
|
||||
const NPC_FLAG_INNKEEPER: u32 = 0x80;
|
||||
/// The wire `GOSSIP_ICON` byte every `GOSSIP_OPTION_INNKEEPER` row in the world DB sends
|
||||
/// (21/21, verified this session) — decision 1331's table indexes it to `"binder"`.
|
||||
/// The wire `GOSSIP_ICON` byte every `GOSSIP_OPTION_INNKEEPER` row sends.
|
||||
const ICON_INNKEEPER: u8 = 5;
|
||||
/// The type string [`crate::ui_gossip`]'s table must produce for [`ICON_INNKEEPER`].
|
||||
const ICON_TYPE_BINDER: &str = "binder";
|
||||
/// What the pre-1331 map produced instead — the chat bubble. Seeing it back is B249, not a flake.
|
||||
/// The chat bubble, what an unmapped icon byte falls back to.
|
||||
const ICON_TYPE_REGRESSION: &str = "gossip";
|
||||
/// The substring the bind row's label must carry before the probe is willing to select it — a
|
||||
/// guard against binding whatever else the menu happens to offer if the DB row ever moves.
|
||||
/// A substring the bind row's label must carry before the probe selects it.
|
||||
const BIND_LABEL_HINT: &str = "home";
|
||||
/// Scan radius around the `.go` landing, generously wide so a slightly-off hop still finds her
|
||||
/// (the bank/mail probes' idiom).
|
||||
/// Scan radius around the `.go` landing, wide enough for a slightly-off hop.
|
||||
const SCAN_RANGE: f32 = 12.0;
|
||||
|
||||
const SETTLE_SECS: f64 = 3.0;
|
||||
/// The waits are deliberately generous, and the reason is measured rather than guessed: the `.go`
|
||||
/// lands on a **different map**, so the whole leg after the hop runs inside Teldrassil's terrain
|
||||
/// load — the first live run logged five consecutive `frame hitch: ~1050 ms` lines and a 5.0 s
|
||||
/// loading screen across steps 4→5, i.e. the probe got roughly one frame per second to poll in.
|
||||
/// The packets were prompt; the *observer* was starved. A timeout tight enough to trip on that
|
||||
/// would report a FAIL about the wire, which is the one thing an instrument must never do.
|
||||
/// Generous: the hop lands on another map, and its terrain load leaves the probe about one frame
|
||||
/// a second to poll in, though the packets arrive promptly.
|
||||
const SCAN_TIMEOUT_SECS: f64 = 20.0;
|
||||
const MENU_TIMEOUT_SECS: f64 = 20.0;
|
||||
const CONFIRM_TIMEOUT_SECS: f64 = 20.0;
|
||||
const BIND_TIMEOUT_SECS: f64 = 20.0;
|
||||
const LINE_TIMEOUT_SECS: f64 = 20.0;
|
||||
|
||||
/// `ERR_DEATHBIND_SUCCESS_S` (GlobalStrings.lua:1543, verbatim) — what step 7 expects to read back
|
||||
/// off CHAT_MSG_SYSTEM, composed here rather than imported so the probe asserts against the
|
||||
/// reference's own string and not against whatever `ui_binder` happens to hold.
|
||||
/// `ERR_DEATHBIND_SUCCESS_S` (`GlobalStrings.lua:1544`), written out so step 7 asserts against
|
||||
/// the reference string, not `ui_binder`'s.
|
||||
fn bound_line(area_name: &str) -> String {
|
||||
"%s is now your home.".replace("%s", area_name)
|
||||
}
|
||||
@@ -150,27 +72,22 @@ impl Plugin for ProbeBinderPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe's phase machine plus the identities discovered along the way (the bank probe's shape:
|
||||
/// a `Copy` phase snapshotted out of the resource each tick, so an arm can mutate `probe` freely).
|
||||
/// The probe's phase machine plus the identities found along the way.
|
||||
#[derive(Resource, Default)]
|
||||
struct BinderProbe {
|
||||
phase: Phase,
|
||||
/// The innkeeper's guid, once streamed in.
|
||||
innkeeper: Option<u64>,
|
||||
/// The bind row's **wire** `index` — the value the packet carried and the value
|
||||
/// `CMSG_GOSSIP_SELECT_OPTION` must echo back. vmangos numbers them `data << uint32(iI)` over
|
||||
/// the rows it actually sends (`GossipDef.cpp:188`), so it is the row's **0-based** position in
|
||||
/// *this* menu — neither the DB's `gossip_menu_option.id` nor the Lua menu's 1-based position,
|
||||
/// and the probe assumes no relation between them for the same reason the real drain doesn't.
|
||||
/// The bind row's wire index, echoed by `CMSG_GOSSIP_SELECT_OPTION`: its 0-based position
|
||||
/// among the rows sent (`GossipDef.cpp:188`), neither the DB id nor the Lua position.
|
||||
bind_index: Option<u32>,
|
||||
/// The area id [`HomeBind`] held before step 6 cleared it — reported in the verdict so a
|
||||
/// rebind-to-the-same-place reads as the deliberate no-visible-change case it is.
|
||||
/// The area id [`HomeBind`] held before step 6 cleared it, reported in the verdict.
|
||||
baseline_area: Option<u32>,
|
||||
/// The area name the bind resolved to — step 7 composes its expected line from it.
|
||||
/// The area name the bind resolved to; step 7 composes its expected line from it.
|
||||
bound_name: String,
|
||||
passes: u32,
|
||||
fails: u32,
|
||||
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
|
||||
/// Latched once [`Phase::Done`] has fired its exit.
|
||||
exited: bool,
|
||||
}
|
||||
|
||||
@@ -182,7 +99,7 @@ enum Phase {
|
||||
Settling {
|
||||
sent_at: f64,
|
||||
},
|
||||
/// `GossipHello` sent; waiting for the parsed menu AND its push into the VM (step 2).
|
||||
/// `GossipHello` sent; waiting for the parsed menu and its push into the VM (step 2).
|
||||
Menu {
|
||||
sent_at: f64,
|
||||
},
|
||||
@@ -198,23 +115,21 @@ enum Phase {
|
||||
since: f64,
|
||||
sent: bool,
|
||||
},
|
||||
/// Step 7 — the bind landed; waiting for `SMSG_PLAYERBOUND`'s
|
||||
/// `ERR_DEATHBIND_SUCCESS_S` line to reach the VM as CHAT_MSG_SYSTEM.
|
||||
/// Step 7: waiting for `SMSG_PLAYERBOUND`'s `ERR_DEATHBIND_SUCCESS_S` as `CHAT_MSG_SYSTEM`.
|
||||
Line {
|
||||
since: f64,
|
||||
},
|
||||
Done,
|
||||
}
|
||||
|
||||
/// Read the Lua-side `ProbeBinderEvents` log length (the `CONFIRM_BINDER` hook) — `0` on any eval
|
||||
/// hiccup, treated as "nothing observed yet", never a panic (the bank probe's idiom).
|
||||
/// The Lua-side `ProbeBinderEvents` log length (the `CONFIRM_BINDER` hook); 0 on an eval error.
|
||||
fn events_len(script: &UiScript) -> i64 {
|
||||
script
|
||||
.eval::<i64>("return table.getn(ProbeBinderEvents or {})")
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The newest `ProbeBinderEvents` entry — `CONFIRM_BINDER`'s `arg1`, the area name that fills the
|
||||
/// The newest `ProbeBinderEvents` entry: `CONFIRM_BINDER`'s `arg1`, the area name that fills the
|
||||
/// dialog's `"Do you want to make %s your new home?"`.
|
||||
fn last_event(script: &UiScript) -> String {
|
||||
script
|
||||
@@ -222,27 +137,24 @@ fn last_event(script: &UiScript) -> String {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The `CHAT_MSG_SYSTEM` lines seen since the hook went in, newest last — step 6's second
|
||||
/// observation. `SMSG_PLAYERBOUND` prints `ERR_DEATHBIND_SUCCESS_S` here (`0x5e3d3f` →
|
||||
/// `DisplayError(0x138)`, chat type 238 = CHAT_MSG_SYSTEM; decision 1335).
|
||||
/// The `CHAT_MSG_SYSTEM` lines seen since the hook went in, newest last. `SMSG_PLAYERBOUND`
|
||||
/// prints `ERR_DEATHBIND_SUCCESS_S` there (`0x5e3d3f` → `DisplayError(0x138)`, chat type 238).
|
||||
fn system_lines(script: &UiScript) -> Vec<String> {
|
||||
script
|
||||
.eval::<Vec<String>>("return ProbeBinderSystemLines or {}")
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// How many values the live `GetGossipOptions()` returns — flat `(label, type)` pairs, so twice the
|
||||
/// row count. The probe waits on this rather than assuming the feed already ran this frame.
|
||||
/// How many values the live `GetGossipOptions()` returns: flat `(label, type)` pairs, twice the
|
||||
/// row count.
|
||||
fn vm_gossip_values(script: &UiScript) -> i64 {
|
||||
script
|
||||
.eval::<i64>("local t = { GetGossipOptions() } return table.getn(t)")
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The icon **type string** the app mapped for the 1-based menu row `pos` — read exactly where the
|
||||
/// FrameXML reads it, out of the pushed [`benilla_ui::script::GossipMenu`] snapshot through the
|
||||
/// Era `GetGossipOptions()` vararg. (`ui_gossip::gossip_icon_type` is private to its module; the
|
||||
/// snapshot is the same value the real menu draws from, which is the stronger assert anyway.)
|
||||
/// The icon type string `GetGossipOptions()` returns for the 1-based menu row `pos`, read where
|
||||
/// `GossipFrame.lua` reads it.
|
||||
fn vm_icon_type(script: &UiScript, pos: usize) -> String {
|
||||
script
|
||||
.eval::<String>(&format!(
|
||||
@@ -252,15 +164,15 @@ fn vm_icon_type(script: &UiScript, pos: usize) -> String {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The texture path `BENILLA_GOSSIP_ICONS.binder` resolves to in the live VM — `""` if the table or
|
||||
/// the key is missing, which would mean the row draws the fallback bubble whatever the app mapped.
|
||||
/// `BENILLA_GOSSIP_ICONS.binder` in the live VM, `""` when absent. The stock
|
||||
/// `GossipFrame.lua:123` builds the icon path from the type string and defines no such table.
|
||||
fn vm_binder_texture(script: &UiScript) -> String {
|
||||
script
|
||||
.eval::<String>("return (BENILLA_GOSSIP_ICONS and BENILLA_GOSSIP_ICONS.binder) or \"\"")
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The live VM's `GetBindLocation()` — the hearthstone's own answer for where you are bound.
|
||||
/// The live VM's `GetBindLocation()`, the hearthstone's own answer.
|
||||
fn vm_bind_location(script: &UiScript) -> String {
|
||||
script
|
||||
.eval::<String>("return GetBindLocation()")
|
||||
@@ -285,15 +197,14 @@ fn binder_probe(
|
||||
return; // not in-world yet
|
||||
}
|
||||
let Some(script) = script else {
|
||||
return; // no UI VM this build (headless net-only) — nothing this probe can drive
|
||||
return; // no UI VM in this build, so nothing to drive
|
||||
};
|
||||
let now = time.elapsed_secs_f64();
|
||||
let phase = probe.phase;
|
||||
|
||||
match phase {
|
||||
Phase::Wait => {
|
||||
// The CONFIRM_BINDER hook (step 5's observation channel — the bank probe's exact
|
||||
// pattern), installed up front so it is live long before the question can arrive.
|
||||
// The `CONFIRM_BINDER` and `CHAT_MSG_SYSTEM` hook, live before the question can arrive.
|
||||
if let Err(e) = script.run(
|
||||
r#"
|
||||
if not ProbeBinderHooked then
|
||||
@@ -429,9 +340,8 @@ fn binder_probe(
|
||||
}
|
||||
Phase::Accept { since, sent } => {
|
||||
if !sent {
|
||||
// Clear the bind BEFORE answering: a rebind to the area we already hearth in
|
||||
// re-sends SMSG_BINDPOINTUPDATE with an identical payload, so only the
|
||||
// None → Some transition proves a fresh packet landed (module doc).
|
||||
// Clear the bind before answering: a rebind to the same area re-sends an
|
||||
// identical `SMSG_BINDPOINTUPDATE`, so only None → Some proves a fresh packet.
|
||||
probe.baseline_area = home.0;
|
||||
home.0 = None;
|
||||
if let Err(e) = script.run("ConfirmBinder()") {
|
||||
@@ -480,9 +390,7 @@ fn binder_probe(
|
||||
}
|
||||
}
|
||||
Phase::Line { since } => {
|
||||
// The feedback half of B249: "accepting appears to change nothing" was partly that
|
||||
// nothing ever SAID it had. SMSG_PLAYERBOUND prints ERR_DEATHBIND_SUCCESS_S
|
||||
// ("%s is now your home.") as CHAT_MSG_SYSTEM — VERIFIED at `0x5e3d3f`, decision 1335.
|
||||
// `SMSG_PLAYERBOUND` prints `ERR_DEATHBIND_SUCCESS_S` as a system line (`0x5e3d3f`).
|
||||
let want = bound_line(&probe.bound_name);
|
||||
let lines = system_lines(&script);
|
||||
if lines.iter().any(|l| l == &want) {
|
||||
@@ -507,9 +415,8 @@ fn binder_probe(
|
||||
"PROBE_BINDER: DONE pass={} fail={}",
|
||||
probe.passes, probe.fails
|
||||
);
|
||||
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
|
||||
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
|
||||
// client holding the probe account.
|
||||
// `ProbeExitPlugin::fire_probe_exit`'s pattern: `AppExit` plus a hard backstop, so a
|
||||
// teardown hang cannot leave a client holding the probe account.
|
||||
exit.write(AppExit::Success);
|
||||
std::thread::spawn(|| {
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
@@ -520,10 +427,7 @@ fn binder_probe(
|
||||
}
|
||||
}
|
||||
|
||||
/// Steps 3 and 4 — B249's first half, then the click.
|
||||
///
|
||||
/// Split out of the phase match because it is one straight line of asserts with several exits, and
|
||||
/// inlining it buried the phase machine's shape. Sets `probe.phase` on every path.
|
||||
/// Steps 3 and 4: the bind row's icon, then the click. Sets `probe.phase` on every path.
|
||||
fn assert_icon_and_select(
|
||||
probe: &mut BinderProbe,
|
||||
gossip: &GossipState,
|
||||
@@ -532,8 +436,7 @@ fn assert_icon_and_select(
|
||||
innkeeper: u64,
|
||||
now: f64,
|
||||
) {
|
||||
// Step 3 — the icon. The row is found by the WIRE byte, because that byte is the fact under
|
||||
// test: every GOSSIP_OPTION_INNKEEPER row in the world DB sends 5 (module doc).
|
||||
// Step 3, the icon: the row is found by its wire byte, the fact under test.
|
||||
let Some((pos, opt)) = gossip
|
||||
.options
|
||||
.iter()
|
||||
@@ -592,8 +495,7 @@ fn assert_icon_and_select(
|
||||
);
|
||||
probe.passes += 1;
|
||||
|
||||
// Step 4 — the click. Guarded on the label, and sent with the row's WIRE index — read off the
|
||||
// packet, never derived from where the row sits in the list (the drain's own rule).
|
||||
// Step 4, the click: guarded on the label, sent with the row's wire index from the packet.
|
||||
if !opt.message.to_lowercase().contains(BIND_LABEL_HINT) {
|
||||
warn!(
|
||||
"PROBE_BINDER: SKIP (4 select) — the icon=={ICON_INNKEEPER} row reads {:?}, which does \
|
||||
|
||||
@@ -1,28 +1,12 @@
|
||||
//! The world-book live probe (`WOW_PROBE_BOOK=1`) — B240's instrument: what does having the item-
|
||||
//! text reader open actually cost per frame, on the real object the report names?
|
||||
//! The world-book live probe (`WOW_PROBE_BOOK=1`): the per-frame cost of the item-text reader
|
||||
//! open on a real plaque. It hops to the plaque, samples the UI pass's meter
|
||||
//! ([`crate::ui_script::UiFrameCost`]) with the reader closed, opens it, samples again, checks the
|
||||
//! page's render, and logs both windows under `PROBE_BOOK:`, with how many frames the extract
|
||||
//! gate skipped.
|
||||
//!
|
||||
//! The symptom: a ~50% fps drop (62 → 36 fps, 16.0 → 28.0 ms) while the *Alliance Military Ranks*
|
||||
//! plaque's HTML page is up in the reader, recovering the moment it closes. That is a **frame-cost
|
||||
//! A/B**, and eyeballing an fps counter is exactly the way not to settle one (docs/METHOD.md's
|
||||
//! "timing and feel are measured, never eyeballed"). So this probe teleports to the plaque, samples
|
||||
//! the UI pass's own per-phase meter ([`crate::ui_script::UiFrameCost`]) with the reader CLOSED,
|
||||
//! opens it on the real route, samples again, and prints the two side by side — including how many
|
||||
//! of each window's frames the extract gate (decision 0740) *skipped*, which is what decides
|
||||
//! whether a paint-pass cost is paid once or every frame.
|
||||
//!
|
||||
//! The object: `GameObject` 3011 (`gameobject_template` entry 2857, `GAMEOBJECT_TYPE_TEXT` = 9)
|
||||
//! in Stormwind's Old Town, whose `data[0]` is `page_text` 2676 — a 647-byte HTML body. The
|
||||
//! `.go xyz` below is the reported standing position, off the debug panel: `-8760.2 402.3 103.9`.
|
||||
//!
|
||||
//! ## The run recipe
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_NOSOUND=1 WOW_USER=probe0 WOW_PASS=pprobe0 WOW_CHAR=Probezero \
|
||||
//! WOW_PROBE_BOOK=1 cargo run -q -p benilla --release
|
||||
//! ```
|
||||
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
|
||||
//! skill). An outer `timeout` + a grep on `PROBE_BOOK:` is the whole harness;
|
||||
//! the probe self-exits ([`super::probes::ProbeExitPlugin`]'s pattern) once DONE.
|
||||
//! The object is Stormwind Old Town's Alliance Military Ranks plaque (`gameobject_template` 2857,
|
||||
//! `GAMEOBJECT_TYPE_TEXT`), whose `data[0]` is `page_text` 2676, a 647-byte HTML body. The
|
||||
//! switches are `docs/CONTRIBUTING.md`, "Running it unattended".
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -35,16 +19,14 @@ use crate::player::Player;
|
||||
use crate::ui_item_text::ItemTextOpen;
|
||||
use crate::ui_script::{UiCostWanted, UiFrameCost};
|
||||
|
||||
/// The reporter's own standing position beside the plaque (their debug panel, in the report's
|
||||
/// screenshot).
|
||||
/// A standing position beside the plaque.
|
||||
const PLAQUE_AT: [f32; 3] = [-8760.2, 402.3, 103.9];
|
||||
/// `GAMEOBJECT_TYPE_TEXT` — the strategy type a book/plaque carries (decision 1105).
|
||||
/// `GAMEOBJECT_TYPE_TEXT`, a book or plaque.
|
||||
const GO_TYPE_TEXT: i32 = 9;
|
||||
/// Scan radius around the landing spot, in yards — generous, so a slightly-off `.go` still finds it.
|
||||
/// Scan radius around the landing spot, in yards.
|
||||
const SCAN_RANGE: f32 = 20.0;
|
||||
|
||||
/// Frames sampled per window. Long enough that a one-off hitch cannot carry the mean, short enough
|
||||
/// that the whole run is a few seconds.
|
||||
/// Frames sampled per window, enough that one hitch cannot carry the mean.
|
||||
const SAMPLE_FRAMES: usize = 240;
|
||||
const SETTLE_SECS: f64 = 4.0;
|
||||
const SCAN_TIMEOUT_SECS: f64 = 20.0;
|
||||
@@ -62,24 +44,21 @@ impl Plugin for ProbeBookPlugin {
|
||||
/// One frame's reading off the UI meter.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Sample {
|
||||
/// The paint pass — the phase the glyph rasterization and the ellipsis seam live in.
|
||||
/// The paint pass: glyph rasterization and the ellipsis seam.
|
||||
convert: u128,
|
||||
/// Everything the pass costs, whether or not the gate skipped: tick + resolve + measure +
|
||||
/// extract + convert + diff.
|
||||
/// The whole UI pass, skipped or not.
|
||||
total: u128,
|
||||
/// Did the extract gate skip the conversion this frame (decision 0740)?
|
||||
/// Whether the extract gate skipped the conversion this frame.
|
||||
skipped: bool,
|
||||
}
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
struct BookProbe {
|
||||
phase: Phase,
|
||||
/// The plaque's guid, once the scan finds it.
|
||||
plaque: Option<u64>,
|
||||
closed: Vec<Sample>,
|
||||
open: Vec<Sample>,
|
||||
/// Characters the whole UI draws once the reader is up — the `n` the seam's cost scales in,
|
||||
/// and the proof the page's blocks were built at all.
|
||||
/// Characters the whole UI draws once the reader is up.
|
||||
page_chars: i64,
|
||||
fails: u32,
|
||||
exited: bool,
|
||||
@@ -93,24 +72,19 @@ enum Phase {
|
||||
Settling {
|
||||
sent_at: f64,
|
||||
},
|
||||
/// Sampling with the reader CLOSED — the control window.
|
||||
/// Sampling with the reader closed, the control window.
|
||||
Closed,
|
||||
/// Reader opened on the real route; waiting for `ITEM_TEXT_READY` to paint a body.
|
||||
/// Reader opened; waiting for `ITEM_TEXT_READY` to paint a body.
|
||||
WaitReady {
|
||||
since: f64,
|
||||
},
|
||||
/// Sampling with the reader OPEN — the reported condition.
|
||||
/// Sampling with the reader open.
|
||||
Open,
|
||||
Done,
|
||||
}
|
||||
|
||||
/// The reader's painted state: `(shown, characters the page actually draws)`.
|
||||
///
|
||||
/// The body is read off the RENDER LIST, not out of the VM. `ItemTextPageText` is a `SimpleHTML`
|
||||
/// since decisions 1337/1338 and 5875's SimpleHTML has no `GetText` (its Lua table is 19 entries
|
||||
/// and none of them is a text getter) — so the honest question is what the reader draws, which is
|
||||
/// also the stronger one: a parsed page is many blocks, a plain one is a single raw block, and a
|
||||
/// page that failed to build is zero.
|
||||
/// The reader's painted state: `(shown, characters drawn)`, read off the render list because the
|
||||
/// 1.12 `SimpleHTML` has no `GetText`.
|
||||
fn reader_state(script: &UiScript) -> (bool, i64) {
|
||||
use benilla_ui::script::QuadContent;
|
||||
let shown = script
|
||||
@@ -128,9 +102,8 @@ fn reader_state(script: &UiScript) -> (bool, i64) {
|
||||
(shown, chars)
|
||||
}
|
||||
|
||||
/// Every string the UI currently draws — the render list is the honest place to ask what the page
|
||||
/// looks like, and the only place since `ItemTextPageText` became a `SimpleHTML` (5875's has no
|
||||
/// `GetText` in its method table `0x87ba80`).
|
||||
/// Every string the UI draws; the only way to read the page, as `SimpleHTML` has no `GetText`
|
||||
/// (method table `0x87ba80`).
|
||||
fn drawn_strings(script: &UiScript) -> Vec<String> {
|
||||
use benilla_ui::script::QuadContent;
|
||||
script
|
||||
@@ -144,9 +117,8 @@ fn drawn_strings(script: &UiScript) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// **B240's render half, checked where it was reported.** The page body is HTML; if the parse ever
|
||||
/// falls back, the reader draws the markup itself — the reported symptom. So: no drawn
|
||||
/// string may contain a tag, and the page's own lines must each be there as their own block.
|
||||
/// Checks the page's render: no drawn string holds a tag (a failed parse draws the markup), each
|
||||
/// of the page's lines is its own block, none is ellipsized, and the page fits its window.
|
||||
fn report_render(script: &UiScript) -> u32 {
|
||||
let drawn = drawn_strings(script);
|
||||
let mut fails = 0;
|
||||
@@ -163,7 +135,7 @@ fn report_render(script: &UiScript) -> u32 {
|
||||
markup.iter().take(2).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
// The blocks the plaque's own body must produce, each its own centred line.
|
||||
// Lines of the plaque's body, each its own centred block.
|
||||
let want = [
|
||||
"ALLIANCE MILITARY RANKS",
|
||||
"OFFICERS",
|
||||
@@ -181,18 +153,15 @@ fn report_render(script: &UiScript) -> u32 {
|
||||
fails += 1;
|
||||
error!("PROBE_BOOK: (render) FAIL — blocks missing from the page: {missing:?}");
|
||||
}
|
||||
// The other half of the reported look: a truncated block means the body is height-pinned
|
||||
// again and decision 1332's ellipsis seam has it back.
|
||||
// A truncated block means the body is height-pinned and the ellipsis seam cuts it.
|
||||
if let Some(cut) = drawn.iter().find(|t| t.ends_with("...")) {
|
||||
fails += 1;
|
||||
error!("PROBE_BOOK: (render) FAIL — a drawn block is ellipsized: {cut:?}");
|
||||
} else {
|
||||
info!("PROBE_BOOK: (render) PASS — no block is cut off with \"...\"");
|
||||
}
|
||||
// **Does the page FIT?** The director's own comparison against 1.12.1 (08-15) was that ours
|
||||
// ran off the bottom where the reference's ends inside the window. The scroll range answers it
|
||||
// exactly and without an eye: it is the content's overhang past the viewport, so this page —
|
||||
// which the reference shows whole — must come out at zero.
|
||||
// The reference shows this page whole, so its scroll range (the overhang past the viewport)
|
||||
// must be zero.
|
||||
let range = script
|
||||
.eval::<f64>("return ItemTextScrollFrame:GetVerticalScrollRange()")
|
||||
.unwrap_or(-1.0);
|
||||
@@ -250,7 +219,7 @@ fn book_probe(
|
||||
return; // not in-world yet
|
||||
}
|
||||
let Some(script) = script else {
|
||||
return; // no UI VM this build — nothing this probe can drive
|
||||
return; // no UI VM in this build
|
||||
};
|
||||
let now = time.elapsed_secs_f64();
|
||||
let sample = Sample {
|
||||
@@ -262,8 +231,7 @@ fn book_probe(
|
||||
|
||||
match phase {
|
||||
Phase::Wait => {
|
||||
// Arm the pass's phase split for this run (decision 1174's direction: the consumer
|
||||
// asks, the pass never learns the instrument's name).
|
||||
// Arm the pass's per-phase split for this run.
|
||||
wanted.0 = true;
|
||||
let [x, y, z] = PLAQUE_AT;
|
||||
info!("PROBE_BOOK: heading to the Old Town plaque ({x} {y} {z}) — GameObject type 9, page_text 2676");
|
||||
@@ -341,9 +309,7 @@ fn book_probe(
|
||||
open_mean - closed_mean,
|
||||
probe.fails
|
||||
);
|
||||
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
|
||||
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
|
||||
// client holding the probe account.
|
||||
// `AppExit` plus a hard backstop, so a teardown hang cannot keep the account held.
|
||||
exit.write(AppExit::Success);
|
||||
std::thread::spawn(|| {
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
|
||||
@@ -1,31 +1,15 @@
|
||||
//! `WOW_PROBE_CAM` — the scripted **camera park**: hold the third-person rig at an absolute yaw,
|
||||
//! pitch and zoom so an unattended probe can *look at* a named thing (decision 0653).
|
||||
//! `WOW_PROBE_CAM`: parks the third-person camera at an absolute yaw, pitch and zoom so an
|
||||
//! unattended probe can frame a subject; [`super::probe_look`] turns the avatar, never the pitch.
|
||||
//!
|
||||
//! [`super::probe_look`] turns the avatar's **aim**; that is the right lever for the facing stream,
|
||||
//! and the wrong one for framing a subject — it never touches `FlyCam::pitch`, which world entry
|
||||
//! pins at `-0.45` rad (26° **down**). Everything above the horizon was therefore unreachable
|
||||
//! headlessly: a `.go` to a pinned coordinate lands with the camera staring at the dirt, so the
|
||||
//! tower / crystal / rock the report is *about* is off the top of the frame. That is why the first
|
||||
//! C4 flicker burst came back clean — the subject was never in it.
|
||||
//! Format: `WOW_PROBE_CAM="<yaw_deg>,<pitch_deg>[,<dist_yd>]@<start_s>[:<pan_deg_per_s>][;...]"`,
|
||||
//! e.g. `"180,12,25@25"`: from 25 s on, yaw 180, 12 degrees up, 25 yd back. Yaw and pitch are
|
||||
//! absolute (`+pitch` is up); an omitted distance is left alone. The pose is re-applied every
|
||||
//! frame, so a teleport or reset cannot knock it off, and the latest armed entry wins. A pan turns
|
||||
//! the yaw at a constant rate from the pose's start, for defects that only show with the camera
|
||||
//! moving (z-fighting).
|
||||
//!
|
||||
//! Format: `WOW_PROBE_CAM="<yaw_deg>,<pitch_deg>[,<dist_yd>]@<start_s>[:<pan_deg_per_s>][;…]"`,
|
||||
//! e.g. `"180,12,25@25"` — from 25 s on, face south-ish, 12° **up**, 25 yd back. Yaw and pitch are
|
||||
//! absolute (world yaw as the rig spells it, `+pitch` = up); the distance is optional and left alone
|
||||
//! when omitted. Entries are held **every frame** once armed, not set once: a teleport, a settle, or
|
||||
//! a wire-in reset would otherwise knock the pose off between the arm and the shot. The last armed
|
||||
//! entry wins, so a `;` list is a sequence of poses.
|
||||
//!
|
||||
//! The optional **pan** turns the parked yaw at a constant rate from that pose's own start
|
||||
//! (`"140,16,22@20:8"` — park, then sweep 8°/s). It exists because a parked camera is blind to a
|
||||
//! whole class of defect: the director's read of the Far Watch Post tower is that it "mostly only
|
||||
//! flickers while moving the cam, not while still" — z-fighting's signature, and precisely the
|
||||
//! condition decision 0653's still burst cannot see (decision 0656). Pair it with `benilla-visual
|
||||
//! flicker`'s **toggle map**, the reading that survives a moving view.
|
||||
//!
|
||||
//! It writes the rig fields directly for the same reason `probe_look` writes `face_yaw` directly —
|
||||
//! faking mouse motion needs the OS cursor inside the viewport, which an unfocused probe window
|
||||
//! cannot promise, and the camera *plumbing* is not what is under test. From these fields onward
|
||||
//! this is the identical path a mouse-drag takes.
|
||||
//! It writes the rig fields directly, as a mouse-drag would, since faking mouse motion needs the
|
||||
//! OS cursor inside an unfocused window.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -34,8 +18,7 @@ use benilla_world::schedule::WorldStage;
|
||||
|
||||
use super::ProbeClock;
|
||||
|
||||
/// One parked pose: absolute yaw/pitch (radians), an optional orbit distance (yards), when it takes
|
||||
/// over, and an optional constant yaw pan (rad/s) applied from that moment.
|
||||
/// One parked pose: yaw and pitch (radians), orbit distance (yards), start (s), yaw pan (rad/s).
|
||||
struct Pose {
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
@@ -49,8 +32,7 @@ pub(crate) struct ProbeCam {
|
||||
poses: Vec<Pose>,
|
||||
}
|
||||
|
||||
/// Parse the env script. Absent or unparseable entries yield no poses (with a warning), so the probe
|
||||
/// is inert unless asked for.
|
||||
/// Parses `WOW_PROBE_CAM`; unparseable entries are dropped, and no pose leaves the probe inert.
|
||||
pub(crate) fn from_env() -> Option<ProbeCam> {
|
||||
let spec = std::env::var("WOW_PROBE_CAM").ok()?;
|
||||
let poses: Vec<Pose> = spec
|
||||
@@ -62,13 +44,13 @@ pub(crate) fn from_env() -> Option<ProbeCam> {
|
||||
let mut parts = pose.split(',').map(str::trim);
|
||||
let yaw = parts.next()?.parse::<f32>().ok()?;
|
||||
let pitch = parts.next()?.parse::<f32>().ok()?;
|
||||
// A third field is the orbit distance; anything past it is a typo worth refusing.
|
||||
// A third field is the orbit distance; a fourth is refused.
|
||||
let distance = match (parts.next(), parts.next()) {
|
||||
(None, _) => Some(None),
|
||||
(Some(d), None) => d.parse::<f32>().ok().map(Some),
|
||||
(Some(_), Some(_)) => None,
|
||||
}?;
|
||||
// `@<start>` or `@<start>:<pan>` — the same `at:rate` shape `WOW_PROBE_LOOK` uses.
|
||||
// `@<start>` or `@<start>:<pan>`, the `WOW_PROBE_LOOK` shape.
|
||||
let (at, pan) = match at.trim().split_once(':') {
|
||||
Some((at, pan)) => (at.trim(), pan.trim().parse::<f32>().ok()?),
|
||||
None => (at.trim(), 0.0),
|
||||
@@ -88,16 +70,8 @@ pub(crate) fn from_env() -> Option<ProbeCam> {
|
||||
(!poses.is_empty()).then_some(ProbeCam { poses })
|
||||
}
|
||||
|
||||
/// Hold the camera at the latest armed pose. Runs in `WorldStage::Input` **before** `control`, so
|
||||
/// the frame that sees the pose is the frame that renders it.
|
||||
///
|
||||
/// On the **wall clock** ([`ProbeClock`], decision 0789), like every other probe schedule: `@25`
|
||||
/// means twenty-five real seconds in, and a pan of `8°/s` means eight degrees per real second. It
|
||||
/// read `Res<Time>` from 0653 until decision 1174 — clamped to `max_delta` (250 ms) and frozen
|
||||
/// outright by the capture harness, so a hitching or occluded run silently under-ran every knob.
|
||||
/// The drift was never caught because this file lived in `player/`, outside the reach of the very
|
||||
/// checker that exists to catch it ([`super::probes`]); moving the instrument into the harness is
|
||||
/// what surfaced it.
|
||||
/// Holds the camera at the latest armed pose, before `control` so the same frame renders it.
|
||||
/// Times and pan rates are wall-clock ([`ProbeClock`]), not `Res<Time>`, which clamps and freezes.
|
||||
pub(crate) fn drive_probe_cam(
|
||||
probe: Res<ProbeCam>,
|
||||
time: ProbeClock,
|
||||
@@ -116,19 +90,16 @@ pub(crate) fn drive_probe_cam(
|
||||
let Ok(mut cam) = cam.single_mut() else {
|
||||
return;
|
||||
};
|
||||
// Absolute, from the pose's own start — so the sweep is reproducible frame-for-frame across
|
||||
// runs rather than accumulating whatever frame times this run happened to get.
|
||||
// Absolute from the pose's start, so the sweep does not depend on frame times.
|
||||
cam.park(pose.yaw + pose.pan * (now - pose.at), pose.pitch);
|
||||
if let Some(d) = pose.distance {
|
||||
// `park_distance`, not the wheel target alone: the glide would ease `distance` back toward
|
||||
// the old target every frame and the parked shot would drift through the whole zoom while
|
||||
// the burst is running.
|
||||
// `park_distance`, not the wheel target: the glide would ease back toward the old target.
|
||||
rig.park_distance(d);
|
||||
}
|
||||
}
|
||||
|
||||
/// `WOW_PROBE_CAM`'s registration — [`super::probe_look::ProbeLookPlugin`]'s twin, in the same slot
|
||||
/// (`control` reads the rig right after) and equally inert without the variable.
|
||||
/// Registers `WOW_PROBE_CAM` in the same slot as [`super::probe_look::ProbeLookPlugin`]; inert
|
||||
/// without the variable.
|
||||
pub(crate) struct ProbeCamPlugin;
|
||||
|
||||
impl Plugin for ProbeCamPlugin {
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
//! The cast-cancel live probe (`WOW_PROBE=castcancel`) — the end-to-end instrument for the cast
|
||||
//! bar's LOCAL self-cancel (decision 0256 open item 2), inert without the env: once in-world, use
|
||||
//! the Hearthstone (a 10 s cast that never arms the send guard, so it exercises the
|
||||
//! started-`Casting` half of the cancel's inflight union), then 2 s into the bar inject a real
|
||||
//! `W` press into `ButtonInput<KeyCode>` — the ACTUAL controller path, so the probe drives the
|
||||
//! same move-start edge a player's key does (controller → `LocalMoveStart` →
|
||||
//! `spell::local_self_cancel` → `CMSG_CANCEL_CAST`).
|
||||
//! The cast-cancel live probe (`WOW_PROBE=castcancel`): once in-world it uses the Hearthstone (a
|
||||
//! 10 s cast), then 2 s into the bar presses `W` through `ButtonInput<KeyCode>`, the controller
|
||||
//! path a player's key takes to `spell::local_self_cancel` and `CMSG_CANCEL_CAST`.
|
||||
//!
|
||||
//! The verdict is read off the run log with `WOW_CAST_TRACE=1`: the `LOCAL self-cancel` line
|
||||
//! must land beside the `SEND move StartForward` line — frames, not a server round trip, after
|
||||
//! it — and the server's echo (`RECV CAST_RESULT failure`) must follow without repainting
|
||||
//! anything (the reap already emptied the `Casting` key it tests). The probe also logs every
|
||||
//! cast-bar phase transition ([`bar_timeline`]) so the red bar's hold/burst/fade durations are
|
||||
//! measured off the timestamps, not eyeballed. Non-combat, safe unattended
|
||||
//! (docs/METHOD.md's rule bans unattended *combat* probes); a hearth that completes because the
|
||||
//! cancel FAILED just ports the probe character home — visible in the log as the missing cancel line. Pair with
|
||||
//! the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill) + `WOW_CAST_TRACE=1 WOW_PROBE_EXIT_AT=<s>`.
|
||||
//! With `WOW_CAST_TRACE=1` the `LOCAL self-cancel` line must land frames after the `SEND move
|
||||
//! StartForward` line, and the server's `RECV CAST_RESULT failure` must follow without repainting.
|
||||
//! Every bar phase change is logged ([`bar_timeline`]) so the hold, burst and fade are measured
|
||||
//! from timestamps. Non-combat: a failed cancel only hearths the character home. The switches are
|
||||
//! `docs/CONTRIBUTING.md`, "Running it unattended".
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -30,7 +22,7 @@ impl Plugin for ProbeCastCancelPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bar's phase, read out of the live VM (the transcription's own state fields).
|
||||
/// The bar's phase, read from the stock `CastingBarFrame`'s state fields.
|
||||
const BAR_PHASE_CHUNK: &str = "return (function()\n\
|
||||
local f = CastingBarFrame\n\
|
||||
if not f then return \"noui\" end\n\
|
||||
@@ -44,10 +36,9 @@ const BAR_PHASE_CHUNK: &str = "return (function()\n\
|
||||
return \"shown|\" .. txt\n\
|
||||
end)()";
|
||||
|
||||
/// Log every cast-bar phase transition with a timestamp — the measured timeline of the
|
||||
/// transcription's hold/burst/fade (decision 0454's pin: `holdTime` 1 s wall-clock, then
|
||||
/// ~5 flash + ~20 fade ticks normalized to `CASTING_BAR_REF_TICK` 30 Hz ≈ 0.83 s). The deltas
|
||||
/// between the logged `hold` → `burst` → `fade` → `hidden` lines ARE the verdict; no eyeballing.
|
||||
/// Logs each cast-bar phase change with a timestamp. Stock `CastingBarFrame.lua` holds
|
||||
/// `CASTING_BAR_HOLD_TIME` (1 s), then steps the flash by 0.2 and the fade by 0.05 once per
|
||||
/// `OnUpdate` (`CastingBarFrame.lua:132-147`), so burst and fade last 5 and 20 frames.
|
||||
fn bar_timeline(
|
||||
time: ProbeClock,
|
||||
script: Option<NonSend<benilla_ui::script::UiScript>>,
|
||||
@@ -60,9 +51,7 @@ fn bar_timeline(
|
||||
let Some(script) = script else {
|
||||
return;
|
||||
};
|
||||
// Session-keyed (1290) even though this is harness code: the phase string is read *out of* the
|
||||
// VM, so a stale one would swallow the first transition of the next login — the exact event
|
||||
// this probe exists to timestamp.
|
||||
// Session-keyed: a phase string left from the last login would swallow the first transition.
|
||||
let last = last.get(&script);
|
||||
let Ok(state) = script.eval::<String>(BAR_PHASE_CHUNK) else {
|
||||
return;
|
||||
@@ -85,9 +74,8 @@ struct ProbeCastCancel {
|
||||
used_at: Option<f32>,
|
||||
}
|
||||
|
||||
/// Scan the backpack for the Hearthstone by item link (a restock can't silently move the
|
||||
/// probe's target) and use it. `false` = not resolved yet — a fresh character's first login
|
||||
/// still has the item-query round trip in flight, so the caller retries per frame.
|
||||
/// Finds the Hearthstone in the backpack by item link and uses it; `false` while the item query
|
||||
/// is still in flight, so the caller retries each frame.
|
||||
const USE_HEARTH_CHUNK: &str = "return (function()\n\
|
||||
for s = 1, GetContainerNumSlots(0) do\n\
|
||||
local link = GetContainerItemLink(0, s)\n\
|
||||
@@ -99,10 +87,8 @@ const USE_HEARTH_CHUNK: &str = "return (function()\n\
|
||||
return false\n\
|
||||
end)()";
|
||||
|
||||
/// The timeline: from **3.0 s** after world-enter, scan-until-resolved for the Hearthstone and
|
||||
/// use it (deadline 10 s — the link needs the server's item-query echo on a cold cache); **2.0 s
|
||||
/// into the 10 s cast** press `W`; **+0.3 s** release it. Every phase logs, so a stalled run
|
||||
/// shows where it stopped.
|
||||
/// From 3 s after world entry, use the Hearthstone (deadline 10 s), press `W` 2 s into the cast
|
||||
/// and release it 0.3 s later; every phase logs.
|
||||
fn cast_cancel_probe(
|
||||
time: ProbeClock,
|
||||
mut probe: ResMut<ProbeCastCancel>,
|
||||
@@ -132,7 +118,7 @@ fn cast_cancel_probe(
|
||||
probe.phase = 99;
|
||||
error!("castcancel probe: no Hearthstone link resolved by t+10 s");
|
||||
}
|
||||
Ok(false) => {} // still resolving — retry next frame
|
||||
Ok(false) => {} // still resolving, retry next frame
|
||||
Err(e) => {
|
||||
probe.phase = 99;
|
||||
error!("castcancel probe: {e}");
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
//! The character-create live probe (`WOW_PROBE_CHARCREATE="<name>[,race,class,gender[,skin,face,\
|
||||
//! hair,haircolor,facial]]"`) — the agent-side instrument that machine-verifies the char-create/delete
|
||||
//! wire against the live server (decision 0423, phase 1), inert without the env. It also keeps the
|
||||
//! `CharRequest::Create`/`Delete` verbs exercised until their UI lands (the create screen is phase 4,
|
||||
//! delete's UI is deferred).
|
||||
//!
|
||||
//! While parked at character select it sends [`CharRequest::Create`] with the parsed request, logs
|
||||
//! the `SMSG_CHAR_CREATE` result byte, and — unless `WOW_PROBE_CHARCREATE_KEEP=1` — deletes the
|
||||
//! character it just made ([`CharRequest::Delete`]) so the account isn't littered, logging that
|
||||
//! result too. Run it **without** `WOW_CHAR` (which would auto-enter the world and leave select),
|
||||
//! against the checkout's own probe account (`.probe-identity`, or `WOW_USER`/`WOW_PASS` — the
|
||||
//! `probe` skill); creating/deleting characters is a
|
||||
//! non-combat operation, safe to run headlessly.
|
||||
//! The character-create live probe (`WOW_PROBE_CHARCREATE="<name>[,race,class,gender[,skin,\
|
||||
//! face,hair,haircolor,facial]]"`), inert without the env. At character select it sends
|
||||
//! [`CharRequest::Create`], logs the `SMSG_CHAR_CREATE` result byte, then deletes the new
|
||||
//! character and logs that result too, unless `WOW_PROBE_CHARCREATE_KEEP=1`. Run it without
|
||||
//! `WOW_CHAR`, which would enter the world; the switches are `docs/CONTRIBUTING.md`, "Running it
|
||||
//! unattended".
|
||||
|
||||
use benilla_protocol::{messages, CharAction, CharCreateReq};
|
||||
use bevy::prelude::*;
|
||||
@@ -75,18 +68,18 @@ struct CharCreateProbe {
|
||||
req: CharCreateReq,
|
||||
keep: bool,
|
||||
state: ProbeState,
|
||||
/// The freshest roster seen (tracked so a successful create can find its guid to clean up).
|
||||
/// The freshest roster, where a successful create finds its guid to clean up.
|
||||
roster: Vec<benilla_protocol::Character>,
|
||||
}
|
||||
|
||||
/// Drive the probe's create → (delete) → done sequence off the roster + result messages.
|
||||
/// Drive create, then delete, off the roster and result messages.
|
||||
fn drive_charcreate_probe(
|
||||
mut probe: ResMut<CharCreateProbe>,
|
||||
pick: Res<CharPick>,
|
||||
mut lists: MessageReader<CharListMessage>,
|
||||
mut results: MessageReader<CharActionResultMessage>,
|
||||
) {
|
||||
// Always track the freshest roster (the fresh list precedes each successful action's result).
|
||||
// The fresh roster arrives before each successful action's result.
|
||||
let got_roster = lists.read().fold(false, |_, m| {
|
||||
probe.roster = m.characters.clone();
|
||||
true
|
||||
@@ -167,8 +160,7 @@ fn drive_charcreate_probe(
|
||||
}
|
||||
}
|
||||
|
||||
/// A minimal name for the result byte (the create screen has the full 1.12 GlueStrings table; the
|
||||
/// probe only needs the anchors it verifies against).
|
||||
/// A name for the result bytes the probe checks; the create screen has the full table.
|
||||
fn result_label(action: CharAction, code: u8) -> &'static str {
|
||||
match (action, code) {
|
||||
(CharAction::Create, messages::CHAR_CREATE_SUCCESS) => "CHAR_CREATE_SUCCESS",
|
||||
|
||||
@@ -1,109 +1,24 @@
|
||||
//! The guild-charter live probe (`WOW_PROBE_CHARTER=1`) — decision 1672's end-to-end instrument:
|
||||
//! log in, GM-hop to the Stormwind guild registrar, open his gossip menu on the real wire, assert
|
||||
//! the charter row's icon reads **petition**, select it by its wire index, buy a charter through
|
||||
//! the registrar's own window, right-click the charter in the bags the way a player does, watch the
|
||||
//! petition window fill in a round trip later, rename it, and destroy it again so the next run can
|
||||
//! do the same. One `PROBE_CHARTER: <step> PASS/FAIL/SKIP <detail>` line per step, then a final
|
||||
//! `PROBE_CHARTER: DONE pass=<n> fail=<m>`. Modeled closely on [`super::probe_binder`] — same phase
|
||||
//! machine, same trace style, same self-terminating exit ([`super::probes::ProbeExitPlugin`]'s
|
||||
//! pattern), same live-VM observation idiom (`script.eval` against the real UI VM) — with
|
||||
//! [`super::probe_clam`]'s bag-click leg (`UseContainerItem` through the live VM, so the real
|
||||
//! dispatcher runs) and its watch-the-cleanup-land exit.
|
||||
//! The guild-charter live probe (`WOW_PROBE_CHARTER=1`): hop to the Stormwind guild registrar,
|
||||
//! open his gossip menu, check the charter row's icon reads `petition`, select it by wire index,
|
||||
//! buy a charter through the registrar's window, right-click it in the bags, watch the petition
|
||||
//! window fill a round trip later, rename it and destroy it so the next run can repeat. One
|
||||
//! `PROBE_CHARTER: <step> PASS/FAIL/SKIP <detail>` line per step, then
|
||||
//! `PROBE_CHARTER: DONE pass=<n> fail=<m>`. Inert without the env.
|
||||
//!
|
||||
//! **Unit tests cannot reach any of this.** Every claim the charter slice rests on is about what a
|
||||
//! real server sends and in what order: a packet that had no const and no parse arm until 1672
|
||||
//! landed, a window that deliberately opens *empty* and fills a round trip later, and an item-use
|
||||
//! fork arm whose whole evidence is "no other arm accepts a charter". Those are only visible
|
||||
//! against real bytes.
|
||||
//! The registrar is Aldwin Laughlin (entry 4974, spawn guid 79681, map 0), `npc_flags` `0x601`:
|
||||
//! the buy handler wants the petitioner flag and also refuses anything that is not a tabard
|
||||
//! designer (`PetitionsHandler.cpp:44-52`). His menu 708 sends two unconditional rows, the charter
|
||||
//! at wire index 0 with icon 7 (`petition` in the reference's `0x84b7ac` table) and the tabard
|
||||
//! designer with icon 8. The charter is entry 5863, flags `0x2000` (`ITEM_FLAG_CHARTER`), no use
|
||||
//! spell, at 1000 copper (`PetitionsHandler.cpp:38`); a charter name is at most 24 characters
|
||||
//! (`ObjectMgr.h:401`), digits and spaces allowed.
|
||||
//!
|
||||
//! ## The registrar (live-DB verified against the local vmangos, `mangos` DB)
|
||||
//! One client cannot sign, offer or turn in a charter: that half needs other accounts. Closing the
|
||||
//! window is not asserted: closing another player's charter sends `MSG_PETITION_DECLINE`
|
||||
//! (`0x4f3f60`).
|
||||
//!
|
||||
//! Aldwin Laughlin, the Stormwind guild registrar — `creature_template.entry = 4974`, spawn
|
||||
//! `creature.guid = 79681`, **map 0**, position `(-8885.25, 614.395, 95.2576)`,
|
||||
//! `creature_template.npc_flags = 1537` = `0x601` = GOSSIP | **PETITIONER (0x200)** |
|
||||
//! TABARDDESIGNER (0x400), `gossip_menu_id = 708`. Both of the top two bits matter: vmangos's
|
||||
//! buy handler fetches the NPC with `GetNPCIfCanInteractWith(guid, UNIT_NPC_FLAG_PETITIONER)` and
|
||||
//! then *also* refuses anything that is not `IsTabardDesigner()`
|
||||
//! (`Handlers/PetitionsHandler.cpp:44-52`), so a petitioner without the tabard bit sells nothing.
|
||||
//!
|
||||
//! **`UNIT_NPC_FLAG_PETITIONER` is `0x200`** (vmangos `Objects/UnitDefines.h:666`). The probe
|
||||
//! reuses [`crate::target::cursor_mode::npc_flags::PETITIONER`] rather than keeping a second copy
|
||||
//! — a duplicated flag table is exactly how B249's icon map went stale.
|
||||
//!
|
||||
//! ## The charter row (live-DB verified this session)
|
||||
//!
|
||||
//! `gossip_menu_option` for menu 708 carries **exactly two rows, both unconditional**
|
||||
//! (`condition_id = 0`), so a GM probe and a player see the same two — unlike the innkeeper menu
|
||||
//! [`super::probe_binder`] walks, where GM mode adds holiday rows:
|
||||
//!
|
||||
//! | wire index | `option_icon` | `option_id` | text |
|
||||
//! |------------|---------------|--------------------------------|---------------------------------|
|
||||
//! | 0 | **7** | 10 (`GOSSIP_OPTION_PETITIONER`)| *"How do I form a guild?"* |
|
||||
//! | 1 | 8 | 11 (tabard designer) | *"I want to create a guild crest."* |
|
||||
//!
|
||||
//! Icon byte **7** is `"petition"` in [`crate::ui_gossip`]'s `GOSSIP_ICON_TYPES` — the client's own
|
||||
//! `0x84b7ac` table, byte-verified in decision 1335. The probe finds the row **by that icon byte
|
||||
//! and selects by the row's wire index, never by list position**: that is the lesson
|
||||
//! [`super::probe_binder`]'s header records, and it is why this probe survives a menu that grows a
|
||||
//! row. The label guard is a lowercase substring for the same file's other lesson — vmangos
|
||||
//! prefers `option_broadcast_text` (3413) over the `option_text` column, so an equality test would
|
||||
//! be asserting which of two copies of the same sentence the server happened to pick.
|
||||
//!
|
||||
//! ## The charter item and its price (live-DB + vmangos source, verified this session)
|
||||
//!
|
||||
//! Entry **5863** "Guild Charter", `display_id = 16161`, `flags = 8192` (`0x2000` =
|
||||
//! `ITEM_FLAG_CHARTER`), `max_count = 1`, `inventory_type = 0`, `start_quest = 0`, `page_text = 0`,
|
||||
//! **no ON_USE spell** and not LOOTABLE. That combination is the whole of
|
||||
//! [`crate::ui_items::ItemUseRoute::ShowPetition`]'s evidence: every other arm of the item-use fork
|
||||
//! declines a charter, so before 1672 a right-click reached `Nothing` and sent nothing at all.
|
||||
//!
|
||||
//! The price is **1000 copper** (10 silver) — vmangos `GUILD_CHARTER_COST`
|
||||
//! (`Handlers/PetitionsHandler.cpp:39`), which arrives in `SMSG_PETITION_SHOWLIST`'s `charterCost`
|
||||
//! field and is what `GetGuildCharterCost()` must quote.
|
||||
//!
|
||||
//! `MAX_CHARTER_NAME` is **24** UTF-8 characters (`ObjectMgr.h:401`, enforced by
|
||||
//! `ObjectMgr::IsValidCharterName`, which allows digits and spaces —
|
||||
//! `isValidString(…, numericOrSpace = true)`). The probe's names are 22 characters.
|
||||
//!
|
||||
//! ## What each step can and cannot conclude
|
||||
//!
|
||||
//! | step | what a FAIL there means | `ui_petition` claim it bears on |
|
||||
//! |------|-------------------------|---------------------------------|
|
||||
//! | 0 | the probe left a guild behind and `GuildLeave`/`GuildDisband` did not take — every later step is meaningless, because a buy is refused **silently** while guilded | — (re-runnability) |
|
||||
//! | 1 | environmental: the `.go` was refused or Stormwind never streamed — SKIP, not a defect | — |
|
||||
//! | 2 | the gossip wire or the B292 text-query hold is broken for this NPC | — |
|
||||
//! | 3 | the icon table regressed one row over from B249: byte 7 must read `"petition"` | — |
|
||||
//! | 4 | `SMSG_PETITION_SHOWLIST` is not parsed, does not fire `GUILD_REGISTRAR_SHOW`, or its `charterCost` is not what `GetGuildCharterCost()` reads | **INFERRED #1**, and `script::petition`'s "what `GetGuildCharterCost` reads" |
|
||||
//! | 5 | the buy never reached the server, or was refused — there is **no confirmation packet at all**, so the item arriving *is* the answer | — |
|
||||
//! | 6 | using a charter does not open the petition window: either the fork arm or `SMSG_PETITION_SHOW_SIGNATURES` → `PETITION_SHOW` | **INFERRED #6**, and **#2** |
|
||||
//! | 7 | the lazy record fill is broken — the window would sit titleless forever, which is the whole two-caches design | `script::petition`'s "the repaint path" |
|
||||
//! | 8 | `MSG_PETITION_RENAME`'s echo does not patch the cached record (the echo is sent **only on success**) | — |
|
||||
//! | 9 | the charter is still in the bags, and the **next** run's buy will be refused silently for "the owner already has one" (`PetitionsHandler.cpp:70`) — a failure that looks nothing like its cause | — (re-runnability) |
|
||||
//!
|
||||
//! Three of `ui_petition`'s six INFERRED claims are **not** touched here, and none of the three is
|
||||
//! an oversight. #4 (re-asking for the signature list on an `OK` sign result) needs a second
|
||||
//! account to sign, and #5 (`ERR_GUILD_FOUNDER_S` on a successful turn-in) needs nine of them —
|
||||
//! neither is reachable from one client, so the whole sign/offer/turn-in half of the family
|
||||
//! (`SignPetition`, `OfferPetition`, `TurnInGuildCharter`, `MSG_PETITION_DECLINE`) still has no
|
||||
//! live coverage after this probe. #3 (the two closes send nothing) is *superseded*: closing a
|
||||
//! charter you do NOT own can send `MSG_PETITION_DECLINE` (`0x4f3f60`), so this probe deliberately
|
||||
//! asserts nothing about either close rather than pinning a claim that is being rewritten.
|
||||
//!
|
||||
//! ## The run recipe
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_NOSOUND=1 WOW_USER=probe0 WOW_PASS=pprobe0 WOW_CHAR=Probezero \
|
||||
//! WOW_PROBE_CHARTER=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the checkout's probe identity, the `probe` skill. **Never a player's account** — a login on it kicks
|
||||
//! the director's live session.) `WOW_NOSOUND=1` because an unattended probe must not play zone
|
||||
//! music into the director's room; `caffeinate -dis` is **not** needed for a run this short — the
|
||||
//! whole sequence is a handful of round trips and finishes in well under a minute.
|
||||
//!
|
||||
//! Non-combat: this probe never fights and never targets anything. GM mode is left exactly as
|
||||
//! found. An outer `timeout` + grep on `PROBE_CHARTER:` is the whole harness; the probe self-exits
|
||||
//! once DONE, and every wait is bounded — a hung leg FAILs with a legible detail rather than
|
||||
//! hanging the run.
|
||||
//! Non-combat; GM mode is left as found, every wait is bounded and the probe exits once done. How
|
||||
//! to run it is `docs/CONTRIBUTING.md`, "Running it unattended".
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -125,65 +40,47 @@ use crate::ui_items::{find_item, ItemSearch, PACK_SLOTS};
|
||||
use crate::ui_petition::GuildRegistrarState;
|
||||
use crate::ui_session::NpcSession;
|
||||
|
||||
/// Aldwin Laughlin's spawn (vmangos `creature` guid 79681, entry 4974) — the `.go xyz` target.
|
||||
/// Aldwin Laughlin's spawn, the `.go xyz` target.
|
||||
const REGISTRAR_AT: [f32; 3] = [-8885.25, 614.395, 95.2576];
|
||||
/// His map — Eastern Kingdoms. `.go xyz` takes the map id as its fourth argument.
|
||||
/// Eastern Kingdoms, `.go xyz`'s fourth argument.
|
||||
const REGISTRAR_MAP: u32 = 0;
|
||||
/// His creature template entry — the streamed-unit identity check (module doc). The `0x200` npc
|
||||
/// flag is only the fallback, exactly as the binder probe splits it.
|
||||
/// His creature entry, the streamed-unit check; the petitioner flag is the fallback.
|
||||
const REGISTRAR_ENTRY: u32 = 4974;
|
||||
/// The wire `GOSSIP_ICON` byte menu 708's charter row sends (module doc) — decision 1335's table
|
||||
/// indexes it to [`ICON_TYPE_PETITION`].
|
||||
/// The wire icon byte of menu 708's charter row.
|
||||
const ICON_PETITION: u8 = 7;
|
||||
/// The type string [`crate::ui_gossip`]'s table must produce for [`ICON_PETITION`].
|
||||
const ICON_TYPE_PETITION: &str = "petition";
|
||||
/// What a broken icon table produces instead — the chat bubble. Seeing it back here is B249's
|
||||
/// regression one row over, not a flake.
|
||||
/// What a wrong icon table produces instead: the chat bubble.
|
||||
const ICON_TYPE_REGRESSION: &str = "gossip";
|
||||
/// The substring the charter row's label must carry before the probe is willing to select it. A
|
||||
/// lowercase *substring*, never an equality: the label on the wire is the row's
|
||||
/// `option_broadcast_text` (3413) rather than its `option_text` column, and the exact wording is
|
||||
/// the server's to choose (module doc).
|
||||
/// A lowercase substring of the charter row's label: the wire label is the row's broadcast text,
|
||||
/// not its `option_text` column.
|
||||
const CHARTER_LABEL_HINT: &str = "guild";
|
||||
/// Scan radius around the `.go` landing, generously wide so a slightly-off hop still finds him
|
||||
/// (the bank/mail/binder probes' shared idiom).
|
||||
/// Scan radius around the `.go` landing.
|
||||
const SCAN_RANGE: f32 = 12.0;
|
||||
/// The price `GetGuildCharterCost()` must quote, in copper — vmangos `GUILD_CHARTER_COST`
|
||||
/// (`PetitionsHandler.cpp:39`), carried in `SMSG_PETITION_SHOWLIST`'s `charterCost`.
|
||||
/// `GUILD_CHARTER_COST` in copper (`PetitionsHandler.cpp:38`), the showlist's `charterCost`.
|
||||
const CHARTER_COST_COPPER: i64 = 1000;
|
||||
/// What `GetPetitionInfo()`'s `maxSignatures` must read once the record lands — vmangos answers
|
||||
/// `SMSG_PETITION_QUERY_RESPONSE` with `minSignatures = maxSignatures = 9`, hardcoded
|
||||
/// (`PetitionsHandler.cpp:182-183`), whatever `MinPetitionSigns` is configured to.
|
||||
/// `GetPetitionInfo()`'s `maxSignatures`: vmangos hardcodes min and max to 9
|
||||
/// (`PetitionsHandler.cpp:182-183`) whatever `MinPetitionSigns` is.
|
||||
const REQUIRED_SIGNATURES: i64 = 9;
|
||||
/// A fresh charter has **no** signatures: `GetNumPetitionNames()` counts signers, and the owner is
|
||||
/// not one of them (the reference paints the owner into its own font string).
|
||||
/// `GetNumPetitionNames()` counts signers, and the owner is not one.
|
||||
const FRESH_SIGNATURES: i64 = 0;
|
||||
/// Copper handed to the probe body up front so the buy can never fail for funds. `.modify money`
|
||||
/// is `SEC_BASIC_ADMIN` (4) in vmangos's `Chat.cpp` command table and every `probeN` account is
|
||||
/// gmlevel **6** (docs/METHOD.md, decision 0651), so it lands; with no selection it targets the sender
|
||||
/// (`ChatHandler::GetSelectedPlayer`, `Chat.cpp:2601-2612`), which is why it is sent before the
|
||||
/// probe touches an NPC.
|
||||
/// Copper sent up front so the buy never fails for funds. `.modify money` is `SEC_BASIC_ADMIN`
|
||||
/// (`Chat.cpp:586`) and targets the sender with nothing selected (`Chat.cpp:2601-2612`), so it
|
||||
/// goes before any NPC is targeted.
|
||||
const FUND_COPPER: u32 = 100_000;
|
||||
|
||||
/// Settle after the `.go` before scanning — the hop's own travel plus a frame for the stream.
|
||||
/// Settle after the `.go` before scanning.
|
||||
const SETTLE_SECS: f64 = 3.0;
|
||||
/// The waits are deliberately generous, for the reason [`super::probe_binder`] measured: a `.go`
|
||||
/// can land inside a terrain load, and the probe then gets roughly one frame per second to poll
|
||||
/// in. A timeout tight enough to trip on that would report a FAIL about the wire, which is the one
|
||||
/// thing an instrument must never do.
|
||||
/// Generous: a `.go` can land inside a terrain load, where the probe polls about once a second.
|
||||
const GUILD_CLEAR_TIMEOUT_SECS: f64 = 20.0;
|
||||
const SCAN_TIMEOUT_SECS: f64 = 25.0;
|
||||
const MENU_TIMEOUT_SECS: f64 = 20.0;
|
||||
/// How long step 4 will wait for the portrait booth's `"npc"` token before reporting it empty.
|
||||
///
|
||||
/// Not a gate — the step passes either way. It exists so the detail line says something true: the
|
||||
/// booth is fed by a system that is deliberately unordered against the apply pass, so the token is
|
||||
/// `None` on the frame the window opens and resolves on the next.
|
||||
/// How long step 4 waits for the portrait's `"npc"` token, reported, not asserted:
|
||||
/// `feed_interact_npc` is unordered against the apply pass, so it resolves a frame late.
|
||||
const REGISTRAR_TOKEN_SETTLE_SECS: f64 = 1.0;
|
||||
|
||||
const REGISTRAR_TIMEOUT_SECS: f64 = 15.0;
|
||||
/// The buy's wait is the longest of the action legs: nothing acknowledges it, so this is the time
|
||||
/// the *item* gets to arrive and its template with it.
|
||||
/// The longest wait: nothing acknowledges a buy, so this is for the item and its template.
|
||||
const BUY_TIMEOUT_SECS: f64 = 25.0;
|
||||
const PETITION_TIMEOUT_SECS: f64 = 15.0;
|
||||
const RECORD_TIMEOUT_SECS: f64 = 15.0;
|
||||
@@ -199,33 +96,28 @@ impl Plugin for ProbeCharterPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe's phase machine plus the identities discovered along the way (the binder/bank probes'
|
||||
/// shape: a `Copy` phase snapshotted out of the resource each tick, so an arm can mutate `probe`
|
||||
/// freely).
|
||||
/// The probe's phase machine and what it found; the `Copy` phase is snapshotted each tick so an
|
||||
/// arm can mutate `probe` freely.
|
||||
#[derive(Resource, Default)]
|
||||
struct CharterProbe {
|
||||
phase: Phase,
|
||||
/// The registrar's guid, once streamed in.
|
||||
registrar: Option<u64>,
|
||||
/// The charter row's **wire** `index` — the value the packet carried and the value
|
||||
/// `CMSG_GOSSIP_SELECT_OPTION` must echo back. vmangos numbers them over the rows it actually
|
||||
/// sends (`GossipDef.cpp:188`), so it is the row's 0-based position in *this* menu — neither
|
||||
/// the DB's `gossip_menu_option.id` nor the Lua menu's 1-based position, and the probe assumes
|
||||
/// no relation between them for the same reason the real drain doesn't.
|
||||
/// The charter row's wire index, which `CMSG_GOSSIP_SELECT_OPTION` echoes: vmangos numbers the
|
||||
/// rows it sends from 0 (`GossipDef.cpp:188`), neither the DB id nor the Lua position.
|
||||
charter_row: Option<u32>,
|
||||
/// Where the bought charter is: the wire `(bag_index, slot)` pair and the instance guid, from
|
||||
/// [`find_item`]. The Lua click position is derived from it by [`lua_bag_pos`].
|
||||
/// The bought charter's wire `(bag_index, slot)` and item guid, from [`find_item`].
|
||||
charter: Option<(u8, u8, u64)>,
|
||||
/// The guild name bought in step 5 — what step 7 waits for the title to become.
|
||||
/// The guild name bought in step 5, the title step 7 waits for.
|
||||
bought: String,
|
||||
/// The name step 8 renames to — what step 8 waits for the title to become.
|
||||
/// The name step 8 renames to, the title step 8 waits for.
|
||||
renamed: String,
|
||||
/// The title read at the instant the petition window first became visible (step 6). Step 7
|
||||
/// asserts it was either empty (the packet carries no text) or already filled.
|
||||
/// The title when the petition window first showed (step 6): empty, since the packet carries
|
||||
/// no text, or already filled.
|
||||
title_at_open: String,
|
||||
passes: u32,
|
||||
fails: u32,
|
||||
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
|
||||
/// Latched once [`Phase::Done`] has fired its exit.
|
||||
exited: bool,
|
||||
}
|
||||
|
||||
@@ -249,45 +141,44 @@ impl CharterProbe {
|
||||
enum Phase {
|
||||
#[default]
|
||||
Wait,
|
||||
/// Step 0 — the money is sent and any guild an earlier run founded is being left. `sent`
|
||||
/// distinguishes "haven't asked yet" from "asked, waiting for the descriptor to clear".
|
||||
/// Step 0: money sent; leaving any guild an earlier run founded (`sent` once the verb ran).
|
||||
Unguild {
|
||||
since: f64,
|
||||
sent: bool,
|
||||
},
|
||||
/// Step 1 — `.go` issued; settling before the world streams the registrar in.
|
||||
/// Step 1: `.go` sent; settling while the registrar streams in.
|
||||
Settling {
|
||||
sent_at: f64,
|
||||
},
|
||||
/// Step 2 — `GossipHello` sent; waiting for the parsed menu AND its push into the VM.
|
||||
/// Step 2: `GossipHello` sent; waiting for the parsed menu and its push into the VM.
|
||||
Menu {
|
||||
sent_at: f64,
|
||||
},
|
||||
/// Step 4 — the charter row selected; waiting for `SMSG_PETITION_SHOWLIST` to reach
|
||||
/// Step 4: the charter row selected; waiting for `SMSG_PETITION_SHOWLIST` to reach
|
||||
/// [`GuildRegistrarState`] and `GUILD_REGISTRAR_SHOW` to open the window.
|
||||
Registrar {
|
||||
since: f64,
|
||||
},
|
||||
/// Step 5 — the registrar's purchase panel driven; waiting for the charter item to arrive.
|
||||
/// Step 5: the purchase sent; waiting for the charter item.
|
||||
Buying {
|
||||
since: f64,
|
||||
sent: bool,
|
||||
},
|
||||
/// Step 6 — the charter right-clicked through the live VM; waiting for the petition window.
|
||||
/// Step 6: the charter right-clicked through the live VM; waiting for the petition window.
|
||||
Opening {
|
||||
since: f64,
|
||||
sent: bool,
|
||||
},
|
||||
/// Step 7 — waiting for `SMSG_PETITION_QUERY_RESPONSE` to fill the title in.
|
||||
/// Step 7: waiting for `SMSG_PETITION_QUERY_RESPONSE` to fill the title in.
|
||||
Record {
|
||||
since: f64,
|
||||
},
|
||||
/// Step 8 — `RenamePetition` run in the live VM; waiting for the echo to patch the record.
|
||||
/// Step 8: `RenamePetition` run; waiting for the echo to patch the record.
|
||||
Renaming {
|
||||
since: f64,
|
||||
sent: bool,
|
||||
},
|
||||
/// Step 9 — the charter destroyed; watching it actually leave the bags before exiting.
|
||||
/// Step 9: the charter destroyed; waiting for it to leave the bags.
|
||||
Destroying {
|
||||
since: f64,
|
||||
sent: bool,
|
||||
@@ -296,34 +187,26 @@ enum Phase {
|
||||
}
|
||||
|
||||
// ── The live-VM readings ─────────────────────────────────────────────────────────────────────
|
||||
// Every one of these answers a default on an eval hiccup and is treated as "nothing observed
|
||||
// yet", never a panic (the bank/binder probes' idiom): the probe's own timeouts are what turn a
|
||||
// persistently missing reading into a verdict.
|
||||
// Each answers a default on an eval error, read as "nothing yet"; the timeouts give the verdict.
|
||||
|
||||
/// The `CHAT_MSG_SYSTEM` / `UI_ERROR_MESSAGE` lines seen since the hook went in, newest last.
|
||||
///
|
||||
/// This is the probe's window into the family's **silent** refusals: a buy or a rename that the
|
||||
/// server turns down comes back as `SMSG_GUILD_COMMAND_RESULT` on the *guild* family's channel,
|
||||
/// which [`crate::ui_guild::lines`] prints as a system line and nothing else records. Without it a
|
||||
/// refused buy and a lost packet read identically.
|
||||
/// The `CHAT_MSG_SYSTEM` / `UI_ERROR_MESSAGE` lines seen since the hook went in, newest last: a
|
||||
/// refused buy or rename comes back only as `SMSG_GUILD_COMMAND_RESULT`, printed as a system line.
|
||||
fn probe_lines(script: &UiScript) -> Vec<String> {
|
||||
script
|
||||
.eval::<Vec<String>>("return ProbeCharterLines or {}")
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// How many values the live `GetGossipOptions()` returns — flat `(label, type)` pairs, so twice the
|
||||
/// row count. The probe waits on this rather than assuming the feed already ran this frame, and it
|
||||
/// is also what makes the wait honour [`crate::ui_gossip`]'s B292 hold: on the first visit to a
|
||||
/// `text_id` the menu deliberately does not reach the VM until `SMSG_NPC_TEXT_UPDATE` lands.
|
||||
/// How many values the live `GetGossipOptions()` returns, flat `(label, type)` pairs: on a first
|
||||
/// visit the menu reaches the VM only once `SMSG_NPC_TEXT_UPDATE` lands.
|
||||
fn vm_gossip_values(script: &UiScript) -> i64 {
|
||||
script
|
||||
.eval::<i64>("local t = { GetGossipOptions() } return table.getn(t)")
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The icon **type string** the app mapped for the 1-based menu row `pos` — read exactly where the
|
||||
/// FrameXML reads it, out of the pushed snapshot through the Era `GetGossipOptions()` vararg.
|
||||
/// The icon type string for the 1-based menu row `pos`, read through `GetGossipOptions()` as the
|
||||
/// stock frame reads it.
|
||||
fn vm_icon_type(script: &UiScript, pos: usize) -> String {
|
||||
script
|
||||
.eval::<String>(&format!(
|
||||
@@ -333,15 +216,8 @@ fn vm_icon_type(script: &UiScript, pos: usize) -> String {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The texture path `BENILLA_GOSSIP_ICONS.petition` resolves to in the live VM — `""` if the table
|
||||
/// or the key is missing, which would mean the row draws the fallback bubble whatever the app
|
||||
/// mapped ([`super::probe_binder`]'s second half of the same assert).
|
||||
/// The charter's own bag tooltip, line by line — `SetBagItem` on the slot the buy landed in, read
|
||||
/// back out of the live VM.
|
||||
///
|
||||
/// Reported in step 8's detail rather than asserted, for §7's reason: the wording and the ORDER are
|
||||
/// a unit test's job, and what a live run adds is only that the petition record travels from the
|
||||
/// query response into the bag slot's pushed view at all.
|
||||
/// The charter's bag tooltip, line by line, from `SetBagItem` in the live VM; step 8 checks only
|
||||
/// that the renamed title reaches it, the wording is a unit test's.
|
||||
fn charter_tooltip_lines(script: &UiScript, pos: Option<(i64, u32)>) -> Vec<String> {
|
||||
let Some((bag, slot)) = pos else {
|
||||
return Vec::new();
|
||||
@@ -369,15 +245,15 @@ fn charter_tooltip_lines(script: &UiScript, pos: Option<(i64, u32)>) -> Vec<Stri
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `BENILLA_GOSSIP_ICONS.petition` in the live VM, `""` when absent. The stock
|
||||
/// `GossipFrame.lua:123` builds the icon path from the type string and defines no such table.
|
||||
fn vm_petition_texture(script: &UiScript) -> String {
|
||||
script
|
||||
.eval::<String>("return (BENILLA_GOSSIP_ICONS and BENILLA_GOSSIP_ICONS.petition) or \"\"")
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Is a named frame up, asked of the live UI rather than of our own state — the window is what a
|
||||
/// player looks for. Nil-guarded on the global so a build without the file reads `false` instead of
|
||||
/// raising.
|
||||
/// Is a named frame visible in the live UI; a missing global reads `false`.
|
||||
fn vm_visible(script: &UiScript, frame: &str) -> bool {
|
||||
script
|
||||
.eval::<bool>(&format!(
|
||||
@@ -386,15 +262,15 @@ fn vm_visible(script: &UiScript, frame: &str) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `GetGuildCharterCost()` — the registrar's price in copper.
|
||||
/// `GetGuildCharterCost()`, the registrar's price in copper.
|
||||
fn vm_charter_cost(script: &UiScript) -> i64 {
|
||||
script
|
||||
.eval::<i64>("return GetGuildCharterCost()")
|
||||
.unwrap_or(-1)
|
||||
}
|
||||
|
||||
/// One of `GetPetitionInfo()`'s six returns, by 1-based position, as a string. With nothing open
|
||||
/// the getter returns **no values at all**, so every slot reads `""`.
|
||||
/// One of `GetPetitionInfo()`'s six returns, by 1-based position; `""` with nothing open, when it
|
||||
/// returns no values.
|
||||
fn vm_petition_str(script: &UiScript, slot: usize) -> String {
|
||||
let binds = "_,".repeat(slot - 1);
|
||||
script
|
||||
@@ -404,33 +280,29 @@ fn vm_petition_str(script: &UiScript, slot: usize) -> String {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `GetPetitionInfo()`'s fourth return — the signature requirement off the wire.
|
||||
/// `GetPetitionInfo()`'s fourth return, the signature requirement.
|
||||
fn vm_petition_max(script: &UiScript) -> i64 {
|
||||
script
|
||||
.eval::<i64>("local _,_,_,v = GetPetitionInfo() return v or -1")
|
||||
.unwrap_or(-1)
|
||||
}
|
||||
|
||||
/// `GetPetitionInfo()`'s sixth return — `isOriginator`, an Era `1`/`nil` (never `true`/`false`).
|
||||
/// `GetPetitionInfo()`'s sixth return, `isOriginator`: `1` or `nil`, never a boolean.
|
||||
fn vm_is_originator(script: &UiScript) -> i64 {
|
||||
script
|
||||
.eval::<i64>("local _,_,_,_,_,v = GetPetitionInfo() return v or 0")
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `GetNumPetitionNames()` — the signers, which never include the owner.
|
||||
/// `GetNumPetitionNames()`, the signers, never the owner.
|
||||
fn vm_num_names(script: &UiScript) -> i64 {
|
||||
script
|
||||
.eval::<i64>("return GetNumPetitionNames()")
|
||||
.unwrap_or(-1)
|
||||
}
|
||||
|
||||
/// The inverse of [`crate::ui_items::wire_pos`] for the two regions a **bag right-click** can
|
||||
/// address: the backpack (Lua container `0`) and the four equipped bags (`1..=4`).
|
||||
///
|
||||
/// `None` for anywhere else — the bank, the keyring, a doll slot. A bought charter cannot land in
|
||||
/// any of them, so a `None` here is the probe refusing to click a slot it cannot name rather than
|
||||
/// guessing one.
|
||||
/// The inverse of [`crate::ui_items::wire_pos`] for the backpack (Lua container `0`) and the four
|
||||
/// equipped bags (`1..=4`); `None` anywhere else.
|
||||
fn lua_bag_pos(bag_index: u8, slot: u8) -> Option<(i64, u32)> {
|
||||
if bag_index == BAG_PLAYER_INVENTORY {
|
||||
let inner = slot.checked_sub(SLOT_PACK_FIRST)?;
|
||||
@@ -441,10 +313,8 @@ fn lua_bag_pos(bag_index: u8, slot: u8) -> Option<(i64, u32)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A guild name that cannot collide with a previous run or a real guild, and cannot be refused for
|
||||
/// length: a 13-character prefix, a space, and the low 8 digits of the wall clock — 22 characters,
|
||||
/// inside vmangos's `MAX_CHARTER_NAME` of 24. Digits and spaces are both allowed by
|
||||
/// `IsValidCharterName` (`isValidString(…, numericOrSpace = true)`, verified this session).
|
||||
/// A fresh guild name: a 13-character prefix, a space and the low 8 digits of the wall clock, 22
|
||||
/// characters within `MAX_CHARTER_NAME` (24); `IsValidCharterName` allows digits and spaces.
|
||||
fn probe_guild_name(prefix: &str) -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -471,16 +341,14 @@ fn charter_probe(
|
||||
return; // not in-world yet
|
||||
};
|
||||
let Some(script) = script else {
|
||||
return; // no UI VM this build (headless net-only) — nothing this probe can drive
|
||||
return; // no UI VM (headless net-only)
|
||||
};
|
||||
let now = time.elapsed_secs_f64();
|
||||
let phase = probe.phase;
|
||||
|
||||
match phase {
|
||||
Phase::Wait => {
|
||||
// The refusal channel, installed up front so it is live long before the first verb.
|
||||
// Both events land on the same list because the probe only ever reads it as "what did
|
||||
// the client say while this leg ran".
|
||||
// The refusal channel, installed before the first verb.
|
||||
if let Err(e) = script.run(
|
||||
r#"
|
||||
if not ProbeCharterHooked then
|
||||
@@ -497,8 +365,8 @@ fn charter_probe(
|
||||
) {
|
||||
error!("PROBE_CHARTER: installing the refusal hook: {e}");
|
||||
}
|
||||
// Money first, before anything is targeted: `.modify money` with no selection targets
|
||||
// the sender, and with a *creature* selected it answers "no character selected".
|
||||
// Money first: with a creature selected, `.modify money` answers "no character
|
||||
// selected".
|
||||
let _ = net.0.send(ClientCommand::Chat {
|
||||
kind: ChatKind::Say,
|
||||
target: None,
|
||||
@@ -513,15 +381,9 @@ fn charter_probe(
|
||||
sent: false,
|
||||
};
|
||||
}
|
||||
// ── Step 0 — the preconditions ──────────────────────────────────────────────────────
|
||||
// A buy is refused **silently** while the buyer is in a guild (`PetitionsHandler.cpp:66`,
|
||||
// a bare `return`), so a run that founded one and never left it would make step 5 fail
|
||||
// for a reason with no trace anywhere. Leaving is therefore a precondition, not tidiness,
|
||||
// and it is what makes this probe re-runnable rather than a one-shot.
|
||||
//
|
||||
// A FAIL here means `GuildLeave()`/`GuildDisband()` did not take: the descriptor's
|
||||
// `PLAYER_GUILDID` never came back to 0, which is the same field `IsInGuild()` reads and
|
||||
// the same edge that fires `PLAYER_GUILD_UPDATE`. Every step below it would be meaningless.
|
||||
// ── Step 0: the preconditions ───────────────────────────────────────────────────────
|
||||
// A buy is refused silently while the buyer is in a guild (`PetitionsHandler.cpp:66`), so
|
||||
// any guild an earlier run founded is left first; FAIL if `PLAYER_GUILDID` never clears.
|
||||
Phase::Unguild { since, sent } => {
|
||||
let guild_id = store.0.player_guild_id();
|
||||
if !sent {
|
||||
@@ -535,13 +397,9 @@ fn charter_probe(
|
||||
);
|
||||
return hop(&mut probe, &net, now);
|
||||
}
|
||||
// Disband if we are the master (a guild master cannot simply leave — vmangos
|
||||
// answers that with `ERR_GUILD_LEADER_LEAVE_S`), else leave. The verb runs through
|
||||
// the live VM's own globals so the whole engine→drain→wire chain is exercised, but
|
||||
// the *decision* is read straight off the descriptor rather than out of
|
||||
// `IsGuildLeader()`: rank 0 IS the guild master, `PLAYER_GUILDRANK` is mirrored
|
||||
// into the model by a feed this probe is not ordered against, and on the first
|
||||
// frame in-world that one frame of lag would pick the wrong verb.
|
||||
// A guild master cannot leave (`ERR_GUILD_LEADER_LEAVE`), so rank 0 disbands.
|
||||
// The rank is read off the descriptor, not `IsGuildLeader()`, whose feed can lag
|
||||
// a frame behind on the first frame in-world.
|
||||
let verb = if store.0.player_guild_rank() == 0 {
|
||||
"GuildDisband()"
|
||||
} else {
|
||||
@@ -590,9 +448,8 @@ fn charter_probe(
|
||||
probe.phase = Phase::Done;
|
||||
}
|
||||
}
|
||||
// ── Step 1 — the hop ────────────────────────────────────────────────────────────────
|
||||
// A SKIP here is environmental and says so: the `.go` may have been refused, or Stormwind
|
||||
// may never have streamed. It is not evidence about the charter flow either way.
|
||||
// ── Step 1: the hop ─────────────────────────────────────────────────────────────────
|
||||
// A SKIP here is environmental: the `.go` was refused or Stormwind never streamed.
|
||||
Phase::Settling { sent_at } => {
|
||||
if now - sent_at < SETTLE_SECS {
|
||||
return;
|
||||
@@ -630,11 +487,9 @@ fn charter_probe(
|
||||
probe.phase = Phase::Done;
|
||||
}
|
||||
}
|
||||
// ── Step 2 — the menu ───────────────────────────────────────────────────────────────
|
||||
// A FAIL means the gossip wire is broken for this NPC. The wait is for the menu **in the
|
||||
// VM**, not for the packet: `ui_gossip`'s B292 hold deliberately keeps a first visit's
|
||||
// menu closed until `SMSG_NPC_TEXT_UPDATE` answers the greeting query, so a wait on the
|
||||
// parse alone would race the hold and read a menu the player cannot see.
|
||||
// ── Step 2: the menu ────────────────────────────────────────────────────────────────
|
||||
// Waits for the menu in the VM, not the packet: `ui_gossip` holds a first visit's menu
|
||||
// until `SMSG_NPC_TEXT_UPDATE` answers the greeting query.
|
||||
Phase::Menu { sent_at } => {
|
||||
let Some(npc) = probe.registrar else {
|
||||
probe.phase = Phase::Done;
|
||||
@@ -673,14 +528,9 @@ fn charter_probe(
|
||||
probe.phase = Phase::Done;
|
||||
}
|
||||
}
|
||||
// ── Step 4's second half — the window ───────────────────────────────────────────────
|
||||
// **The load-bearing step.** Before decision 1672 `SMSG_PETITION_SHOWLIST` had no const
|
||||
// and no parse arm: it fell through `parse.rs`'s tail into `ServerPacket::Other`, and
|
||||
// clicking "How do I form a guild?" closed the gossip window and did nothing else. A FAIL
|
||||
// here is exactly that shape, and it is the evidence for `ui_petition`'s INFERRED #1 —
|
||||
// that this packet is what fires `GUILD_REGISTRAR_SHOW`. The cost reading is the second
|
||||
// half: `GetGuildCharterCost` is modelled as the showlist row's `charterCost` in copper,
|
||||
// and 1000 is the only number vmangos can send.
|
||||
// ── Step 4's second half: the window ────────────────────────────────────────────────
|
||||
// `SMSG_PETITION_SHOWLIST` must fire `GUILD_REGISTRAR_SHOW`, and `GetGuildCharterCost()`
|
||||
// must read the showlist's `charterCost`, which vmangos always sends as 1000.
|
||||
Phase::Registrar { since } => {
|
||||
let Some(npc) = probe.registrar else {
|
||||
probe.phase = Phase::Done;
|
||||
@@ -690,17 +540,9 @@ fn charter_probe(
|
||||
let visible = vm_visible(&script, "GuildRegistrarFrame");
|
||||
let cost = vm_charter_cost(&script);
|
||||
if parked && visible && cost == CHARTER_COST_COPPER {
|
||||
// The portrait booth's `"npc"` token is reported, never asserted: it is what
|
||||
// paints the window's face and name banner, and a blank one is a look question,
|
||||
// which is the director's to call and not a probe's.
|
||||
//
|
||||
// **Sampled one frame late, on purpose.** `feed_interact_npc` runs in
|
||||
// `WorldStage::Net` deliberately unordered against the apply pass (its own doc says
|
||||
// so: a session is open for seconds, so one frame either way is invisible), which
|
||||
// means on the very frame the registrar opens `InteractNpc` is still `None` and
|
||||
// this token reads `""` every single time. A detail line that is always empty is an
|
||||
// instrument that lies — it reads exactly like the black-disc bug this arm was
|
||||
// added to fix.
|
||||
// The portrait's `"npc"` token is reported, not asserted, and sampled up to
|
||||
// `REGISTRAR_TOKEN_SETTLE_SECS` late: `feed_interact_npc` is unordered against
|
||||
// the apply pass, so it is empty on the frame the window opens.
|
||||
let token = script
|
||||
.eval::<String>("return UnitName(\"npc\") or \"\"")
|
||||
.unwrap_or_default();
|
||||
@@ -723,8 +565,7 @@ fn charter_probe(
|
||||
sent: false,
|
||||
};
|
||||
} else if parked && visible && cost != CHARTER_COST_COPPER {
|
||||
// The window opened, so the packet parsed — this is a value bug, not a wire one,
|
||||
// and saying which saves the next reader the bisect.
|
||||
// The window opened, so the packet parsed: a value bug, not a wire one.
|
||||
probe.fail(
|
||||
4,
|
||||
"registrar",
|
||||
@@ -754,21 +595,12 @@ fn charter_probe(
|
||||
probe.phase = Phase::Done;
|
||||
}
|
||||
}
|
||||
// ── Step 5 — the buy ────────────────────────────────────────────────────────────────
|
||||
// Driven through the registrar's own window: `GuildRegistrar_ShowPurchaseFrame()` is the
|
||||
// reference's pure local panel swap (and the only place the price is painted), then
|
||||
// `BuyGuildCharter(name)` is the Purchase button's own send. The real button also hides
|
||||
// the window afterwards (`GuildRegistrar_Purchase`); the probe deliberately does not, so
|
||||
// the registrar session stays observable — and it costs nothing, because step 6's
|
||||
// `ShowUIPanel(PetitionFrame)` displaces the left-slot incumbent anyway
|
||||
// (`SeatLeftAreaPanel`'s two-pushable-0 arm).
|
||||
//
|
||||
// **There is no confirmation packet at all for a successful buy** — the item arriving IS
|
||||
// the answer (`PetitionsHandler.cpp:130` pushes it and nothing else) — so this is a poll
|
||||
// with a timeout, and a FAIL means either the send never happened or the server refused.
|
||||
// The four silent refusals are: already in a guild, already owns a petition, the name is
|
||||
// taken, or not enough money; the name-taken one is the only one that says anything, and
|
||||
// it says it on the guild family's channel, which is why the detail dumps the lines.
|
||||
// ── Step 5: the buy ─────────────────────────────────────────────────────────────────
|
||||
// `GuildRegistrar_ShowPurchaseFrame()` then the Purchase button's `BuyGuildCharter(name)`.
|
||||
// The stock button also hides the window (`GuildRegistrarFrame.xml:269-270`); the probe
|
||||
// does not, and step 6's `PetitionFrame` displaces it (both left, pushable 0,
|
||||
// `UIParent.lua:33-34`). A successful buy has no reply, only the item
|
||||
// (`PetitionsHandler.cpp:130`); of the refusals only a taken name says anything.
|
||||
Phase::Buying { since, sent } => {
|
||||
if !sent {
|
||||
let name = probe_guild_name("Probe Charter");
|
||||
@@ -804,13 +636,8 @@ fn charter_probe(
|
||||
CHARTER_ITEM_ENTRY,
|
||||
ItemSearch::default(),
|
||||
);
|
||||
// The template has to have landed too, and it is folded into the SAME `Option` as the
|
||||
// item rather than short-circuiting on its own: the click dispatcher's charter arm is
|
||||
// a **template flag** test (`ITEM_FLAG_CHARTER`), so a click made before the answer
|
||||
// arrives falls through to a plain use and proves nothing — but an early `return` on a
|
||||
// template that never answers would sail straight past the timeout below and hang the
|
||||
// run, which is the one thing an instrument may never do. Reading the flags here also
|
||||
// cross-checks the DB fact (`flags = 8192`) against what the live server sends.
|
||||
// The template must have landed too: the click's charter arm tests its
|
||||
// `ITEM_FLAG_CHARTER`. Folded into one `Option` so a missing template still times out.
|
||||
let ready = found.and_then(|(bag_index, slot, guid)| {
|
||||
items
|
||||
.template(CHARTER_ITEM_ENTRY, guid, &net)
|
||||
@@ -870,8 +697,7 @@ fn charter_probe(
|
||||
probe_lines(&script)
|
||||
),
|
||||
);
|
||||
// Nothing usable arrived, but `find_item` may still have seen a charter — hand it
|
||||
// to the cleanup rather than to `Done`, or the next run's buy is refused silently.
|
||||
// A charter seen without a template still goes to the cleanup.
|
||||
probe.charter = found;
|
||||
probe.phase = if found.is_some() {
|
||||
Phase::Destroying {
|
||||
@@ -883,34 +709,18 @@ fn charter_probe(
|
||||
};
|
||||
}
|
||||
}
|
||||
// ── Step 6 — the item-use fork ──────────────────────────────────────────────────────
|
||||
// Driven as a bag right-click through the live VM's own `UseContainerItem`, so
|
||||
// `ui_items::drain::drain_container_uses` runs its real dispatcher and reaches
|
||||
// `ItemUseRoute::ShowPetition` — **not** a synthesized `CMSG_PETITION_SHOW_SIGNATURES`.
|
||||
// That distinction is the whole point of this step: the arm's position in the fork is
|
||||
// INFERRED (`ui_petition`'s #6), and the only thing that can exercise it is a real click.
|
||||
//
|
||||
// A FAIL means one of two things, and the detail says which it cannot separate: either
|
||||
// the fork never reached the charter arm (the charter fell through to `Nothing`, which is
|
||||
// exactly the pre-1672 behaviour — right-click a charter, nothing happens at all), or the
|
||||
// answer never became a window (INFERRED #2, `SMSG_PETITION_SHOW_SIGNATURES` →
|
||||
// `PETITION_SHOW`).
|
||||
//
|
||||
// **What the window's identity rests on is the control, not a field.** Every reading here
|
||||
// is the live VM's, and none of the charter getters exposes the open charter's ITEM guid,
|
||||
// so "the window is on the charter we just bought" is established by the BEFORE control
|
||||
// (no petition window existed before the click) plus the click itself, rather than by
|
||||
// matching guids. `PetitionState::open_item` would give the direct form in one term; it is
|
||||
// private to `ui_petition`, and widening it is that module's call to make, not this
|
||||
// probe's.
|
||||
// ── Step 6: the item-use fork ───────────────────────────────────────────────────────
|
||||
// A real bag right-click through `UseContainerItem`, so `drain_container_uses` reaches
|
||||
// `ItemUseRoute::ShowPetition`. A FAIL is either the fork missing the charter arm or
|
||||
// `SMSG_PETITION_SHOW_SIGNATURES` never becoming `PETITION_SHOW`. No getter exposes the
|
||||
// open charter's item guid, so its identity rests on no window being up before the click.
|
||||
Phase::Opening { since, sent } => {
|
||||
let Some((bag_index, slot, guid)) = probe.charter else {
|
||||
probe.phase = Phase::Done;
|
||||
return;
|
||||
};
|
||||
if !sent {
|
||||
// The control, the clam probe's first reading: a window that was already up would
|
||||
// make everything below it meaningless.
|
||||
// The control: a window already up would make the step meaningless.
|
||||
if vm_visible(&script, "PetitionFrame") {
|
||||
probe.fail(
|
||||
6,
|
||||
@@ -1004,24 +814,16 @@ fn charter_probe(
|
||||
};
|
||||
}
|
||||
}
|
||||
// ── Step 7 — the lazy record fill ───────────────────────────────────────────────────
|
||||
// The window opens with an EMPTY title: `SMSG_PETITION_SHOW_SIGNATURES` carries an item
|
||||
// guid, an owner guid, a petition id and signer guids, and no text at all. The name
|
||||
// arrives a round trip later on `SMSG_PETITION_QUERY_RESPONSE`, keyed by petition id, and
|
||||
// the feed re-fires `PETITION_SHOW` so the window repaints in place.
|
||||
//
|
||||
// A PASS here is the whole two-caches design observed working end to end. A FAIL means the
|
||||
// ask-once record query never went out, its answer never landed, or the repaint edge never
|
||||
// fired — in every one of which the window sits titleless forever, which is what a player
|
||||
// would report as "the charter has no name".
|
||||
// ── Step 7: the lazy record fill ────────────────────────────────────────────────────
|
||||
// `SMSG_PETITION_SHOW_SIGNATURES` carries guids and a petition id but no text; the name
|
||||
// arrives on `SMSG_PETITION_QUERY_RESPONSE`, keyed by petition id, and the feed re-fires
|
||||
// `PETITION_SHOW`. A FAIL leaves the window titleless.
|
||||
Phase::Record { since } => {
|
||||
let title = vm_petition_str(&script, 2);
|
||||
let max = vm_petition_max(&script);
|
||||
if title == probe.bought && max == REQUIRED_SIGNATURES {
|
||||
// The first half of the claim, checked against what step 6 latched: the title at
|
||||
// open was either empty (the packet carries none) or already the bought name (the
|
||||
// record beat the paint). Anything else is a title from somewhere it cannot have
|
||||
// come from.
|
||||
// At open the title was empty or already the bought name (the record beat the
|
||||
// paint); anything else came from another charter's record.
|
||||
let at_open = probe.title_at_open.clone();
|
||||
if at_open.is_empty() || at_open == probe.bought {
|
||||
probe.pass(
|
||||
@@ -1067,14 +869,10 @@ fn charter_probe(
|
||||
};
|
||||
}
|
||||
}
|
||||
// ── Step 8 — the rename echo ────────────────────────────────────────────────────────
|
||||
// `RenamePetition(name)` in the live VM — the `RENAME_GUILD` popup's own Accept. vmangos
|
||||
// sends `MSG_PETITION_RENAME` back **only on success** (`PetitionsHandler.cpp:209-215`,
|
||||
// inside `if (petition->Rename(...))`), so the title changing IS the echo arriving; a
|
||||
// refusal is silent apart from a guild-command result on the other channel.
|
||||
//
|
||||
// A FAIL means the echo never arrived or never patched the cached record — the window
|
||||
// would keep showing the old name indefinitely, since nothing re-queries.
|
||||
// ── Step 8: the rename echo ─────────────────────────────────────────────────────────
|
||||
// `RenamePetition(name)`, the `RENAME_GUILD` popup's Accept. vmangos echoes
|
||||
// `MSG_PETITION_RENAME` only on success (`PetitionsHandler.cpp:209-215`), and nothing
|
||||
// re-queries, so the echo must patch the cached record.
|
||||
Phase::Renaming { since, sent } => {
|
||||
if !sent {
|
||||
let name = probe_guild_name("Probe Renamed");
|
||||
@@ -1099,12 +897,8 @@ fn charter_probe(
|
||||
return;
|
||||
}
|
||||
let title = vm_petition_str(&script, 2);
|
||||
// The bag tooltip has to reach the SAME name, not merely be non-empty. It is fed by a
|
||||
// different path — the container snapshot, whose rebuild is gated (decision 1439) —
|
||||
// so the two can disagree, and requiring convergence is what proves the rename's
|
||||
// record patch reaches the gate rather than only the window. Held inside the step's
|
||||
// own timeout: a tooltip that never catches up FAILs here rather than being reported
|
||||
// as a curiosity.
|
||||
// The bag tooltip must reach the same name: it is fed by the container snapshot,
|
||||
// whose rebuild is gated, so it can lag the window.
|
||||
let tip = charter_tooltip_lines(
|
||||
&script,
|
||||
probe.charter.and_then(|(b, sl, _)| lua_bag_pos(b, sl)),
|
||||
@@ -1112,12 +906,8 @@ fn charter_probe(
|
||||
let tip_caught_up = tip.iter().any(|l| l.contains(&probe.renamed));
|
||||
if title == probe.renamed && tip_caught_up {
|
||||
let bought = probe.bought.clone();
|
||||
// The item TOOLTIP's line 3 while we are here — the charter's guild name and
|
||||
// master, which the director reported missing. Reported, not asserted: the plate's
|
||||
// wording and placement are pinned by the unit test
|
||||
// (`charter_lines_sit_between_the_name_and_the_signable_line`); what only a live
|
||||
// run can show is that the petition record actually reaches the bag slot's view,
|
||||
// which is a different question from whether the renderer would print it.
|
||||
// The tooltip lines are reported; their wording is pinned by
|
||||
// `charter_lines_sit_between_the_name_and_the_signable_line`.
|
||||
probe.pass(
|
||||
8,
|
||||
"rename",
|
||||
@@ -1150,30 +940,18 @@ fn charter_probe(
|
||||
};
|
||||
}
|
||||
}
|
||||
// ── Step 9 — the cleanup that makes the probe re-runnable ───────────────────────────
|
||||
// `CMSG_DESTROYITEM` on the charter's own wire slot. vmangos cascades that into deleting
|
||||
// the petition (`Player.cpp:10811-10817`: `if (pItem->IsCharter())` → `DeletePetition`),
|
||||
// which is the only reason destroying the item is enough.
|
||||
//
|
||||
// **Without this, the NEXT run's buy is refused silently** — "Cannot buy a petition if the
|
||||
// owner already has one", a bare `return` at `PetitionsHandler.cpp:70` — and step 5 would
|
||||
// fail for a reason that looks nothing like its cause: no packet, no error line, just an
|
||||
// item that never arrives. That is exactly the failure this step exists to prevent, and it
|
||||
// is why every exit from steps 5-8 routes through here rather than to `Done`.
|
||||
//
|
||||
// The send is watched to land, the clam probe's lesson: `AppExit` tears the net thread
|
||||
// down within a frame or two, so a fire-and-forget destroy written on the way out never
|
||||
// reaches the wire.
|
||||
// ── Step 9: the cleanup ─────────────────────────────────────────────────────────────
|
||||
// `CMSG_DESTROYITEM` deletes the petition with the charter (`Player.cpp:10811-10817`);
|
||||
// without it the next run's buy is refused silently (`PetitionsHandler.cpp:70`), so every
|
||||
// exit from steps 5-8 comes here. The destroy is watched to land before `AppExit` tears
|
||||
// the net thread down.
|
||||
Phase::Destroying { since, sent } => {
|
||||
let Some((bag_index, slot, guid)) = probe.charter else {
|
||||
probe.phase = Phase::Done; // nothing was bought — nothing to clean up
|
||||
probe.phase = Phase::Done; // nothing was bought
|
||||
return;
|
||||
};
|
||||
if !sent {
|
||||
// The position is read FRESH rather than reused from step 5's latch. `count: 0`
|
||||
// destroys whatever whole stack sits at the addressed position, so a stale pair
|
||||
// would destroy the wrong item; nothing is expected to move a charter, and that is
|
||||
// exactly the kind of expectation a destructive send must not rest on.
|
||||
// The position is read fresh: `count: 0` destroys whatever stack sits there.
|
||||
let Some((bag_index, slot, _)) = find_item(
|
||||
&store.0,
|
||||
&objects,
|
||||
@@ -1246,9 +1024,7 @@ fn charter_probe(
|
||||
"PROBE_CHARTER: DONE pass={} fail={}",
|
||||
probe.passes, probe.fails
|
||||
);
|
||||
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
|
||||
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
|
||||
// client holding the probe account.
|
||||
// `AppExit` plus a hard-exit thread, so a teardown hang cannot hold the account.
|
||||
exit.write(AppExit::Success);
|
||||
std::thread::spawn(|| {
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
@@ -1259,7 +1035,7 @@ fn charter_probe(
|
||||
}
|
||||
}
|
||||
|
||||
/// Step 1's send, shared by both of step 0's exits so the hop is spelled once.
|
||||
/// Step 1's send, from both of step 0's exits.
|
||||
fn hop(probe: &mut CharterProbe, net: &NetCommands, now: f64) {
|
||||
let [x, y, z] = REGISTRAR_AT;
|
||||
info!(
|
||||
@@ -1274,19 +1050,8 @@ fn hop(probe: &mut CharterProbe, net: &NetCommands, now: f64) {
|
||||
probe.phase = Phase::Settling { sent_at: now };
|
||||
}
|
||||
|
||||
/// Steps 3 and 4's first half — the icon, then the click.
|
||||
///
|
||||
/// Split out of the phase match because it is one straight line of asserts with several exits, and
|
||||
/// inlining it buried the phase machine's shape ([`super::probe_binder`]'s own split). Sets
|
||||
/// `probe.phase` on every path.
|
||||
///
|
||||
/// **Step 3's FAIL is the icon-table regression, one row over from B249.** Byte 7 is `"petition"`
|
||||
/// in the client's own `0x84b7ac` table (decision 1335); a `"gossip"` here means the row draws the
|
||||
/// chat bubble, which is what the pre-1331 hand-written map did to the innkeeper's byte 5.
|
||||
///
|
||||
/// **Step 4's send is by WIRE INDEX**, read off the packet, never derived from where the row sits
|
||||
/// in the list — the drain's own rule, and the reason a menu that grows a row cannot silently make
|
||||
/// this probe click the tabard designer.
|
||||
/// Step 3, the icon (byte 7 is `"petition"` in the reference's `0x84b7ac` table), then step 4's
|
||||
/// select by the row's wire index, never its list position. Sets `probe.phase` on every path.
|
||||
fn assert_icon_and_select(
|
||||
probe: &mut CharterProbe,
|
||||
gossip: &GossipState,
|
||||
@@ -1342,9 +1107,7 @@ fn assert_icon_and_select(
|
||||
probe.phase = Phase::Done;
|
||||
return;
|
||||
}
|
||||
// The type string being right is not the row drawing right: the XML looks the type up in
|
||||
// `BENILLA_GOSSIP_ICONS`, and a missing key falls back to the chat bubble with no error
|
||||
// anywhere. Both halves, exactly as the binder probe asserts them.
|
||||
// The stock interface defines no `BENILLA_GOSSIP_ICONS` (see `vm_petition_texture`).
|
||||
let texture = vm_petition_texture(script);
|
||||
if texture.is_empty() {
|
||||
probe.fail(
|
||||
@@ -1371,8 +1134,7 @@ fn assert_icon_and_select(
|
||||
),
|
||||
);
|
||||
|
||||
// Step 4's click. Guarded on the label — a lowercase substring, never an equality, because the
|
||||
// wire's label is the row's broadcast text rather than its `option_text` column.
|
||||
// Step 4's click, guarded on the label substring.
|
||||
if !opt.message.to_lowercase().contains(CHARTER_LABEL_HINT) {
|
||||
probe.skip(
|
||||
4,
|
||||
|
||||
@@ -1,51 +1,21 @@
|
||||
//! The chest live probe (`WOW_PROBE_CHEST=1`) — **B84's instrument**: does the player kneel at an
|
||||
//! open chest?
|
||||
//! The chest live probe (`WOW_PROBE_CHEST=1`): does the player kneel at an open chest? The
|
||||
//! reference arms the loot latch in `OnLootResponse` (`0x5eb900`) as well as on the `CMSG_LOOT`
|
||||
//! send, and a chest never sends `CMSG_LOOT`.
|
||||
//!
|
||||
//! The symptom: no looting animation when opening a chest. At the same Mirror Lake Orchard chest,
|
||||
//! benilla and the reference side by side show the reference
|
||||
//! gnome kneeling over the open loot window, ours standing bolt upright. Decision 0515 had shipped
|
||||
//! the kneel keyed to the `CMSG_LOOT` send alone and recorded a chest's silence as *correct*; 1471
|
||||
//! is the correction (the real client also arms the latch in `OnLootResponse 0x5eb900`, and a chest
|
||||
//! never sends `CMSG_LOOT` at all).
|
||||
//! It reads the self unit's
|
||||
//! [`AnimDriver::active_anim`][crate::creature_anim::AnimDriver::active_anim] in three windows:
|
||||
//!
|
||||
//! **A kneel is a number here, not a picture** (`docs/METHOD.md` step 4: timing and pose questions are
|
||||
//! settled with instruments). The probe reads the self unit's own
|
||||
//! [`AnimDriver::active_anim`][crate::creature_anim::AnimDriver::active_anim] — the id the base
|
||||
//! selector actually landed on — so the verdict is `50` (`Loot`) or it is not, with no screenshot
|
||||
//! and no eye in the loop. Three readings, in order, which is what makes it a *regression* test and
|
||||
//! not a one-sided assertion:
|
||||
//! 1. before, window shut: `0` (`Stand`), the control;
|
||||
//! 2. open, the chest used through the click's own route: `50` (`Loot`);
|
||||
//! 3. after `CloseLoot()`: back to `0`.
|
||||
//!
|
||||
//! 1. **BEFORE** — parked at the chest, window shut: the base anim must be `0` (`Stand`). This is
|
||||
//! the control; a probe that only ever sees `50` cannot tell a fix from a stuck kneel.
|
||||
//! 2. **OPEN** — the chest used on the click's own route, the loot window up: must be `50`.
|
||||
//! 3. **AFTER** — `CloseLoot()` through the live VM: back to `0`.
|
||||
//! The use goes through [`crate::target::click::resolve_go_action`], the mouse's lock chain, and
|
||||
//! sends whichever arm it names; world chests carry a `Skill 0` lock the "Opening" spell meets, so
|
||||
//! the live arm is `OpenLock`. The DONE line reports the latch (a loot session is open) and the
|
||||
//! kneel (this kind of target is knelt at) apart: a fishing bobber latches but must not kneel.
|
||||
//!
|
||||
//! Nothing here re-decides what packet a chest takes. The probe resolves the object through
|
||||
//! [`crate::target::click::resolve_go_action`] — the same lock chain the mouse runs (0239/0752) —
|
||||
//! and sends whichever arm it names, so a chest whose `Lock.dbc` row changes shape still gets the
|
||||
//! packet the real click would send. (Every world chest carries a lock: `Lock.dbc` 43 is one
|
||||
//! `LockType 13 "Open Kneeling"` slot, 57 is `5 "Open"` + `6 "Treasure (DND)"`, all `Skill 0`, so
|
||||
//! the "Opening" spell every character knows satisfies them and the live arm is `OpenLock`.)
|
||||
//!
|
||||
//! The DONE line reports the two mechanism halves separately — `latch-while-open` (predicate A: a
|
||||
//! loot session is open) and `kneel-while-open` (predicate B: this *kind* of target is knelt at,
|
||||
//! decision 1477). They are not the same question, and the whole of B84's correction lives in the
|
||||
//! gap: a fishing bobber arms the latch exactly as a chest does and must still read `kneel=false`.
|
||||
//!
|
||||
//! ## The run recipe
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_NOSOUND=1 WOW_USER=probe0 WOW_PASS=pprobe0 WOW_CHAR=Probezero \
|
||||
//! WOW_PROBE_CHEST=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
|
||||
//! skill). `WOW_PROBE_CHEST=<x>,<y>,<z>[,<map>]` aims it at a different
|
||||
//! object; the default is a live `Battered Chest` spawn (`gameobject.guid` 26756, template 2843) in
|
||||
//! the Mirror Lake stretch of Elwynn, the same corner of the world the report came from. A chest is
|
||||
//! a respawning spawn point: if someone emptied that one it will be missing for its respawn timer,
|
||||
//! which the probe reports as a scan failure rather than as a passing run.
|
||||
//!
|
||||
//! Grep `PROBE_CHEST:` for the verdict; the probe self-exits when it lands.
|
||||
//! `WOW_PROBE_CHEST=<x>,<y>,<z>[,<map>]` aims it elsewhere; grep `PROBE_CHEST:` for the verdict.
|
||||
//! The switches are `docs/CONTRIBUTING.md`, "Running it unattended".
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -60,36 +30,26 @@ use crate::target::click::{resolve_go_action, GoAction};
|
||||
use crate::target::lock::GoLockInputs;
|
||||
use crate::ui_loot::{LootKneel, LootLatch, LootState};
|
||||
|
||||
/// The probe's default object: the `Worn Wooden Chest` at `gameobject` 3998644 (template 1765,
|
||||
/// `Lock.dbc` 43 → `LockType 13 "Open Kneeling"`, loot 1570) below Northshire in Elwynn.
|
||||
///
|
||||
/// **Not** the reporter's own Mirror Lake `Battered Chest`, deliberately, and the reason is the
|
||||
/// trap this probe fell into first: every ordinary world chest — that one included — is a
|
||||
/// **spawn-pool** member (`pool_gameobject`, one of pool 20004's many points spawned at a time), so
|
||||
/// a fixed coordinate finds nothing most of the time and the probe reads "no chest" where the real
|
||||
/// answer is "not this point today". 3998644 is unpooled with a 25 s respawn, so it is there every
|
||||
/// run and it comes back between runs. It is the same GameObject type, the same lock family and the
|
||||
/// same `SMSG_LOOT_RESPONSE`-only session as the reported object.
|
||||
/// The default object: the `Worn Wooden Chest` at `gameobject` 3998644 (template 1765,
|
||||
/// `Lock.dbc` 43, `LockType 13 "Open Kneeling"`) in Elwynn. Ordinary world chests are spawn-pool
|
||||
/// members, absent from a fixed point most of the time; this one is unpooled with a 25 s respawn.
|
||||
const CHEST_AT: [f32; 4] = [-9474.37, 111.25, 57.03, 0.0];
|
||||
/// `GAMEOBJECT_TYPE_CHEST` — the strategy type a chest (and a herb/mining node) carries.
|
||||
/// `GAMEOBJECT_TYPE_CHEST`, also a herb or mining node.
|
||||
const GO_TYPE_CHEST: i32 = 3;
|
||||
/// `AnimationData.dbc` ids the verdict is written in: the kneel we want, and the Stand we must
|
||||
/// return to. Named rather than inlined so the report reads as animations, not magic numbers.
|
||||
/// `AnimationData.dbc` ids: the kneel, and the stand to return to.
|
||||
const ANIM_LOOT: u16 = 50;
|
||||
const ANIM_STAND: u16 = 0;
|
||||
|
||||
/// Scan radius around the landing spot, in yards.
|
||||
const SCAN_RANGE: f32 = 25.0;
|
||||
/// Frames each window samples. The base anim is a settled per-frame state, so this is about
|
||||
/// outlasting a transient, not about statistics.
|
||||
/// Frames each window samples, enough to outlast a transient.
|
||||
const SAMPLE_FRAMES: usize = 60;
|
||||
const SETTLE_SECS: f64 = 5.0;
|
||||
const SCAN_TIMEOUT_SECS: f64 = 25.0;
|
||||
const LOOT_TIMEOUT_SECS: f64 = 15.0;
|
||||
const CLOSE_TIMEOUT_SECS: f64 = 10.0;
|
||||
|
||||
/// The streamed-object columns the scan and the lock resolve both read — a `type` alias so the
|
||||
/// one `Query` stays inside clippy's complexity bar.
|
||||
/// The streamed-object columns the scan and the lock resolve read.
|
||||
type NearbyObject = (
|
||||
&'static Guid,
|
||||
&'static NetEntity,
|
||||
@@ -110,16 +70,13 @@ impl Plugin for ProbeChestPlugin {
|
||||
#[derive(Resource, Default)]
|
||||
struct ChestProbe {
|
||||
phase: Phase,
|
||||
/// The chest's guid, once the scan finds it.
|
||||
chest: Option<u64>,
|
||||
/// The base anim ids seen in each window, in frame order.
|
||||
before: Vec<Option<u16>>,
|
||||
open: Vec<Option<u16>>,
|
||||
after: Vec<Option<u16>>,
|
||||
/// The two mechanism readings taken when the OPEN window finished — reported beside the pose
|
||||
/// so a failure says *which* half broke. They are deliberately separate: predicate A (a loot
|
||||
/// session is open — the latch) and predicate B (this KIND of target is knelt at — decision
|
||||
/// 1477). A fishing bobber is `latch=Some(...) kneel=false`, and that is correct.
|
||||
/// The latch (a loot session is open) and the kneel (this kind of target is knelt at), read
|
||||
/// when the open window ends; a fishing bobber is `latch=Some(..) kneel=false`.
|
||||
latch_open: Option<u64>,
|
||||
kneel_open: bool,
|
||||
fails: u32,
|
||||
@@ -134,25 +91,24 @@ enum Phase {
|
||||
Settling {
|
||||
sent_at: f64,
|
||||
},
|
||||
/// Sampling with the chest SHUT — the control window.
|
||||
/// Sampling with the chest shut, the control window.
|
||||
Before,
|
||||
/// The use packet is out; waiting for the server to open a loot window.
|
||||
WaitLoot {
|
||||
since: f64,
|
||||
},
|
||||
/// Sampling with the loot window OPEN — the reported condition.
|
||||
/// Sampling with the loot window open.
|
||||
Open,
|
||||
/// `CloseLoot()` called; waiting for the window to actually go.
|
||||
/// `CloseLoot()` called; waiting for the window to go.
|
||||
WaitClosed {
|
||||
since: f64,
|
||||
},
|
||||
/// Sampling after the close — the "and it stands back up" half.
|
||||
/// Sampling after the close.
|
||||
After,
|
||||
Done,
|
||||
}
|
||||
|
||||
/// Where the probe is aimed: `WOW_PROBE_CHEST=<x>,<y>,<z>[,<map>]`, else [`CHEST_AT`]. Anything
|
||||
/// unparseable falls back to the default rather than failing the run — the common value is `1`.
|
||||
/// `WOW_PROBE_CHEST=<x>,<y>,<z>[,<map>]`, else [`CHEST_AT`] (the common value is `1`).
|
||||
fn target() -> [f32; 4] {
|
||||
let Ok(raw) = std::env::var("WOW_PROBE_CHEST") else {
|
||||
return CHEST_AT;
|
||||
@@ -168,20 +124,17 @@ fn target() -> [f32; 4] {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the loot window is up, asked of the **live UI** rather than of our own state: the
|
||||
/// window is what the reporter saw, and `LootFrame` shows only once the feed has fired
|
||||
/// `LOOT_OPENED` off the wire. Falls back to the app-side session when the frame can't be read.
|
||||
/// Whether `LootFrame` is shown (after `LOOT_OPENED`), else the app-side loot session.
|
||||
fn window_open(script: &UiScript, loot: &LootState) -> bool {
|
||||
script
|
||||
.eval::<bool>("return LootFrame:IsShown() and 1 or nil")
|
||||
.unwrap_or_else(|_| loot.source().is_some())
|
||||
}
|
||||
|
||||
/// One window's verdict line: what the base slot held, how steadily, and whether it is the id the
|
||||
/// reference shows. `expect` is the id every frame of the window must carry.
|
||||
/// One window's verdict line; `expect` is the id every frame of the window must carry.
|
||||
fn report(label: &str, expect: u16, seen: &[Option<u16>]) -> u32 {
|
||||
let held = seen.iter().filter(|a| **a == Some(expect)).count();
|
||||
// What it held instead, most-common first — the useful half of a failure.
|
||||
// What it held instead, in first-seen order.
|
||||
let mut other: Vec<String> = Vec::new();
|
||||
for a in seen.iter().filter(|a| **a != Some(expect)) {
|
||||
let name = a.map_or_else(|| "none".to_string(), |id| id.to_string());
|
||||
@@ -231,7 +184,7 @@ fn chest_probe(
|
||||
return; // not in-world yet
|
||||
};
|
||||
let Some(script) = script else {
|
||||
return; // no UI VM this build — nothing this probe can drive
|
||||
return; // no UI VM in this build
|
||||
};
|
||||
let now = time.elapsed_secs_f64();
|
||||
let anim = driver.active_anim();
|
||||
@@ -286,8 +239,7 @@ fn chest_probe(
|
||||
probe.phase = Phase::Done;
|
||||
return;
|
||||
};
|
||||
// The click's own route (0239/0752): ask the shared lock chain what the right-click
|
||||
// would send, then send exactly that.
|
||||
// Send exactly what the right-click's lock chain would send.
|
||||
let go = objects
|
||||
.iter()
|
||||
.find(|(g, ..)| g.0 == guid)
|
||||
@@ -376,9 +328,7 @@ fn chest_probe(
|
||||
probe.latch_open.map(|g| format!("{g:#x}")),
|
||||
probe.kneel_open,
|
||||
);
|
||||
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
|
||||
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
|
||||
// client holding the probe account.
|
||||
// `AppExit` plus a hard backstop, so a teardown hang cannot keep the account held.
|
||||
exit.write(AppExit::Success);
|
||||
std::thread::spawn(|| {
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
|
||||
@@ -1,50 +1,22 @@
|
||||
//! The openable-item live probe (`WOW_PROBE_CLAM=1`) — **the clam instrument**: right-click a
|
||||
//! LOOTABLE item in the bags and does a loot window actually open?
|
||||
//! The openable-item live probe (`WOW_PROBE_CLAM=1`): right-clicks a lootable item in the bags
|
||||
//! and checks a loot window opens. The click makes two writes before the send (`0x5edc80`): the
|
||||
//! grey lock ([`PendingItemOps`], `0x4953e0` at `0x5edcd9`) and the loot latch ([`LootLatch`],
|
||||
//! `0x5edcc0`). vmangos answers `CMSG_OPEN_ITEM` with `SendLoot(item guid, LOOT_CORPSE)`, so
|
||||
//! `SMSG_LOOT_RESPONSE` comes back type 1 on the item's own guid, which a cold latch refuses.
|
||||
//!
|
||||
//! The director, 2026-08-22: *"Clams don't open anymore. I right click them and they turn gray
|
||||
//! correctly but no loot window opens ever."* Both halves of that sentence are readings this probe
|
||||
//! takes separately, because they come from two different pre-send writes in the same emitter
|
||||
//! (`0x5edc80`):
|
||||
//! The click runs through the live VM's `UseContainerItem(0, slot)`, the bag button's own call;
|
||||
//! each reading is `LootFrame:IsShown()` plus the session guid:
|
||||
//!
|
||||
//! - the **grey lock** ([`PendingItemOps`], `0x4953e0` at `0x5edcd9`) — the half that kept working;
|
||||
//! - the **loot latch** ([`LootLatch`], `0x5edcc0`) — the half decision 1477 left unmodelled, on
|
||||
//! the reading that an item arm changes no pose. It changes no pose and it is still load-bearing:
|
||||
//! vmangos answers `CMSG_OPEN_ITEM` with `SendLoot(item guid, LOOT_CORPSE)`, so
|
||||
//! `SMSG_LOOT_RESPONSE` comes back **type 1** on the item's own guid, and 1477's admission gate
|
||||
//! *refuses* a type-1 answer against a cold latch — bounces a `CMSG_LOOT_RELEASE` and opens
|
||||
//! nothing. Grey clam, no window, forever. Decision 1531.
|
||||
//! 1. BEFORE: the item unclicked, no window (the control).
|
||||
//! 2. OPEN: after the click, a window on the item's own guid, its slot grey.
|
||||
//! 3. CLOSED: `CloseLoot()` takes the window down and clears the latch.
|
||||
//! 4. REOPEN: the same item clicked again opens a second time.
|
||||
//!
|
||||
//! **The window is a number here, not a picture.** The probe drives the click through the live UI
|
||||
//! VM's own `UseContainerItem(0, slot)` — the same binding the bag button calls, so the whole
|
||||
//! dispatcher (`ui_items::drain::drain_container_uses`) runs for real — and then reads
|
||||
//! `LootFrame:IsShown()` plus the app-side session guid. Four readings, in order, which is what
|
||||
//! makes it a regression test rather than a one-sided assertion:
|
||||
//!
|
||||
//! 1. **BEFORE** — the clam sitting in the bag, unclicked: no loot window. The control; a probe
|
||||
//! that only ever sees a window cannot tell a fix from a window that was already up.
|
||||
//! 2. **OPEN** — after the click: a window, whose session guid is the **clam's own item guid**
|
||||
//! (not a corpse's, not zero), with the grey lock on its slot.
|
||||
//! 3. **CLOSED** — `CloseLoot()` through the live VM: the window goes and the latch clears.
|
||||
//! 4. **REOPEN** — the same clam clicked again: it opens a second time. The adjacent state that
|
||||
//! catches a latch which arms once and never re-arms (the classic "works the first time" shape).
|
||||
//!
|
||||
//! ## The run recipe
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_NOSOUND=1 WOW_USER=probe0 WOW_PASS=pprobe0 WOW_CHAR=Probezero \
|
||||
//! WOW_PROBE_CLAM=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
|
||||
//! skill). `WOW_PROBE_CLAM=<entry>` aims it at a different openable template;
|
||||
//! the default is a Small Barnacled Clam (7973 — `Flags` LOOTABLE with `LockID = 0`, so it is
|
||||
//! openable the moment it exists: no key, no lockpicking). The probe `.additem`s its own copy and
|
||||
//! subtracts it again on the way out, so it leaves the character as it found it.
|
||||
//!
|
||||
//! Auto-loot is forced **off** for the run: with it on, the clam's single row is taken at the open
|
||||
//! edge and the last-row auto-release closes the window within a frame or two, which would read as
|
||||
//! "no window" for the wrong reason.
|
||||
//!
|
||||
//! Grep `PROBE_CLAM:` for the verdict; the probe self-exits when it lands.
|
||||
//! `WOW_PROBE_CLAM=<entry>` aims it at another openable template; the default is a Small
|
||||
//! Barnacled Clam (7973, lootable with no lock). The probe `.additem`s its own copy and removes it
|
||||
//! on the way out. Auto-loot is forced off: it would close the window within a frame or two. It
|
||||
//! logs `PROBE_CLAM:` lines and exits; the switches are `docs/CONTRIBUTING.md`, "Running it
|
||||
//! unattended".
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -56,9 +28,8 @@ use crate::net::{ChatKind, ClientCommand, NetCommands, ObjectStore, Objects, Sel
|
||||
use crate::pending_item_ops::PendingItemOps;
|
||||
use crate::ui_loot::{LootConfig, LootLatch, LootState};
|
||||
|
||||
/// The probe's default template: "Small Barnacled Clam" (entry 7973) — the director's own case, and
|
||||
/// the `benilla-world --open-item` wire probe's. Its `item_loot_template` row (a Zesty Clam Meat)
|
||||
/// is what the window must show.
|
||||
/// The default template, Small Barnacled Clam (entry 7973); its `item_loot_template` row (a Zesty
|
||||
/// Clam Meat) is what the window must show.
|
||||
const CLAM_ENTRY: u32 = 7973;
|
||||
|
||||
/// How long to wait for `.additem` to land the item and its template in the bags.
|
||||
@@ -69,18 +40,12 @@ const OPEN_TIMEOUT_SECS: f64 = 10.0;
|
||||
const CLOSE_TIMEOUT_SECS: f64 = 10.0;
|
||||
/// How long the `.additem -1` gets to take the probe's own copy back out of the bags.
|
||||
const CLEANUP_TIMEOUT_SECS: f64 = 8.0;
|
||||
/// How long the REOPEN round waits after the window goes down before clicking again.
|
||||
///
|
||||
/// **Not padding — a real race the reference shares.** `CloseLoot()` sends `CMSG_LOOT_RELEASE` and
|
||||
/// clears the latch locally at the *send* (`0x48f2c9` in `CloseInteraction 0x48f200`); the server's
|
||||
/// `SMSG_LOOT_RELEASE_RESPONSE` clears it again, **guid-matched** (`0x5ec0d4`). Re-open the *same*
|
||||
/// object inside that round trip and the response's clear lands on the latch the re-open just
|
||||
/// armed, so its type-1 answer is refused — guid-matching cannot separate them when both guids are
|
||||
/// the same object. Both clear sites are the reference's own, so this is faithful rather than ours
|
||||
/// to fix; a hand re-click never gets near the ~40 ms window, and a probe clicking on the next
|
||||
/// frame does, every few runs. One second is a human's reaction time, generously.
|
||||
/// How long the REOPEN round waits after the window goes down before clicking again. A race the
|
||||
/// reference shares: `CloseLoot()` clears the latch at the send (`0x48f2c9` in `CloseInteraction`,
|
||||
/// `0x48f200`) and `SMSG_LOOT_RELEASE_RESPONSE` clears it again, guid-matched (`0x5ec0d4`), so a
|
||||
/// re-open of the same object inside that round trip is refused.
|
||||
const REOPEN_SETTLE_SECS: f64 = 1.0;
|
||||
/// Frames the BEFORE control samples — long enough to outlast a transient, short enough to be free.
|
||||
/// Frames the BEFORE control samples.
|
||||
const CONTROL_FRAMES: usize = 60;
|
||||
|
||||
pub(crate) struct ProbeClamPlugin;
|
||||
@@ -92,8 +57,7 @@ impl Plugin for ProbeClamPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which template the probe opens: `WOW_PROBE_CLAM=<entry>`, else [`CLAM_ENTRY`]. Anything
|
||||
/// unparseable falls back to the default rather than failing the run — the common value is `1`.
|
||||
/// Which template the probe opens: `WOW_PROBE_CLAM=<entry>`, else [`CLAM_ENTRY`] (also for `1`).
|
||||
fn target_entry() -> u32 {
|
||||
std::env::var("WOW_PROBE_CLAM")
|
||||
.ok()
|
||||
@@ -108,11 +72,9 @@ struct ClamProbe {
|
||||
/// The clam's item guid and its 1-based Lua backpack slot, once the stock scan finds it.
|
||||
clam: Option<u64>,
|
||||
slot: u32,
|
||||
/// Frames sampled with the clam untouched — every one must show no window.
|
||||
/// Frames sampled with the clam untouched; every one must show no window.
|
||||
control: Vec<bool>,
|
||||
/// What each open round read: the session guid the window came up on, and whether the clicked
|
||||
/// slot was grey at the same moment. Reported separately because the director's report split
|
||||
/// exactly there — grey yes, window no.
|
||||
/// What each open round read: the window's session guid, and whether the clicked slot was grey.
|
||||
opened: Vec<Option<u64>>,
|
||||
greyed: Vec<bool>,
|
||||
/// Whether the latch was clear after each `CloseLoot()`.
|
||||
@@ -129,11 +91,11 @@ const ROUNDS: u8 = 2;
|
||||
enum Phase {
|
||||
#[default]
|
||||
Wait,
|
||||
/// `.additem` issued; waiting for the item AND its template to reach the bags.
|
||||
/// `.additem` issued; waiting for the item and its template to reach the bags.
|
||||
Stocking {
|
||||
sent_at: f64,
|
||||
},
|
||||
/// Sampling with the clam untouched — the control window.
|
||||
/// Sampling with the clam untouched, the control.
|
||||
Before,
|
||||
/// `UseContainerItem` queued; waiting for a loot window. `round` counts from 0.
|
||||
WaitOpen {
|
||||
@@ -158,8 +120,7 @@ enum Phase {
|
||||
Done,
|
||||
}
|
||||
|
||||
/// Send the probe's own copy back and enter [`Phase::Cleanup`] — the one way out of the round loop,
|
||||
/// so no exit path can skip the cleanup.
|
||||
/// Send the probe's own copy back and enter [`Phase::Cleanup`], the one way out of the round loop.
|
||||
fn finish(net: &NetCommands, entry: u32, now: f64) -> Phase {
|
||||
let _ = net.0.send(ClientCommand::Chat {
|
||||
kind: ChatKind::Say,
|
||||
@@ -169,18 +130,16 @@ fn finish(net: &NetCommands, entry: u32, now: f64) -> Phase {
|
||||
Phase::Cleanup { since: now }
|
||||
}
|
||||
|
||||
/// Whether the loot window is up, asked of the **live UI** rather than of our own state: the window
|
||||
/// is what the director looked for, and `LootFrame` shows only once the feed has fired
|
||||
/// `LOOT_OPENED` off the wire. Falls back to the app-side session when the frame can't be read.
|
||||
/// Whether the live `LootFrame` is shown, which needs `LOOT_OPENED` to have fired; falls back to
|
||||
/// the app-side session when the frame cannot be read.
|
||||
fn window_open(script: &UiScript, loot: &LootState) -> bool {
|
||||
script
|
||||
.eval::<bool>("return LootFrame:IsShown() and 1 or nil")
|
||||
.unwrap_or_else(|_| loot.source().is_some())
|
||||
}
|
||||
|
||||
/// Find the probe's own copy of `entry` in the backpack: its item guid and 1-based Lua slot. The
|
||||
/// template has to have landed too — the click dispatcher's open arm is a **template** LOOTABLE
|
||||
/// test, so a click made before the answer arrives falls through to a plain use and proves nothing.
|
||||
/// The probe's copy of `entry` in the backpack: its item guid and 1-based Lua slot, once its
|
||||
/// template has landed too, since the dispatcher's open arm tests the template's lootable flag.
|
||||
fn find_in_backpack(
|
||||
store: &ObjectStore,
|
||||
entry: u32,
|
||||
@@ -223,7 +182,7 @@ fn clam_probe(
|
||||
return; // not in-world yet
|
||||
};
|
||||
let Some(mut script) = script else {
|
||||
return; // no UI VM this build — nothing this probe can drive
|
||||
return; // no UI VM in this build, so nothing to drive
|
||||
};
|
||||
let now = time.elapsed_secs_f64();
|
||||
let entry = target_entry();
|
||||
@@ -257,7 +216,7 @@ fn clam_probe(
|
||||
refuses silently when the backpack is full. This is NOT a passing run"
|
||||
);
|
||||
probe.fails += 1;
|
||||
probe.phase = Phase::Done; // nothing was stocked — nothing to clean up
|
||||
probe.phase = Phase::Done; // nothing was stocked, so nothing to clean up
|
||||
}
|
||||
}
|
||||
Phase::Before => {
|
||||
@@ -328,10 +287,8 @@ fn clam_probe(
|
||||
}
|
||||
}
|
||||
Phase::Cleanup { since } => {
|
||||
// Put the character back as we found it, and **watch it land**: `AppExit` tears the net
|
||||
// thread down within a frame or two, so a fire-and-forget `.additem -1` written on the
|
||||
// way out never reaches the wire (the first run of this probe left its clam behind
|
||||
// exactly that way). Leave when the copy is gone, or say plainly that it isn't.
|
||||
// Wait for the removal to land: `AppExit` tears the net thread down within a frame or
|
||||
// two, so a `.additem -1` sent on the way out never reaches the wire.
|
||||
let gone = probe
|
||||
.clam
|
||||
.is_some_and(|guid| (0..16u8).all(|i| store.0.player_pack_slot(i) != Some(guid)));
|
||||
@@ -351,9 +308,8 @@ fn clam_probe(
|
||||
}
|
||||
probe.exited = true;
|
||||
report(&probe, entry);
|
||||
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
|
||||
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
|
||||
// client holding the probe account.
|
||||
// `ProbeExitPlugin::fire_probe_exit`'s pattern: `AppExit` plus a hard backstop, so a
|
||||
// teardown hang cannot leave a client holding the probe account.
|
||||
exit.write(AppExit::Success);
|
||||
std::thread::spawn(|| {
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
@@ -364,9 +320,8 @@ fn clam_probe(
|
||||
}
|
||||
}
|
||||
|
||||
/// Right-click the clam the way the bag button does — through the live VM's own
|
||||
/// `UseContainerItem`, so `ui_items::drain::drain_container_uses` runs its real dispatcher (and its
|
||||
/// real open arm) rather than the probe re-deciding which packet an openable item takes.
|
||||
/// Right-click the clam through the live VM's `UseContainerItem`, so the real dispatcher
|
||||
/// (`ui_items::drain::drain_container_uses`) picks the packet.
|
||||
fn click(script: &mut UiScript, probe: &mut ClamProbe, round: u8) {
|
||||
let slot = probe.slot;
|
||||
info!("PROBE_CLAM: round {round} — UseContainerItem(0, {slot}) through the live VM");
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
//! The sea-crossing live probe (`WOW_PROBE=crossing`) — decision 0455's instrument, inert
|
||||
//! without the env: once in-world, wait for a cross-continent transport docked on our map,
|
||||
//! GM-drop onto its deck (`.go xyz`; probe accounts are gmlevel 6), then just stand there and
|
||||
//! report the seam: aboard → map flip (TRANSFER_PENDING / NEW_WORLD riding branch, logged by
|
||||
//! the net layer) → still riding → arrived docked on the far continent. Every phase edge prints
|
||||
//! a `PROBE crossing:` line, so an outer `timeout`d run + grep is the whole harness. Non-combat.
|
||||
//! Pair with the checkout's own probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR
|
||||
//! — the `probe` skill; a shared account gets kicked by parallel sessions mid-ride).
|
||||
//! The sea-crossing live probe (`WOW_PROBE=crossing`): once in-world, waits for a cross-continent
|
||||
//! transport docked on our map, drops onto its deck with `.go xyz`, and logs a `PROBE crossing:`
|
||||
//! line at each edge: aboard, map flip (the riding `NEW_WORLD` branch), still riding, docked on the
|
||||
//! far continent. Non-combat. It needs the checkout's own probe account, since a shared one is
|
||||
//! kicked mid-ride; the switches are `docs/CONTRIBUTING.md`, "Running it unattended".
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -24,15 +21,12 @@ impl Plugin for ProbeCrossingPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe's phase machine. `Wait` → (deck drop sent) `Boarding` → (ride attached) `Aboard` →
|
||||
/// (CurrentMap flipped, still riding) `Crossed` → (docked on the new map, still riding) done.
|
||||
/// A failed boarding (window closed under the settle, landed in the drink) retries the next
|
||||
/// docked window; a lost ride after the flip is a loud FAILURE line.
|
||||
/// `Wait`, `Boarding` (drop sent), `Aboard` (ride attached), `Crossed` (map flipped, still
|
||||
/// riding), `Done`. A missed boarding retries at the next dock; a ride lost after the flip FAILs.
|
||||
#[derive(Resource, Default)]
|
||||
struct CrossingProbe {
|
||||
phase: Phase,
|
||||
/// The one-shot [`BOOTSTRAP_DOCK`] send — so a body that still cannot see a ferry after the
|
||||
/// hop (wrong continent, a `.go` the server refused) doesn't re-teleport every frame.
|
||||
/// Latches the one dock hop, so a body that still sees no ferry does not hop every frame.
|
||||
bootstrapped: bool,
|
||||
}
|
||||
|
||||
@@ -55,41 +49,18 @@ enum Phase {
|
||||
Done,
|
||||
}
|
||||
|
||||
/// Yards above the boat's sampled origin the GM drop aims: high enough to clear the deck
|
||||
/// wherever the model origin sits, low enough that the post-teleport settle hold (6 s) + the free
|
||||
/// fall land well inside the dock window.
|
||||
///
|
||||
/// **10 was not high enough, and the failure was silent.** A taxi path's node `z` is the
|
||||
/// *waterline* for the sea ferries — every node of path 241 carries `z = 0` — while their decks
|
||||
/// stand ~16 yd above it, so the drop materialised inside the hull and the probe reported
|
||||
/// "boarding missed the window" forever without ever printing a `transport: board`. The zeppelins
|
||||
/// hid it: their nodes sit at deck height (71.08), where 10 yd clears fine. Anything on the boat
|
||||
/// counts as boarding — `owning_transport` walks up to the `Transport` root — so overshooting a
|
||||
/// cabin roof is harmless and undershooting is not.
|
||||
/// Yards above the boat's sampled origin the drop aims. A sea ferry's path nodes sit at the
|
||||
/// waterline (path 241 is all `z = 0`) with the deck ~16 yd above, while zeppelin nodes sit at deck
|
||||
/// height; landing anywhere on the boat counts, so overshooting is harmless.
|
||||
const DROP_HEIGHT: f32 = 25.0;
|
||||
/// Seconds after the deck drop before conceding the boarding failed (settle 6 s + fall + attach,
|
||||
/// with slack) and re-arming for the next docked window.
|
||||
/// Seconds after the drop before the boarding counts as missed (6 s settle, fall, attach, slack).
|
||||
const BOARD_DEADLINE: f64 = 15.0;
|
||||
/// Solid ground on the Booty Bay pier, beside the Ratchet ferry's berth (WoW coords) — where the
|
||||
/// probe sends itself when no cross-continent transport is in range at all.
|
||||
///
|
||||
/// The probe used to *assume* it was standing at a dock: its `Wait` arm only reacts to a transport
|
||||
/// the server has already put in visibility range, so a probe body parked anywhere else waited
|
||||
/// forever, printing nothing. That made the instrument unrunnable from a cold login, which is the
|
||||
/// only way an unattended session ever starts it.
|
||||
/// The Booty Bay pier beside the Ratchet ferry's berth (WoW coords), where the probe goes when no
|
||||
/// cross-continent transport is in range.
|
||||
const BOOTSTRAP_DOCK: [f32; 4] = [-14297.2, 531.0, 8.8, 0.0];
|
||||
/// `WOW_PROBE_DOCK=x,y,z[,map]` — go to *this* dock, before waiting for anything. `map` defaults
|
||||
/// to 0 and **must be sent**: `.go xyz` without one teleports within the map you are *currently*
|
||||
/// on, so a probe that has already crossed to Kalimdor and is then aimed at a Booty Bay dock lands
|
||||
/// at Azeroth's coordinates on Kalimdor — off that map's tile grid, where nothing streams and the
|
||||
/// loading cover can never clear. This probe did exactly that and hung, which is the bug it was
|
||||
/// written to chase, arrived at from the other end.
|
||||
///
|
||||
/// Without it the probe rides whichever cross-continent ferry the login happens to be standing
|
||||
/// next to, which is not a choice at all: the 1.12 fleet's seams are not interchangeable (one
|
||||
/// path crosses mid-cycle, another crosses at the cycle wrap), so "it worked" on the ferry that
|
||||
/// answered says nothing about the one a report names. Overriding the destination is how a
|
||||
/// specific seam gets measured.
|
||||
/// `WOW_PROBE_DOCK=x,y,z[,map]`: go to this dock first, to measure a named ferry's seam (one
|
||||
/// path crosses mid-cycle, another at the cycle wrap). `map` defaults to 0 and is always sent:
|
||||
/// `.go xyz` without one stays on the current map (vmangos `TeleportCommands.cpp:871`).
|
||||
fn dock_override() -> Option<[f32; 4]> {
|
||||
let raw = std::env::var("WOW_PROBE_DOCK").ok()?;
|
||||
let mut it = raw.split(',').map(|p| p.trim().parse::<f32>());
|
||||
@@ -102,16 +73,12 @@ fn dock_override() -> Option<[f32; 4]> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// How near [`dock_override`]'s named dock the body must stand before the probe will board
|
||||
/// anything — wide enough to cover a pier and the ferry moored along it, tight enough that the
|
||||
/// login spot on another continent can never satisfy it.
|
||||
/// How near [`dock_override`]'s dock the body must stand before boarding anything.
|
||||
const DOCK_ARRIVED_YD: f32 = 200.0;
|
||||
/// How near the body a docked ferry must be before the probe will drop onto it — see the scan.
|
||||
/// Generous enough for a long pier, far short of the next dock on the same continent.
|
||||
/// How near a docked ferry must be to drop onto it: a long pier, short of the next dock.
|
||||
const BOARDABLE_YD: f32 = 400.0;
|
||||
/// Seconds the probe watches for a cross-continent transport before sending itself to
|
||||
/// [`BOOTSTRAP_DOCK`] — long enough for a login's object stream to deliver one if we are already
|
||||
/// somewhere it sails from.
|
||||
/// Seconds to wait for the login's object stream to show a ferry before going to
|
||||
/// [`BOOTSTRAP_DOCK`].
|
||||
const BOOTSTRAP_AFTER: f64 = 20.0;
|
||||
|
||||
fn crossing_probe(
|
||||
@@ -132,24 +99,13 @@ fn crossing_probe(
|
||||
let now = time.elapsed_secs_f64();
|
||||
match probe.phase {
|
||||
Phase::Wait => {
|
||||
// Nothing that crosses the sea is in range at all — we are not at a ferry dock. Send
|
||||
// the body to one (once; `bootstrapped` latches) rather than waiting out the run.
|
||||
// With no sea-crossing transport in range, go to a dock once.
|
||||
let forced = dock_override();
|
||||
let adrift = !transports
|
||||
.iter()
|
||||
.any(|(_, t, _)| t.touches_map(0) && t.touches_map(1));
|
||||
// An explicit `WOW_PROBE_DOCK` goes FIRST and waits for nothing: the whole point of
|
||||
// naming a dock is to ride *that* ferry, and the login spot very often has a different
|
||||
// one already in range — which the scan below would board within the grace period,
|
||||
// silently measuring the wrong seam. (It did exactly that: a run aimed at Booty Bay
|
||||
// rode the Undercity zeppelin instead.) Only the "no ferry anywhere" fallback waits,
|
||||
// because that one is guessing and should let the object stream finish first.
|
||||
// **Having SENT the hop is not having ARRIVED.** `.go` is a server round trip; the
|
||||
// scan below runs again on the very next frame, when the body is still standing where
|
||||
// it logged in and the old surroundings' ferry is still in range — so it boards that
|
||||
// one and measures the wrong seam, which is precisely the failure the override exists
|
||||
// to prevent. (Aimed at Booty Bay, it rode the Menethil–Theramore ferry.) So while a
|
||||
// dock is named, nothing is boarded until the body is actually standing at it.
|
||||
// A named dock hops at once, and nothing is boarded until the body stands there: the
|
||||
// `.go` is a round trip, and the login spot's own ferry is still in range meanwhile.
|
||||
if let Some(dock) = forced {
|
||||
let here = benilla_assets::coords::bevy_to_wow(player.pos);
|
||||
let (dx, dy) = (here[0] - dock[0], here[1] - dock[1]);
|
||||
@@ -173,7 +129,7 @@ fn crossing_probe(
|
||||
"no cross-continent transport in range"
|
||||
}
|
||||
);
|
||||
// The map id is not optional — see [`dock_override`].
|
||||
// Always send the map id: `.go xyz` without one stays on the current map.
|
||||
let _ = net.0.send(ClientCommand::Chat {
|
||||
kind: crate::net::ChatKind::Say,
|
||||
target: None,
|
||||
@@ -184,9 +140,8 @@ fn crossing_probe(
|
||||
if adrift {
|
||||
return;
|
||||
}
|
||||
// A cross-continent transport (the 1.12 fleet crosses EK↔Kalimdor, maps 0↔1),
|
||||
// currently docked on OUR map: drop onto its deck. `sample.pos` is WoW coords —
|
||||
// exactly what `.go xyz` takes.
|
||||
// A transport touching maps 0 and 1, docked on our map: drop onto its deck.
|
||||
// `sample.pos` is in WoW coords, as `.go xyz` takes.
|
||||
for (guid, transport, anchor) in &transports {
|
||||
if !(transport.touches_map(0) && transport.touches_map(1)) {
|
||||
continue;
|
||||
@@ -195,12 +150,8 @@ fn crossing_probe(
|
||||
if sample.map != map || sample.moving {
|
||||
continue;
|
||||
}
|
||||
// **And it has to be a ferry we are actually standing next to.** A cross-continent
|
||||
// `.go` leaves the departed surroundings' objects in the entity list until the
|
||||
// server gets round to removing them, so the scan can otherwise pick a boat
|
||||
// thousands of yards away and `.go` onto its deck — undoing the hop that just
|
||||
// placed us, and measuring whichever seam that boat happens to cross. Aimed at
|
||||
// Booty Bay, this probe boarded the Tirisfal zeppelin twice that way.
|
||||
// Only a nearby ferry: after a cross-continent `.go` the departed surroundings'
|
||||
// objects stay in the entity list until the server removes them.
|
||||
let here = benilla_assets::coords::bevy_to_wow(player.pos);
|
||||
let (dx, dy) = (here[0] - sample.pos[0], here[1] - sample.pos[1]);
|
||||
if (dx * dx + dy * dy).sqrt() > BOARDABLE_YD {
|
||||
|
||||
@@ -1,73 +1,40 @@
|
||||
//! The probe fleet's **environment registry** — every `WOW_PROBE*` variable the app reads, in one
|
||||
//! table the code is checked against in both directions (decision 2265 §A5).
|
||||
//!
|
||||
//! The set used to live in three places that could not see each other: the read sites (one per
|
||||
//! variable, spread over the fleet), a hand-kept arming list in `dev.rs` for the un-occludable
|
||||
//! window ([`super::ProbeFocusPlugin`], decision 0906), and nothing at all that said what the
|
||||
//! fleet accepts. The arming list is the one that bit: 0906's rule is that *every* scripted
|
||||
//! probe defends itself against the macOS occlusion throttle, and the list had drifted to ten of
|
||||
//! the twenty-five variables that schedule on the wall clock — a mail or auction probe launched
|
||||
//! exactly as the method prescribes ran covered, at ~1 fps, measuring garbage.
|
||||
//!
|
||||
//! So the list is now a **column** here ([`ProbeVar::wall_clock`]), the arming reads the column,
|
||||
//! and two structural tests below keep the table honest: every `"WOW_PROBE…"` string literal in
|
||||
//! the crate is a row, every row is a literal somewhere other than this file, and every value
|
||||
//! `WOW_PROBE` itself dispatches on is in [`PROBE_NAMES`]. `WOW_PROBE=list` prints it.
|
||||
//!
|
||||
//! What this is **not**: a dispatcher. The variables keep their own read sites and their own
|
||||
//! shapes — several carry free-form values (Lua chunks, chat lines with spaces and `=`, `A>B`
|
||||
//! drag specs) and several combine in one run — so folding them into one variable would need an
|
||||
//! escaping mini-language and would change the launch line every session uses. The defect was
|
||||
//! the drift, and a registry is the whole cure for drift.
|
||||
//! The probe environment registry: every `WOW_PROBE*` variable the app reads, with whether it
|
||||
//! schedules on the wall clock ([`ProbeVar::wall_clock`], which arms [`super::ProbeFocusPlugin`]).
|
||||
//! The tests keep it in step with the code both ways; `WOW_PROBE=list` prints it. The variables
|
||||
//! keep their own read sites: this is a registry, not a dispatcher.
|
||||
|
||||
/// One probe-fleet environment variable.
|
||||
pub(crate) struct ProbeVar {
|
||||
/// The variable, exactly as the read site spells it.
|
||||
pub name: &'static str,
|
||||
/// One line: the value shape and what setting it does, from the comment at the read site.
|
||||
/// One line: the value shape and what setting it does.
|
||||
pub purpose: &'static str,
|
||||
/// Whether the variable **schedules on elapsed real time** — its script fires at `<secs>`
|
||||
/// marks, integrates a rate per frame, or steps a phase machine on `Time<Real>` — so the run
|
||||
/// is only right while frames keep arriving at full rate.
|
||||
///
|
||||
/// Why it matters (decision 0906): macOS drops a fully covered window to ~1 fps drawables,
|
||||
/// and on such a window a wall-clock probe does not measure slowly, it **runs the wrong
|
||||
/// script** — one leg fired `W@16` and `Space@19` in the same frame and jumped from a
|
||||
/// standstill; another integrated its camera at 125 yd/s for 500 and never crossed the
|
||||
/// radius it was testing (0794). Every `true` row therefore arms
|
||||
/// [`super::ProbeFocusPlugin`] from `dev.rs`, which keeps the probe window un-occludable
|
||||
/// (and, for a run that draws no pixels, parks it small in a corner — decision 1148).
|
||||
///
|
||||
/// A `false` row is a flag or a modifier: a `_AT`/`_STEP`/`_KEEP` rides its parent's
|
||||
/// arming, a pricing lever changes *what* a run draws rather than *when*, and a state
|
||||
/// machine that advances on wire replies (the char-create probe) has no clock to be robbed of.
|
||||
/// Whether the variable schedules on elapsed real time, so it is only right at full frame
|
||||
/// rate: macOS drops a covered window to ~1 fps, and such a run fires its script out of
|
||||
/// order. A `true` row arms [`super::ProbeFocusPlugin`] from `dev.rs`, keeping the window
|
||||
/// un-occludable; a modifier rides its parent's arming.
|
||||
pub wall_clock: bool,
|
||||
}
|
||||
|
||||
/// Every `WOW_PROBE*` variable the app reads. Grouped by the probe that owns each; the order is
|
||||
/// the order `WOW_PROBE=list` prints.
|
||||
/// Every `WOW_PROBE*` variable the app reads, grouped by owner, in `WOW_PROBE=list` order.
|
||||
pub(crate) const PROBE_VARS: &[ProbeVar] = &[
|
||||
// ── The variable itself, and the run shell every scripted probe rides ────────────────────
|
||||
ProbeVar {
|
||||
name: "WOW_PROBE",
|
||||
purpose: "<name> — a value-dispatched live probe (see the names below); `list` prints this table",
|
||||
// Five of the six values step a phase machine on `time.elapsed_secs` (melee's 3 s swing
|
||||
// cadence, crossing/taxi/guardpoi's phases, castcancel's press-at-t); `partner` answers
|
||||
// invites the frame they land, but rides the same variable.
|
||||
// Every value but `list` and `partner` steps a phase machine on the wall clock.
|
||||
wall_clock: true,
|
||||
},
|
||||
ProbeVar {
|
||||
name: "WOW_PROBE_EXIT_AT",
|
||||
purpose: "<secs> — exit the app after N wall seconds; bounds any scripted live probe's lifetime",
|
||||
// Fires on `ProbeClock` at `<secs>`, and it is the one probe variable a trace-only run
|
||||
// sets on its own (`WOW_MOVE_TRACE`/`WOW_STREAM_TRACE` legs, docs/METHOD.md) — 0794's
|
||||
// throttled camera leg was exactly such a run.
|
||||
// Fires on `ProbeClock`; a trace-only run sets it alone.
|
||||
wall_clock: true,
|
||||
},
|
||||
ProbeVar {
|
||||
name: "WOW_PROBE_PARK",
|
||||
purpose: "corner|edge|off — where the un-occludable probe window sits, and whether it is pinned on top",
|
||||
// A dial ON the occlusion defence itself, not a schedule.
|
||||
// A dial on the occlusion defence itself, not a schedule.
|
||||
wall_clock: false,
|
||||
},
|
||||
ProbeVar {
|
||||
@@ -118,7 +85,7 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[
|
||||
ProbeVar {
|
||||
name: "WOW_PROBE_HOVER",
|
||||
purpose: "\"<frame>[;<frame>…]\" — sweep the real pointer across the named frames' centres, pressing nothing",
|
||||
// Starts at `_AT`, one move per `_STEP` seconds, alternating on `_DUTY` — all wall seconds.
|
||||
// Starts at `_AT`, one move per `_STEP` seconds, alternating on `_DUTY`, in wall seconds.
|
||||
wall_clock: true,
|
||||
},
|
||||
ProbeVar {
|
||||
@@ -162,19 +129,17 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[
|
||||
purpose: "\"<chunk>\" — a Lua chunk evaluated after each drag whose string result is logged as the report",
|
||||
wall_clock: false,
|
||||
},
|
||||
// ── The scripted controller inputs (decisions 0621/0653) ────────────────────────────────
|
||||
// ── The scripted controller inputs ────────────────────────────────
|
||||
ProbeVar {
|
||||
name: "WOW_PROBE_LOOK",
|
||||
purpose: "\"<deg_per_sec>@<start_s>:<duration_s>[;…]\" — the scripted mouse-turn: turn the avatar's aim at a rate for a while",
|
||||
// `90°/s for 6 s` has to produce 540° whatever the frame rate did; integrates rate × dt
|
||||
// between `at` and `until` on `ProbeClock`.
|
||||
// Integrates rate times dt between `at` and `until` on `ProbeClock`.
|
||||
wall_clock: true,
|
||||
},
|
||||
ProbeVar {
|
||||
name: "WOW_PROBE_PITCH",
|
||||
purpose: "\"<deg>@<start_s>[:<deg_per_sec>][;…]\" — the scripted dive: aim a swimming avatar's nose up or down (+up)",
|
||||
// A dive scripted to reach 30° by second 25 has to reach it at second 25, and it is the
|
||||
// one script that can be a run's only actuator (a drifting swimmer needs no keys).
|
||||
// Each dive target is due at its wall-clock second.
|
||||
wall_clock: true,
|
||||
},
|
||||
ProbeVar {
|
||||
@@ -194,7 +159,7 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[
|
||||
purpose: "1 — keep vsync ON in the FPS probe: it then measures the present ceiling the display grants this window",
|
||||
wall_clock: false,
|
||||
},
|
||||
// ── The pricing levers and traces: change what a run draws or logs, never when ───────────
|
||||
// ── The pricing levers and traces: what a run draws or logs, never when ─────────────────
|
||||
ProbeVar {
|
||||
name: "WOW_PROBE_UI_ONE_TEX",
|
||||
purpose: "1 — pricing lever: split UI runs on state flags alone, ignoring texture identity (the draw-count ceiling an atlas would reach)",
|
||||
@@ -211,8 +176,7 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[
|
||||
wall_clock: false,
|
||||
},
|
||||
// ── The `=1` live probes: each parks the body somewhere real and steps a phase machine ───
|
||||
// Every one below reads `time.elapsed_secs_f64()` into a `since`/`now` phase machine —
|
||||
// waits, settles and timeouts in wall seconds — so each arms the occlusion defence.
|
||||
// Their waits, settles and timeouts are wall seconds, so each arms the occlusion defence.
|
||||
ProbeVar {
|
||||
name: "WOW_PROBE_BG_SAMPLES",
|
||||
purpose: "n — how many 12 s census samples WOW_PROBE_BG takes inside the battleground (default 12; ~30 reaches vmangos's 5-minute premature finish, i.e. the end of a match)",
|
||||
@@ -308,8 +272,7 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[
|
||||
ProbeVar {
|
||||
name: "WOW_PROBE_CHARCREATE",
|
||||
purpose: "\"<name>[,race,class,gender[,skin,face,hair,haircolor,facial]]\" — create (and delete) a character at select over the wire",
|
||||
// A roster/result state machine: `AwaitingRoster` → create → result byte → delete →
|
||||
// result byte, each step on the reply that arrives. No clock read anywhere in it.
|
||||
// Each step advances on a wire reply; no clock is read.
|
||||
wall_clock: false,
|
||||
},
|
||||
ProbeVar {
|
||||
@@ -319,9 +282,8 @@ pub(crate) const PROBE_VARS: &[ProbeVar] = &[
|
||||
},
|
||||
];
|
||||
|
||||
/// The named values of `WOW_PROBE` itself, `(value, purpose)` — the one place they are listed.
|
||||
/// `dev.rs` dispatches on the literals (one plugin per value) and `lib.rs` answers `list`
|
||||
/// before any window opens; the test below keeps both in step with this table.
|
||||
/// The named values of `WOW_PROBE`, `(value, purpose)`; `dev.rs` dispatches on them and `lib.rs`
|
||||
/// answers `list`.
|
||||
pub(crate) const PROBE_NAMES: &[(&str, &str)] = &[
|
||||
("list", "print this table and exit, before any window opens"),
|
||||
(
|
||||
@@ -350,12 +312,12 @@ pub(crate) const PROBE_NAMES: &[(&str, &str)] = &[
|
||||
),
|
||||
];
|
||||
|
||||
/// The variables whose presence arms the un-occludable probe window — the `wall_clock` column.
|
||||
/// The variables whose presence arms the un-occludable probe window.
|
||||
pub(crate) fn wall_clock_vars() -> impl Iterator<Item = &'static str> {
|
||||
PROBE_VARS.iter().filter(|v| v.wall_clock).map(|v| v.name)
|
||||
}
|
||||
|
||||
/// `WOW_PROBE=list` — print the registry, one variable per line, then the named values.
|
||||
/// `WOW_PROBE=list`: prints the registry, one variable per line, then the named values.
|
||||
pub(crate) fn print() {
|
||||
println!("The probe fleet's environment (capture::probe_env, decision 2265 §A5).");
|
||||
println!(
|
||||
@@ -380,17 +342,11 @@ pub(crate) fn print() {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// This file's own path under `src/` — excluded from both scans, since its table and its
|
||||
/// tests are the one place the literals are *supposed* to appear without being read.
|
||||
/// This file's path under `src/`, excluded from both scans.
|
||||
const SELF: &str = "capture/probe_env.rs";
|
||||
|
||||
/// **The table cannot drift from the code, in either direction** (decision 2265 §A5).
|
||||
///
|
||||
/// Every `"WOW_PROBE…"` string literal in the crate must be a row here — a variable the
|
||||
/// fleet reads that the registry does not know is exactly the hand-maintained gap this
|
||||
/// module replaced — and every row must be a literal somewhere other than this file, or the
|
||||
/// registry is describing a variable nothing reads. String literals only: doc comments
|
||||
/// write the backticked form and are skipped by line.
|
||||
/// Every `"WOW_PROBE..."` string literal in the crate is a row, and every row is a literal
|
||||
/// outside this file; comment lines are skipped.
|
||||
#[test]
|
||||
fn every_probe_variable_is_registered_and_every_registered_variable_is_read() {
|
||||
let registered: Vec<&str> = PROBE_VARS.iter().map(|v| v.name).collect();
|
||||
@@ -440,10 +396,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **`WOW_PROBE`'s named values are listed once.** Every `Ok("<name>")` the code compares
|
||||
/// `WOW_PROBE`'s value against (the `dev.rs` plugin dispatch, the `lib.rs` `list` exit) must
|
||||
/// be a `PROBE_NAMES` row, and every row must be dispatched on — so `WOW_PROBE=list` prints
|
||||
/// the values that exist, not the ones someone remembered.
|
||||
/// Every `Ok("<name>")` compared against `WOW_PROBE` is a `PROBE_NAMES` row, and every row
|
||||
/// is dispatched on.
|
||||
#[test]
|
||||
fn every_dispatched_probe_name_is_listed_and_every_listed_name_is_dispatched() {
|
||||
let listed: Vec<&str> = PROBE_NAMES.iter().map(|(n, _)| *n).collect();
|
||||
@@ -514,8 +468,7 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
/// The source with every comment line (`//`, `///`, `//!`) dropped — the scans below want
|
||||
/// code, and a doc comment quoting a variable is not a read of it.
|
||||
/// The source with every comment line dropped: a comment quoting a variable is not a read.
|
||||
fn code_lines(text: &str) -> String {
|
||||
text.lines()
|
||||
.filter(|l| !l.trim_start().starts_with("//"))
|
||||
@@ -523,8 +476,7 @@ mod tests {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Every `"WOW_PROBE[A-Z0-9_]*"` string literal in the code — the whole literal, quote to
|
||||
/// quote, so a variable named inside a longer message is not a match.
|
||||
/// Every whole `"WOW_PROBE[A-Z0-9_]*"` string literal in the code, quote to quote.
|
||||
fn probe_literals(text: &str) -> Vec<String> {
|
||||
let code = code_lines(text);
|
||||
let mut out = Vec::new();
|
||||
@@ -551,8 +503,7 @@ mod tests {
|
||||
let mut rest = code.as_str();
|
||||
while let Some(i) = rest.find(needle) {
|
||||
let after = &rest[i + needle.len()..];
|
||||
// The comparison sits right after the read: `.as_deref() == Ok("…")`. Bound the
|
||||
// look-ahead so a bare `is_ok()` gate is not paired with some later `Ok("…")`.
|
||||
// Bounded look-ahead, so a bare `is_ok()` gate is not paired with a later `Ok("...")`.
|
||||
let window = &after[..after.len().min(48)];
|
||||
if let Some(j) = window.find("Ok(\"") {
|
||||
let value = &after[j + 4..];
|
||||
|
||||
@@ -1,108 +1,40 @@
|
||||
//! The GM trouble-ticket live probe (`WOW_PROBE_GMTICKET=1`) — decision 1673's end-to-end
|
||||
//! instrument: log in, and drive the whole five-opcode ticket wire through the **live Lua VM's own
|
||||
//! bindings** (`GetGMStatus`, `DeleteGMTicket`, `GetGMTicket`, `NewGMTicket`, `UpdateGMTicket`),
|
||||
//! observing every answer where the Help window observes it — as an `UPDATE_TICKET` /
|
||||
//! `UPDATE_GM_STATUS` event fired at a real frame. One
|
||||
//! `PROBE_GMTICKET: <step> PASS/FAIL/SKIP <detail>` line per step, then a final
|
||||
//! `PROBE_GMTICKET: DONE pass=<n> fail=<m>`. Modeled on [`super::probe_binder`] and
|
||||
//! [`super::probe_bank`] — same phase machine, same trace style, same self-terminating exit
|
||||
//! ([`super::probes::ProbeExitPlugin`]'s pattern), same live-VM observation idiom (a small Lua hook
|
||||
//! appending to a probe table, read back with `script.eval`).
|
||||
//! The GM ticket live probe (`WOW_PROBE_GMTICKET=1`): drives the five ticket opcodes through the
|
||||
//! live Lua VM's bindings (`GetGMStatus`, `DeleteGMTicket`, `GetGMTicket`, `NewGMTicket`,
|
||||
//! `UpdateGMTicket`) and observes each answer as the Help window does, as an `UPDATE_TICKET` or
|
||||
//! `UPDATE_GM_STATUS` event. Logs one `PROBE_GMTICKET: <step> PASS/FAIL/SKIP <detail>` line per
|
||||
//! step, then `PROBE_GMTICKET: DONE pass=<n> fail=<m>`, and exits. Non-combat and sends no GM
|
||||
//! command; the switches are `docs/CONTRIBUTING.md`, "Running it unattended".
|
||||
//!
|
||||
//! ## Why unit tests cannot close this feature
|
||||
//! vmangos answers several refusals with silence (`GMTicketHandler.cpp:88-113`): no packet for a
|
||||
//! create when the queue is off, the player is under `GMTickets.MinLevel` or the category is
|
||||
//! `>= GMTICKET_MAX` (11), nor for a delete with no ticket (`:73-86`).
|
||||
//!
|
||||
//! 1673 ships 23 unit tests, byte-exact wire goldens and a DBC oracle, and **not one of them can
|
||||
//! tell whether vmangos accepts the packet**. The server answers several refusals with *silence*:
|
||||
//! `HandleGMTicketCreateOpcode` returns with no packet at all when the queue is off, when the
|
||||
//! player is under `GMTickets.MinLevel`, or when the category is `>= GMTICKET_MAX` (11), and
|
||||
//! `HandleGMTicketDeleteTicketOpcode` likewise when there is no ticket (vmangos
|
||||
//! `Handlers/GMTicketHandler.cpp`, read this session). A create the server drops on the floor is
|
||||
//! byte-identical, from the client's side, to one that worked. Only a live round trip settles it.
|
||||
//! The steps:
|
||||
//! 1. queue: `GetGMStatus()` must fire `UPDATE_GM_STATUS` with 1 (`GMTickets.Enable` defaults on,
|
||||
//! `World.cpp:684`); a 0 is the queue switched off, and the rest SKIPs.
|
||||
//! 2. clean: `DeleteGMTicket()` then `GetGMTicket()`, expecting `arg1 == 0`; the get's answer is
|
||||
//! waited on, since a delete with no ticket gets none.
|
||||
//! 3. create: `NewGMTicket(4, <unique text>)` then `GetGMTicket()`, expecting category 4 and the
|
||||
//! text exactly, which proves the create body's layout.
|
||||
//! 4. db: always SKIP. The client never reads map and position back, so the probe prints the row
|
||||
//! it expects (`db-expect`, with a `drift=` bound) and the row is checked by hand.
|
||||
//! 5. edit: `UpdateGMTicket(4, <second text>)` then `GetGMTicket()`, expecting the new text
|
||||
//! exactly, which proves the category byte on `CMSG_GMTICKET_UPDATETEXT` (cmangos-classic reads
|
||||
//! a bare cstring there).
|
||||
//! 6. abandon: `DeleteGMTicket()` then `GetGMTicket()`, expecting `arg1 == 0` again.
|
||||
//!
|
||||
//! ## What each step can and cannot conclude
|
||||
//! Lua's calls reach the wire in call order, so each step only waits on the server's round trip.
|
||||
//! Answers are not correlated to asks: a GM command, vmangos's post-delete `SendTicket(nullptr)`
|
||||
//! and the engine's own re-ask on create-ok and update-ok (reference `0x5e4479`) all fire
|
||||
//! `UPDATE_TICKET` unasked, so each step takes any matching answer after its baseline, and matches
|
||||
//! a unique text in steps 3 and 5.
|
||||
//!
|
||||
//! 1. **queue** — `GetGMStatus()`, then `UPDATE_GM_STATUS` carrying **1**. `GMTickets.Enable`
|
||||
//! defaults to `true` (`World.cpp:684`), so 1 is the expected answer; no event at all means the
|
||||
//! opcode round trip is dead and is a FAIL. A `0` is not a client defect — it is the queue
|
||||
//! genuinely switched off, and every later step would then be answered with silence, so the
|
||||
//! probe SKIPs the rest rather than manufacturing failures out of a server setting.
|
||||
//! 2. **clean** — `DeleteGMTicket()`, then `GetGMTicket()`, expecting `arg1 == 0`. Clears a
|
||||
//! leftover ticket from an earlier run *and* proves the "no ticket" answer decodes
|
||||
//! (`GMTICKET_STATUS_DEFAULT`, a 4-byte body). **The trap:** delete-with-no-ticket is answered
|
||||
//! with nothing at all, so this waits on the `GetGMTicket` answer that follows, never on a
|
||||
//! delete response.
|
||||
//! 3. **create** — `NewGMTicket(4, <unique text>)` (4 = Item, `GMTicketCategory.dbc` id 4 =
|
||||
//! vmangos `GMTICKET_ITEM`), then `GetGMTicket()`, expecting `arg1 == 4` and `arg2` **equal** to
|
||||
//! the text sent. The load-bearing step: it proves the `u8` category, the map/position block,
|
||||
//! the text and the trailing `"Reserved for future use"` cstring are laid out the way vmangos
|
||||
//! reads them. A category widened to `u32` would shift the map id and the position three bytes
|
||||
//! down; the server would file the ticket at a garbage spot or reject the packet outright.
|
||||
//! 4. **db** — the row itself. **A two-part check, and the probe only does the first half**: the
|
||||
//! client never reads map/position back, so the echo in step 3 cannot prove they landed. The
|
||||
//! probe *prints* what it sent (`PROBE_GMTICKET: db-expect …`) and the operator runs the SQL
|
||||
//! after the run and compares. This step therefore always reports SKIP — it is not a pass the
|
||||
//! probe is entitled to claim. The line carries a `drift=` figure: how far the body could have
|
||||
//! moved between the position sample and the frame the packet was stamped (see [`REST_EPS`]).
|
||||
//! `drift=0.000` means the position in the line is exact.
|
||||
//! 5. **edit** — `UpdateGMTicket(4, <second unique text>)`, then `GetGMTicket()`, expecting `arg2`
|
||||
//! **equal** to the new text. This is the step that proves the **category byte** on
|
||||
//! `CMSG_GMTICKET_UPDATETEXT` — the emulator fork 1673 records, where cmangos-classic reads a
|
||||
//! bare cstring and would swallow our category byte as the first character of the text. The
|
||||
//! comparison is equality, never `contains`, because a stray leading control byte is exactly
|
||||
//! the failure this step exists to catch.
|
||||
//! 6. **abandon** — `DeleteGMTicket()`, then `GetGMTicket()`, expecting `arg1 == 0` again.
|
||||
//! vmangos's abandon is `CloseTicket`, which sets `closed_by` and keeps the row
|
||||
//! (`GMTicketMgr.cpp:401-410`); an edit replaces `message` and the type but keeps the create's map
|
||||
//! and position (`GMTicketHandler.cpp:59-60`).
|
||||
//!
|
||||
//! ## Two facts about waiting that this probe is built around
|
||||
//!
|
||||
//! **The wait is for the SERVER, never for the drain.** Lua's calls reach the wire in call order:
|
||||
//! [`crate::ui_gm_ticket`]'s `drain_gm_ticket` walks ONE ordered intent queue
|
||||
//! (`take_gm_ticket_intents`), so `DeleteGMTicket(); GetGMTicket()` in a single chunk arrives
|
||||
//! delete-then-get. It did not always — the drain used to walk a counter per verb and put every
|
||||
//! ask ahead of every write and delete, which inverted exactly that pair and made the get answer
|
||||
//! with the *pre*-delete state. This probe is what surfaced that (driving the bindings directly is
|
||||
//! what a third-party addon does and what no shipped-window test exercises); decision 1673's "One
|
||||
//! ordered queue, not a counter per verb" records the fix. What still has to be waited for is the
|
||||
//! **round trip**: each step separates its write from the read-back by [`WRITE_GAP_SECS`] and then
|
||||
//! waits up to [`ANSWER_TIMEOUT_SECS`] for an answer that matches. The gap is no longer a
|
||||
//! correctness crutch; it keeps one exchange per trace line, which is what makes a failing step
|
||||
//! readable.
|
||||
//!
|
||||
//! **Answers are not correlated to asks, so nothing here waits for "the next event".** Three
|
||||
//! sources put an `UPDATE_TICKET` on the stream that this probe never explicitly asked for: a GM's
|
||||
//! `.ticket viewid`/`escalate`/`complete`; vmangos's own delete handler (`SendTicket(this,
|
||||
//! nullptr)`, right after the delete response); and the **engine's own re-ask**, which fires on
|
||||
//! every create-ok (2) and update-ok (4) response (`GmTicketState::note_write_landed`, the
|
||||
//! reference's `0x5e4479`). So after step 3's `NewGMTicket` an answer arrives *before* the probe's
|
||||
//! own `GetGMTicket()` — expected, not an anomaly. Each step latches the observed event count as a
|
||||
//! baseline and then waits for **any** answer after it that matches the expectation, failing only
|
||||
//! at the timeout and printing every answer it did see. Matching on a *unique* text for the two
|
||||
//! load-bearing steps is what keeps that from being a weaker assertion than "the next one".
|
||||
//!
|
||||
//! ## What the DB row looks like afterwards
|
||||
//!
|
||||
//! vmangos's abandon is `CloseTicket`, which sets `closed_by` and `SaveToDB()`s — it does **not**
|
||||
//! delete the row (`GMTicketMgr.cpp:401-410`) — so step 6 leaves the row in place for the operator
|
||||
//! to read. `HandleGMTicketUpdateTextOpcode` calls `SetTicketType(packet.type)` as well as
|
||||
//! `SetMessage`, so after step 5 the row's `message` is the **edited** text while `map`/
|
||||
//! `position_*` still carry the create's values (the row is REPLACEd in place). Both `db-expect`
|
||||
//! lines say which is which.
|
||||
//!
|
||||
//! ## The run recipe
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_NOSOUND=1 WOW_USER=probe3 WOW_PASS=pprobe3 WOW_CHAR=Probethree \
|
||||
//! WOW_PROBE_GMTICKET=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
|
||||
//! skill). Non-combat; the probe never drives the body and sends no
|
||||
//! GM command, so GM mode and position are left exactly as found — but note that the body is not
|
||||
//! necessarily *still* when a run starts (see [`REST_EPS`]). An outer `timeout` + grep on
|
||||
//! `PROBE_GMTICKET:` is the whole harness; the probe self-exits once DONE. Nothing here is a
|
||||
//! timing measurement, so an occluded window costs wall clock and no correctness.
|
||||
//!
|
||||
//! Every step SKIPs with a note rather than FAILing for an environmental problem (no UI VM in this
|
||||
//! build, the queue switched off server-side, a binding that would not run). A genuine wrong value
|
||||
//! is a FAIL.
|
||||
//! An environmental problem (no UI VM, the queue off, a binding that will not run) SKIPs; only a
|
||||
//! wrong value FAILs.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -113,58 +45,41 @@ use super::probes::ProbeClock;
|
||||
use crate::net::SelfPlayer;
|
||||
use crate::player::Player;
|
||||
|
||||
/// `GMTicketCategory.dbc` id 4 = "Item" (decision 1673's table), which is vmangos's
|
||||
/// `GMTICKET_ITEM = 4` (`SharedDefines.h:1779`) — the same number on both sides, which is the
|
||||
/// point: the id is the wire value, not a list index.
|
||||
/// `GMTicketCategory.dbc` id 4, "Item", which is vmangos's `GMTICKET_ITEM`
|
||||
/// (`SharedDefines.h:1779`): the id is the wire value, not a list index.
|
||||
const CATEGORY_ITEM: u32 = 4;
|
||||
|
||||
/// `SMSG_GMTICKETSYSTEMSTATUS`'s "the queue is taking tickets" (`GMTICKET_QUEUE_STATUS_ENABLED`).
|
||||
///
|
||||
/// The field is **signed** (`GmTicketState::queue_status`, byte-verified: the reference loads it
|
||||
/// with `fild dword`), so `-1` — the window's "queue down, and say so" arm — is a value this probe
|
||||
/// can legitimately read back. vmangos only ever sends 0 or 1.
|
||||
/// `SMSG_GMTICKETSYSTEMSTATUS`'s `GMTICKET_QUEUE_STATUS_ENABLED`. The field is signed (the
|
||||
/// reference fires it at `0x5e4689` through `%d`, a `fild dword` at `0x704fa6`), so the window's
|
||||
/// -1 arm is readable; vmangos sends 0 or 1.
|
||||
const QUEUE_ENABLED: f64 = 1.0;
|
||||
|
||||
/// The `UPDATE_TICKET` `arg1` that means "you have no ticket" — the window's whole else-branch
|
||||
/// hangs on `arg1 and arg1 ~= 0`.
|
||||
/// The `UPDATE_TICKET` `arg1` for no ticket; the Help window tests `arg1 and arg1 ~= 0`.
|
||||
const NO_TICKET: f64 = 0.0;
|
||||
|
||||
/// Settle before the first ask: lets the body land, the world stream, and any login-time
|
||||
/// `GetGMTicket()` the shipped Help window fires drain and answer before a baseline is latched.
|
||||
/// Settle before the first ask, so the Help window's login-time `GetGMTicket()` is answered before
|
||||
/// a baseline is latched.
|
||||
const SETTLE_SECS: f64 = 3.0;
|
||||
|
||||
/// The gap between a write/delete and the `GetGMTicket()` that reads it back.
|
||||
///
|
||||
/// Not a correctness requirement — the drain preserves Lua's call order (module doc) — but a
|
||||
/// readability one: it keeps one exchange per trace line, so a step that fails names the exchange
|
||||
/// that failed rather than a batch of them.
|
||||
/// The gap between a write and its `GetGMTicket()` read-back; the drain keeps call order, so it
|
||||
/// only keeps one exchange per trace line.
|
||||
const WRITE_GAP_SECS: f64 = 1.0;
|
||||
|
||||
/// How long a step waits for a matching answer before calling it. Generous on purpose: a starved
|
||||
/// (occluded) window polls at ~1 fps and a timeout tight enough to trip on that would report a
|
||||
/// FAIL about the wire, which is the one thing an instrument must never do.
|
||||
/// How long a step waits for a matching answer; generous, as an occluded window polls at ~1 fps.
|
||||
const ANSWER_TIMEOUT_SECS: f64 = 20.0;
|
||||
|
||||
/// **The body must be at rest before the ticket is filed.** A ticket records *where you are*, and
|
||||
/// the first live run of this probe filed one while the body was still falling: it entered the
|
||||
/// world at z ≈ 94, the preflight banner read 82, and it was through 60 a second later — 21 yd of
|
||||
/// travel across the create. The row happened to match anyway (the drain sampled the same frame),
|
||||
/// but "happened to" is not a property an instrument may rest step 4 on, so the create now waits
|
||||
/// for the body to stop moving. Movement under [`REST_EPS`] yd for [`REST_HOLD_SECS`] counts as
|
||||
/// stopped.
|
||||
/// A ticket records the body's position, so the create waits for it to rest: under this many yd
|
||||
/// of movement for [`REST_HOLD_SECS`].
|
||||
const REST_EPS: f32 = 0.02;
|
||||
const REST_HOLD_SECS: f64 = 0.5;
|
||||
/// The cap on that wait. Timing out is not a failure — the probe files anyway and says the
|
||||
/// position may be off, because a body that genuinely cannot come to rest (a lift, a swim, a
|
||||
/// treadmill of terrain streaming) still has a working ticket wire to test.
|
||||
/// The cap on that wait; past it the probe files anyway and flags the position as approximate.
|
||||
const REST_TIMEOUT_SECS: f64 = 20.0;
|
||||
|
||||
/// How far the body may have travelled between the position sample and the frame the drain stamps
|
||||
/// the packet, before the db-expect line stops being trustworthy (yd, WoW space).
|
||||
/// How far (yd) the body may move between the position sample and the frame the drain stamps the
|
||||
/// packet before the db-expect position is flagged.
|
||||
const STAMP_DRIFT_EPS: f32 = 0.05;
|
||||
|
||||
/// The operator's half of step 4: the row to read back off the server's `characters` database,
|
||||
/// printed verbatim so it can be pasted into whatever reaches that database.
|
||||
/// Step 4's query against the server's `characters` database, printed for a manual check.
|
||||
const DB_QUERY: &str = "SELECT ticket_id, name, ticket_type, map, position_x, position_y, \
|
||||
position_z, closed_by, message FROM gm_tickets ORDER BY ticket_id DESC \
|
||||
LIMIT 1;";
|
||||
@@ -178,26 +93,24 @@ impl Plugin for ProbeGmTicketPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe's phase machine plus what it discovered along the way (the bank/binder probes' shape:
|
||||
/// a `Copy` phase snapshotted out of the resource each tick, so an arm can mutate `probe` freely).
|
||||
/// The probe's phase machine and what it discovered along the way.
|
||||
#[derive(Resource, Default)]
|
||||
struct GmTicketProbe {
|
||||
phase: Phase,
|
||||
/// The text step 3 filed — unique per run, so no stale answer can satisfy step 3 by accident.
|
||||
/// The text step 3 filed, unique per run so no stale answer satisfies it.
|
||||
create_text: String,
|
||||
/// The text step 5 edited it to.
|
||||
edit_text: String,
|
||||
/// Map and WoW-space position sampled at the moment `NewGMTicket` was called — what the drain
|
||||
/// stamps into `CMSG_GMTICKET_CREATE` in that frame or the next, and what the DB row must
|
||||
/// carry.
|
||||
/// Map and WoW-space position sampled at the `NewGMTicket` call, which the drain stamps into
|
||||
/// `CMSG_GMTICKET_CREATE` that frame or the next.
|
||||
create_map: u32,
|
||||
create_pos: [f32; 3],
|
||||
/// How far the body had travelled by the frame after the create call — the honest bound on
|
||||
/// how wrong the db-expect position could be (`None` until that frame has been seen).
|
||||
/// How far the body had moved by the frame after the create call: the db-expect position's
|
||||
/// error bound.
|
||||
stamp_drift: Option<f32>,
|
||||
passes: u32,
|
||||
fails: u32,
|
||||
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
|
||||
/// Latched once [`Phase::Done`] has fired its exit.
|
||||
exited: bool,
|
||||
}
|
||||
|
||||
@@ -205,7 +118,7 @@ struct GmTicketProbe {
|
||||
enum Phase {
|
||||
#[default]
|
||||
Wait,
|
||||
/// Hook installed; settling before the first ask (module doc).
|
||||
/// Hook installed; settling before the first ask.
|
||||
Settling {
|
||||
since: f64,
|
||||
},
|
||||
@@ -223,8 +136,8 @@ enum Phase {
|
||||
since: f64,
|
||||
baseline: usize,
|
||||
},
|
||||
/// Waiting for the body to stop moving before the ticket is filed ([`REST_EPS`]) — `last` is
|
||||
/// the previous sample and `still_since` when it was last seen to move.
|
||||
/// Waiting for the body to rest; `last` is the previous sample, `still_since` when it last
|
||||
/// moved.
|
||||
Rest {
|
||||
since: f64,
|
||||
last: [f32; 3],
|
||||
@@ -243,7 +156,7 @@ enum Phase {
|
||||
EditGap {
|
||||
since: f64,
|
||||
},
|
||||
/// `GetGMTicket()` called; waiting for the *new* text (step 5b).
|
||||
/// `GetGMTicket()` called; waiting for the new text (step 5b).
|
||||
EditGet {
|
||||
since: f64,
|
||||
baseline: usize,
|
||||
@@ -260,10 +173,8 @@ enum Phase {
|
||||
Done,
|
||||
}
|
||||
|
||||
/// The `UPDATE_TICKET` answers the live VM has seen, newest last, as `(arg1, arg2)` — the category
|
||||
/// (0 for "no ticket") and the description. `arg2` is recorded as `""` whenever `arg1` is 0, since
|
||||
/// the no-ticket fire carries a single argument and reading a second one would be reading whatever
|
||||
/// the global happened to hold.
|
||||
/// The `UPDATE_TICKET` answers seen, newest last, as `(category, text)`. The text is `""` when the
|
||||
/// category is 0: that fire carries one argument, and `arg2` would be stale.
|
||||
fn ticket_answers(script: &UiScript) -> Vec<(f64, String)> {
|
||||
let cats = script
|
||||
.eval::<Vec<f64>>("return ProbeGmTicketCat or {}")
|
||||
@@ -281,10 +192,7 @@ fn status_answers(script: &UiScript) -> Vec<f64> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The first answer after `baseline` that satisfies `want` — the module doc's match-or-timeout
|
||||
/// rule. Answers are not correlated to asks, so "the next event" is not a safe read; "any answer
|
||||
/// after the baseline that matches" is, and the timeout is what turns a never-matching stream into
|
||||
/// a FAIL that prints everything it saw.
|
||||
/// The first answer after `baseline` that satisfies `want`; answers are not correlated to asks.
|
||||
fn matching<T>(answers: &[T], baseline: usize, want: impl Fn(&T) -> bool) -> Option<usize> {
|
||||
answers
|
||||
.iter()
|
||||
@@ -294,9 +202,7 @@ fn matching<T>(answers: &[T], baseline: usize, want: impl Fn(&T) -> bool) -> Opt
|
||||
.map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// A unique-per-run ticket text, so no stale or unsolicited answer can satisfy steps 3/5 by
|
||||
/// accident. Seconds resolution is plenty — two runs cannot start in the same second and reach the
|
||||
/// same step, and the suffix distinguishes the two texts within a run.
|
||||
/// A ticket text unique per run (to the second); the suffix tells a run's two texts apart.
|
||||
fn unique_text(suffix: &str) -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -318,15 +224,14 @@ fn gm_ticket_probe(
|
||||
return; // not in-world yet
|
||||
}
|
||||
let Some(script) = script else {
|
||||
return; // no UI VM this build (headless net-only) — nothing this probe can drive
|
||||
return; // no UI VM in a headless build
|
||||
};
|
||||
let now = time.elapsed_secs_f64();
|
||||
let phase = probe.phase;
|
||||
|
||||
match phase {
|
||||
Phase::Wait => {
|
||||
// The observation channel, installed before anything is asked so it is live for every
|
||||
// answer including the unsolicited ones (the binder probe's exact pattern).
|
||||
// The hook goes in before any ask, so it sees the unsolicited answers too.
|
||||
if let Err(e) = script.run(
|
||||
r#"
|
||||
if not ProbeGmTicketHooked then
|
||||
@@ -353,9 +258,7 @@ fn gm_ticket_probe(
|
||||
end
|
||||
"#,
|
||||
) {
|
||||
// Without the hook nothing can be observed, and every step would then FAIL for a
|
||||
// reason that has nothing to do with the wire. That is the one failure mode an
|
||||
// instrument must never produce, so it stops here instead.
|
||||
// Without the hook nothing is observable, so stop rather than FAIL every step.
|
||||
warn!(
|
||||
"PROBE_GMTICKET: SKIP (0 hook) — the UPDATE_TICKET/UPDATE_GM_STATUS hook would \
|
||||
not install in the live VM: {e}. Nothing can be observed, so no step below \
|
||||
@@ -382,7 +285,7 @@ fn gm_ticket_probe(
|
||||
};
|
||||
}
|
||||
|
||||
// Step 1 — the queue status. No event at all means the round trip is dead.
|
||||
// Step 1, the queue status: no event at all means the round trip is dead.
|
||||
Phase::Queue { since, baseline } => {
|
||||
let seen = status_answers(&script);
|
||||
let Some(i) = matching(&seen, baseline, |_| true) else {
|
||||
@@ -424,8 +327,8 @@ fn gm_ticket_probe(
|
||||
probe.phase = Phase::CleanGap { since: now };
|
||||
}
|
||||
|
||||
// Step 2 — the clean slate. The delete is deliberately NOT waited on: with no ticket
|
||||
// vmangos answers it with nothing at all.
|
||||
// Step 2, the clean slate. The delete is not waited on: with no ticket vmangos sends
|
||||
// nothing.
|
||||
Phase::CleanGap { since } => {
|
||||
if now - since < WRITE_GAP_SECS {
|
||||
return;
|
||||
@@ -465,8 +368,7 @@ fn gm_ticket_probe(
|
||||
}
|
||||
}
|
||||
|
||||
// The rest gate: a ticket records where you are, so file it from a body that has stopped
|
||||
// moving (module doc — the first live run filed one mid-fall).
|
||||
// The rest gate: a ticket records the position, so file it from a body at rest.
|
||||
Phase::Rest {
|
||||
since,
|
||||
last,
|
||||
@@ -507,13 +409,10 @@ fn gm_ticket_probe(
|
||||
probe.phase = Phase::CreateGap { since: now };
|
||||
}
|
||||
|
||||
// Step 3 — the load-bearing one: the create body's layout, proven by the echo.
|
||||
// Step 3: the create body's layout, proven by the echo.
|
||||
Phase::CreateGap { since } => {
|
||||
// The first tick after the create call is the last frame in which the drain can still
|
||||
// stamp the packet, so this is the whole window in which the body could move between
|
||||
// the sample and the wire. Measuring it here — rather than a second later, at the
|
||||
// db-expect print — is the difference between an honest bound and crying wolf at a
|
||||
// body that simply kept falling after the packet had already gone.
|
||||
// The first tick after the create call is the last frame the drain can stamp the
|
||||
// packet in, so the drift is measured here and not at the later db-expect print.
|
||||
if probe.stamp_drift.is_none() {
|
||||
let drift = distance(bevy_to_wow(player.pos), probe.create_pos);
|
||||
probe.stamp_drift = Some(drift);
|
||||
@@ -592,7 +491,7 @@ fn gm_ticket_probe(
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5 — the edit, and with it the category byte on CMSG_GMTICKET_UPDATETEXT.
|
||||
// Step 5: the edit, and with it the category byte on CMSG_GMTICKET_UPDATETEXT.
|
||||
Phase::EditGap { since } => {
|
||||
if now - since < WRITE_GAP_SECS {
|
||||
return;
|
||||
@@ -609,8 +508,7 @@ fn gm_ticket_probe(
|
||||
Phase::EditGet { since, baseline } => {
|
||||
let seen = ticket_answers(&script);
|
||||
let want = probe.edit_text.clone();
|
||||
// Equality, never `contains`: a leading control byte in the stored text is precisely
|
||||
// the cmangos-style failure this step exists to catch (decision 1673's divergence).
|
||||
// Equality, never `contains`: a stray leading byte is the failure this step catches.
|
||||
if let Some(i) = matching(&seen, baseline, |(_, t)| *t == want) {
|
||||
let (cat, text) = &seen[i];
|
||||
info!(
|
||||
@@ -643,8 +541,7 @@ fn gm_ticket_probe(
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6 — abandon. vmangos keeps the row (CloseTicket → closed_by + SaveToDB), so the
|
||||
// operator's SQL still finds it afterwards.
|
||||
// Step 6, abandon. vmangos's `CloseTicket` keeps the row, so step 4's query still finds it.
|
||||
Phase::AbandonGap { since } => {
|
||||
if now - since < WRITE_GAP_SECS {
|
||||
return;
|
||||
@@ -690,9 +587,7 @@ fn gm_ticket_probe(
|
||||
"PROBE_GMTICKET: DONE pass={} fail={}",
|
||||
probe.passes, probe.fails
|
||||
);
|
||||
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
|
||||
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
|
||||
// client holding the probe account.
|
||||
// AppExit plus a hard-exit backstop, so a teardown hang cannot keep the account held.
|
||||
exit.write(AppExit::Success);
|
||||
std::thread::spawn(|| {
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
@@ -703,10 +598,8 @@ fn gm_ticket_probe(
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one binding in the live VM, or SKIP the whole probe with the reason.
|
||||
///
|
||||
/// A binding that will not run is environmental (no VM, a broken chunk) — never a wire verdict —
|
||||
/// so it must not print a FAIL. Returns `false` once the probe has been parked in [`Phase::Done`].
|
||||
/// Runs one binding in the live VM, or SKIPs the whole probe and returns `false`: a binding that
|
||||
/// will not run is environmental, never a wire verdict.
|
||||
fn run_or_skip(probe: &mut GmTicketProbe, script: &UiScript, step: &str, chunk: &str) -> bool {
|
||||
match script.run(chunk) {
|
||||
Ok(()) => {
|
||||
@@ -724,13 +617,8 @@ fn run_or_skip(probe: &mut GmTicketProbe, script: &UiScript, step: &str, chunk:
|
||||
}
|
||||
}
|
||||
|
||||
/// Print step 4's half of the check: exactly what the DB row must carry.
|
||||
///
|
||||
/// The position is the one sampled at the `NewGMTicket` call — what the drain stamps into the
|
||||
/// packet, in that frame or the next. How much the body could have moved inside that window is
|
||||
/// measured once, at the frame after the call, and reported here as `drift=`; a value at or under
|
||||
/// [`STAMP_DRIFT_EPS`] means the line is exact. The map id and `ticket_type` are exact regardless.
|
||||
/// **Nothing here is a PASS** — the row is the operator's to read.
|
||||
/// Prints what step 4's DB row must carry. `drift=` bounds the position's error; at or under
|
||||
/// [`STAMP_DRIFT_EPS`] it is exact, and the map id and `ticket_type` are always exact.
|
||||
fn report_db_expectation(probe: &GmTicketProbe, when: &str, message: &str) {
|
||||
let [x, y, z] = probe.create_pos;
|
||||
info!(
|
||||
@@ -750,9 +638,8 @@ fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The match-or-timeout rule: a step accepts **any** answer after its baseline that matches,
|
||||
/// because unsolicited answers (a GM's `.ticket viewid`, vmangos's own post-delete push) share
|
||||
/// the stream with the solicited ones. It must never accept one from *before* the baseline.
|
||||
/// Unsolicited answers share the stream, so any match after the baseline counts, never one
|
||||
/// before it.
|
||||
#[test]
|
||||
fn a_step_matches_after_its_baseline_and_never_before_it() {
|
||||
let answers = vec![
|
||||
@@ -764,14 +651,12 @@ mod tests {
|
||||
assert_eq!(matching(&answers, 0, |(_, t)| t == "mine"), Some(1));
|
||||
// Baseline past the first match: the later identical answer is the one taken.
|
||||
assert_eq!(matching(&answers, 2, |(_, t)| t == "mine"), Some(3));
|
||||
// Nothing after the baseline matches — the caller's timeout is what turns this into a
|
||||
// FAIL, and it must not be papered over by an earlier answer.
|
||||
// Nothing after the baseline matches, and an earlier answer must not count.
|
||||
assert_eq!(matching(&answers, 4, |(_, t)| t == "mine"), None);
|
||||
assert_eq!(matching(&answers, 2, |(c, _)| *c == NO_TICKET), Some(2));
|
||||
}
|
||||
|
||||
/// The two texts within a run are distinct — step 5's assertion is "the text CHANGED to this",
|
||||
/// and it would be vacuous if the edit reused the create's string.
|
||||
/// Step 5 asserts the text changed, which needs the edit's text to differ from the create's.
|
||||
#[test]
|
||||
fn the_create_and_edit_texts_are_distinct() {
|
||||
assert_ne!(unique_text(""), unique_text(" edited"));
|
||||
|
||||
@@ -1,71 +1,33 @@
|
||||
//! The GameObject-questgiver live probe (`WOW_PROBE_GOQUEST=1`) — the instrument for *"wanted
|
||||
//! posters and quest objects are never status-queried"*, and for the answer that question turned
|
||||
//! out to have (decision 1872).
|
||||
//! The GameObject-questgiver live probe (`WOW_PROBE_GOQUEST=1`). The reference asks about a
|
||||
//! quest-giving GameObject as it does a creature: the sweep sends `CMSG_QUESTGIVER_STATUS_QUERY`
|
||||
//! for any GameObject with `GAMEOBJECT_FLAGS` bit 2 whose reaction toward us is `> 1`. It then
|
||||
//! refuses the answer: its handler `0x5dc9f0` resolves the guid with typemask 8, a GameObject's
|
||||
//! `0x21` misses bit 3, and the packet dies at `0x5dca2f`. Real 1.12 captures carry no GameObject
|
||||
//! status at all, but vmangos answers one (`TYPEMASK_CREATURE_OR_GAMEOBJECT`,
|
||||
//! `QuestHandler.cpp:40`), so without the refusal a wanted poster would wear a `!`. The probe tells
|
||||
//! "asked and refused" from "never asked" through [`QuestGiver::refused_for`], keyed by guid since
|
||||
//! one drain answers several quest objects.
|
||||
//!
|
||||
//! A quest-giving **GameObject** — a wanted poster, a half-eaten body, a suspicious barrel — is a
|
||||
//! questgiver on the wire exactly as a creature is, and the reference client asks about it: the
|
||||
//! sweep callback tests typemask bit 5 and sends `CMSG_QUESTGIVER_STATUS_QUERY` for any GameObject
|
||||
//! carrying `GAMEOBJECT_FLAGS` bit 2 whose reaction toward us is `> 1`. benilla asked about no
|
||||
//! GameObject at all, which is a real wire gap.
|
||||
//! Four readings:
|
||||
//!
|
||||
//! But **the answer is refused, and nothing is ever drawn**: the reference's handler `0x5dc9f0`
|
||||
//! resolves the GUID with typemask 8, a GameObject's `0x21` misses bit 3, and the packet dies at
|
||||
//! `0x5dca2f`. And the real 1.12 service never sent one anyway — zero GameObject GUIDs across the
|
||||
//! 1292 in the genuine sniff corpus. **vmangos does send one** (`TYPEMASK_CREATURE_OR_GAMEOBJECT`,
|
||||
//! `QuestHandler.cpp:41`), so on our server the refusal is load-bearing rather than theoretical:
|
||||
//! without it a wanted poster would wear a gold `!` the reference client leaves bare.
|
||||
//! 1. LOW: at level [`LOW_LEVEL`], below the quest's `MinLevel`, the server answers 1
|
||||
//! (`UNAVAILABLE`) and we refuse it.
|
||||
//! 2. HIGH: past `MinLevel` the answer changes to 5 (`AVAILABLE`), so it is a live query.
|
||||
//! 3. NO STATUS: `QuestGiver::status()` for the poster stays `None` every frame; the marker layer
|
||||
//! and the minimap dot both draw from it.
|
||||
//! 4. UNIT CONTROL: a creature questgiver in the same scene still gets and keeps its status.
|
||||
//!
|
||||
//! That makes this probe's job the three-way distinction a unit test cannot make at the real
|
||||
//! object: **the query went out**, **the answer came back**, and **we refused it** — where "never
|
||||
//! asked" and "asked and correctly refused" both read as an empty status map. The refusal readout
|
||||
//! ([`QuestGiver::refused_for`]) is what separates them, and an arriving status can only exist if
|
||||
//! our query did. It is keyed by guid on purpose: Goldshire has several quest objects in view and
|
||||
//! the server answers all of them in one drain, so a single last-writer slot reads as whichever
|
||||
//! object archetype order happened to put last (it read as a regression exactly once, on a run
|
||||
//! where nothing about the behaviour had changed).
|
||||
//! `WOW_PROBE_GOQUEST=<x>,<y>,<z>[,<map>]` aims it elsewhere; the default is the Goldshire wanted
|
||||
//! poster (`gameobject` 26843, template 68, type 2, flags 4, quest 176 with `MinLevel` 5 and
|
||||
//! `QuestLevel` 11), a single unpooled spawn, with questgiver creatures nearby for the control.
|
||||
//! Each window sets the level and bumps the re-ask epoch to force a sweep, the only way a
|
||||
//! GameObject guid reaches the wire; the starting level is restored on exit. It logs
|
||||
//! `PROBE_GOQUEST:` lines and exits; the switches are `docs/CONTRIBUTING.md`, "Running it
|
||||
//! unattended".
|
||||
//!
|
||||
//! Four readings, in order, and the last two are the controls that make it a regression test:
|
||||
//!
|
||||
//! 1. **LOW** — at level [`LOW_LEVEL`], below the quest's own `MinLevel`: the server must answer
|
||||
//! `1` (`UNAVAILABLE`, the grey `!` a *creature* would wear) and we must refuse it.
|
||||
//! 2. **HIGH** — after a ding past `MinLevel`: the answer must change to `5` (`AVAILABLE`). A probe
|
||||
//! that only ever saw one value could not tell a live query from a single stale packet.
|
||||
//! 3. **NO STATUS** — `QuestGiver::status()` for the poster stays `None` throughout, sampled every
|
||||
//! frame rather than at the windows. The marker layer and the minimap dot both draw from exactly
|
||||
//! that map, so an empty entry *is* "no `!`, no dot".
|
||||
//! 4. **THE UNIT CONTROL** — a creature questgiver in the same scene still gets and keeps its own
|
||||
//! status. This is the half that must not change, and it is what would catch a refusal written
|
||||
//! one bit too wide.
|
||||
//!
|
||||
//! ## The run recipe
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_NOSOUND=1 WOW_USER=probe4 WOW_PASS=pprobe4 WOW_CHAR=Probefour \
|
||||
//! WOW_PROBE_GOQUEST=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
|
||||
//! skill). `WOW_PROBE_GOQUEST=<x>,<y>,<z>[,<map>]` aims it elsewhere; the
|
||||
//! default is the **Goldshire wanted poster** — `gameobject.guid` 26843, template 68
|
||||
//! `Wanted Poster`, `type` 2 `GAMEOBJECT_TYPE_QUESTGIVER`, `flags` 4 `INTERACT_COND`, offering
|
||||
//! quest 176 `Wanted: "Hogger"` (`MinLevel` 5, `QuestLevel` 11). It is a single **unpooled** spawn
|
||||
//! with a 900 s respawn, so it is there every run — unlike the chest probe's first target, which
|
||||
//! was a spawn-pool member and read "missing" most of the time (decision 1471's trap). Goldshire
|
||||
//! also supplies the unit control (Marshal Dughan and the innkeeper are both questgiver-flagged),
|
||||
//! which is why both halves can be read at one spot.
|
||||
//!
|
||||
//! Each window forces its own sweep, because that is the *only* way a GameObject GUID reaches the
|
||||
//! wire: it sets the level (the reference's own `UNIT_FIELD_LEVEL` watch, and what makes the answer
|
||||
//! change) and then bumps the packet epoch, so the window sweeps even if the character already
|
||||
//! happened to be standing at the level the window wants. The starting level is restored on exit.
|
||||
//!
|
||||
//! **One trap the scan line will show you:** every probe character is permanently in GM mode
|
||||
//! (`characters.extra_flags` bit `0x1`), and vmangos ORs `target->IsGameMaster()` into a
|
||||
//! GameObject's `IsActivateToQuest` (`Object.cpp`) — so `GAMEOBJECT_DYN_FLAGS` reads `0x1` on
|
||||
//! every quest object regardless of whether the character could actually take the quest. The
|
||||
//! *dialog status* is computed without any GM term, so the windows below are unaffected; but do
|
||||
//! not read the dyn-flag as "this quest is available to me", and do not gate a query on it.
|
||||
//!
|
||||
//! Grep `PROBE_GOQUEST:` for the verdict; the probe self-exits when it lands.
|
||||
//! vmangos ORs `IsGameMaster()` into a GameObject's `IsActivateToQuest` (`Object.cpp:652`), so in
|
||||
//! GM mode `GAMEOBJECT_DYN_FLAGS` reads `0x1` on every quest object; the dialog status has no GM
|
||||
//! term, so the readings are unaffected.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -79,15 +41,15 @@ use crate::ui_quest::QuestGiver;
|
||||
/// The probe's default object: the `Wanted Poster` outside the Goldshire inn (`gameobject` 26843,
|
||||
/// template 68, quest 176). `[x, y, z, map]`.
|
||||
const POSTER_AT: [f32; 4] = [-9668.23, 683.39, 36.33, 0.0];
|
||||
/// `GAMEOBJECT_TYPE_QUESTGIVER` — the strategy type a wanted poster carries.
|
||||
/// `GAMEOBJECT_TYPE_QUESTGIVER`, the type a wanted poster carries.
|
||||
const GO_TYPE_QUESTGIVER: i32 = 2;
|
||||
/// `GAMEOBJECT_FLAGS` bit 2 — the reference's whole GameObject query gate (`0x5eb0f2 shr eax,0x2`).
|
||||
/// `GAMEOBJECT_FLAGS` bit 2, the reference's GameObject query gate (`0x5eb0f2`).
|
||||
const GO_FLAG_INTERACT_COND: u32 = 0x4;
|
||||
/// `UNIT_NPC_FLAGS` questgiver bit — how the unit control names itself.
|
||||
/// `UNIT_NPC_FLAGS` questgiver bit, how the unit control is found.
|
||||
const NPC_FLAG_QUESTGIVER: u32 = 0x2;
|
||||
/// The two levels the two windows are read at, chosen against quest 176's own `MinLevel` 5 and
|
||||
/// `QuestLevel` 11: below it the server answers `UNAVAILABLE`, above it `AVAILABLE` (and not
|
||||
/// `CHAT`, which would need `level > QuestLevel + Quests.LowLevelHideDiff`).
|
||||
/// The two windows' levels, against quest 176's `MinLevel` 5 and `QuestLevel` 11: below it the
|
||||
/// server answers `UNAVAILABLE`, above it `AVAILABLE` (not `CHAT`, which needs
|
||||
/// `level > QuestLevel + Quests.LowLevelHideDiff`).
|
||||
const LOW_LEVEL: u32 = 2;
|
||||
const HIGH_LEVEL: u32 = 8;
|
||||
/// `DialogStatus` ids the verdict is written in (the keys of the status map `0x80c454`).
|
||||
@@ -96,8 +58,7 @@ const STATUS_AVAILABLE: u32 = 5;
|
||||
|
||||
const SETTLE_SECS: f64 = 5.0;
|
||||
const SCAN_TIMEOUT_SECS: f64 = 25.0;
|
||||
/// How long a window waits for the refused answer. The query goes out on the sweep the window
|
||||
/// itself forces; nothing arriving in this long means it was never sent.
|
||||
/// How long a window waits for the refused answer; nothing arriving means it was never sent.
|
||||
const STATUS_TIMEOUT_SECS: f64 = 15.0;
|
||||
/// How long a `.levelup` is given to come back down the wire as a `UNIT_FIELD_LEVEL` change.
|
||||
const LEVEL_TIMEOUT_SECS: f64 = 15.0;
|
||||
@@ -118,9 +79,9 @@ struct GoQuestProbe {
|
||||
phase: Phase,
|
||||
/// The poster's guid, once the scan finds it.
|
||||
poster: Option<u64>,
|
||||
/// The unit control's guid — a questgiver-flagged creature in the same scene.
|
||||
/// The unit control's guid, a questgiver-flagged creature in the same scene.
|
||||
control: Option<u64>,
|
||||
/// The level the character was at when the probe started — restored before it exits.
|
||||
/// The level the character was at when the probe started, restored before it exits.
|
||||
start_level: Option<u32>,
|
||||
/// The refused status read in each window.
|
||||
low: Option<u32>,
|
||||
@@ -158,8 +119,8 @@ enum Phase {
|
||||
Done,
|
||||
}
|
||||
|
||||
/// Where the probe is aimed: `WOW_PROBE_GOQUEST=<x>,<y>,<z>[,<map>]`, else [`POSTER_AT`]. Anything
|
||||
/// unparseable falls back to the default rather than failing the run — the common value is `1`.
|
||||
/// Where the probe is aimed: `WOW_PROBE_GOQUEST=<x>,<y>,<z>[,<map>]`, else [`POSTER_AT`] (also
|
||||
/// for `1`).
|
||||
fn target() -> [f32; 4] {
|
||||
let Ok(raw) = std::env::var("WOW_PROBE_GOQUEST") else {
|
||||
return POSTER_AT;
|
||||
@@ -175,8 +136,8 @@ fn target() -> [f32; 4] {
|
||||
}
|
||||
}
|
||||
|
||||
/// `.levelup <delta>` on the probe's own character. Nothing is ever selected by this probe, so the
|
||||
/// command's creature branch (`GetSelectedCreature`) can't fire.
|
||||
/// `.levelup <delta>` on the probe's own character; nothing is selected, so the command's
|
||||
/// creature branch (`GetSelectedCreature`) cannot fire.
|
||||
fn level_to(net: &NetCommands, from: u32, to: u32) {
|
||||
let delta = i64::from(to) - i64::from(from);
|
||||
if delta == 0 {
|
||||
@@ -228,8 +189,7 @@ fn goquest_probe(
|
||||
return; // our own descriptor hasn't landed
|
||||
};
|
||||
let now = time.elapsed_secs_f64();
|
||||
// Sampled every frame, not just at the windows: a status that flashed on for one frame and was
|
||||
// pruned would still have been a rendered `!`, and the claim is that none is ever stored.
|
||||
// Sampled every frame: a status stored for one frame is still a rendered `!`.
|
||||
if probe.poster.is_some_and(|p| quest.status(p).is_some()) {
|
||||
probe.poster_ever_stored = true;
|
||||
}
|
||||
@@ -318,10 +278,8 @@ fn goquest_probe(
|
||||
}
|
||||
Phase::Level { to, since } => {
|
||||
if level == to {
|
||||
// Force the sweep this window depends on. A GameObject GUID reaches the wire ONLY
|
||||
// from a sweep (`0x5eb159`, `0x5eb456`), and the level change above is one — but it
|
||||
// is a no-op when the character already stood at this level, so the epoch bump
|
||||
// makes the window's sweep unconditional.
|
||||
// A GameObject guid reaches the wire only from a sweep (`0x5eb159`, `0x5eb456`);
|
||||
// the epoch bump forces one even if the level did not change.
|
||||
quest.bump_reask();
|
||||
info!("PROBE_GOQUEST: at level {to}, swept — reading the window");
|
||||
probe.phase = Phase::Read {
|
||||
@@ -337,8 +295,7 @@ fn goquest_probe(
|
||||
Phase::Read { level: at, since } => {
|
||||
let count = quest.refused_count();
|
||||
let mine = probe.poster.and_then(|p| quest.refused_for(p));
|
||||
// The HIGH window must see a *different* answer than LOW, or one stale refusal would
|
||||
// satisfy both.
|
||||
// HIGH must see a different answer than LOW, or one stale refusal would satisfy both.
|
||||
let settled = if at == LOW_LEVEL {
|
||||
mine
|
||||
} else {
|
||||
@@ -420,9 +377,8 @@ fn goquest_probe(
|
||||
probe.high,
|
||||
quest.refused_count(),
|
||||
);
|
||||
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
|
||||
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
|
||||
// client holding the probe account.
|
||||
// `ProbeExitPlugin::fire_probe_exit`'s pattern: `AppExit` plus a hard backstop, so a
|
||||
// teardown hang cannot leave a client holding the probe account.
|
||||
exit.write(AppExit::Success);
|
||||
std::thread::spawn(|| {
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
//! The guard-directions live probe (`WOW_PROBE=guardpoi`) — the end-to-end instrument for
|
||||
//! [`crate::poi_marker`], inert without the env: once in-world, GM-hop to a Stormwind City Guard,
|
||||
//! open his gossip on the real wire (`CMSG_GOSSIP_HELLO` → `SMSG_GOSSIP_MESSAGE`), click the
|
||||
//! directions option, and report what `SMSG_GOSSIP_POI` actually put on the map.
|
||||
//! The guard-directions live probe (`WOW_PROBE=guardpoi`) for [`crate::poi_marker`]: hops to a
|
||||
//! Stormwind City Guard, opens his gossip (`CMSG_GOSSIP_HELLO`, `SMSG_GOSSIP_MESSAGE`), clicks the
|
||||
//! "Weapons Trainer" option and checks the `SMSG_GOSSIP_POI` marker field by field against
|
||||
//! `points_of_interest` row 808, then logs which minimap draw it gets from here.
|
||||
//!
|
||||
//! The verdict is machine-checked against the server's own row, so a wrong parse cannot pass: the
|
||||
//! guard's "Weapons Trainer" option carries `action_poi_id = 808`, whose `points_of_interest` row
|
||||
//! is `("Woo Ping", -8796.2, 613.098, icon 6, flags 99)`. The probe asserts the name, the position,
|
||||
//! the icon and the flags, then classifies the marker exactly as the minimap's landmark pass would
|
||||
//! — distance, view radius, and which of the two draws it takes — so the run says *what would be
|
||||
//! on screen*, not merely that a packet arrived.
|
||||
//!
|
||||
//! Non-combat. Pair with the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill), and `WOW_NOSOUND=1` when
|
||||
//! it runs unattended. One `timeout`'d run plus a grep for `PROBE guardpoi:` is the whole harness.
|
||||
//! Non-combat; grep `PROBE guardpoi:`. The switches are `docs/CONTRIBUTING.md`, "Running it
|
||||
//! unattended".
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -21,21 +14,19 @@ use crate::player::Player;
|
||||
use crate::poi_marker::PoiMarker;
|
||||
use crate::ui_gossip::GossipState;
|
||||
|
||||
/// A Stormwind City Guard's spawn (vmangos `creature` guid 79664, entry 68, map 0) — the `.go xyz`
|
||||
/// target. The guard himself is then found in the streamed world by his gossip flag, never by a
|
||||
/// hardcoded guid.
|
||||
/// A Stormwind City Guard's spawn (vmangos `creature` guid 79664, entry 68, map 0); the guard is
|
||||
/// then found in the streamed world by his gossip flag.
|
||||
const GUARD_AT: [f32; 3] = [-8854.14, 541.299, 105.984];
|
||||
/// `UNIT_NPC_FLAG_GOSSIP` (bit 0) — every direction-giving guard carries it.
|
||||
/// `UNIT_NPC_FLAG_GOSSIP` (bit 0).
|
||||
const NPC_FLAG_GOSSIP: u32 = 0x1;
|
||||
/// The option we click, by its label. Menu 435 lists it 9th ("Weapons Trainer", `action_poi_id`
|
||||
/// 808); matching on the text rather than the index keeps the probe honest if the row order moves.
|
||||
/// The option clicked, matched by label: gossip menu 435's "Weapons Trainer", `action_poi_id` 808.
|
||||
const OPTION_LABEL: &str = "Weapons Trainer";
|
||||
/// What `points_of_interest` row 808 says the answer must be.
|
||||
/// `points_of_interest` row 808.
|
||||
const EXPECT_NAME: &str = "Woo Ping";
|
||||
const EXPECT_POS: [f32; 2] = [-8796.2, 613.098];
|
||||
const EXPECT_ICON: u32 = 6; // ICON_POI_REDFLAG
|
||||
const EXPECT_FLAGS: u32 = 99; // candidate | draw-the-in-range-icon
|
||||
/// The in/out-of-range split the minimap's landmark pass applies (`0x811730`, VERIFIED).
|
||||
const EXPECT_FLAGS: u32 = 99; // bits 0 (a candidate) and 1 (draw the in-range icon)
|
||||
/// The in/out-of-range split of the minimap's landmark pass (`0x811730`).
|
||||
const BLIP_EDGE_RATIO: f32 = 0.8;
|
||||
|
||||
pub(crate) struct ProbeGuardPoiPlugin;
|
||||
@@ -52,8 +43,7 @@ struct GuardPoiProbe {
|
||||
phase: Phase,
|
||||
}
|
||||
|
||||
/// `Wait` → (GM hop sent) `Hopped` → (guard found, hello sent) `Greeted` → (option clicked)
|
||||
/// `Asked` → verdict → `Done`.
|
||||
/// `Wait`, `Hopped` (hop sent), `Greeted` (hello sent), `Asked` (option clicked), `Done`.
|
||||
#[derive(Default, PartialEq)]
|
||||
enum Phase {
|
||||
#[default]
|
||||
@@ -71,7 +61,6 @@ enum Phase {
|
||||
Done,
|
||||
}
|
||||
|
||||
// One Bevy system's full input set (the taxi-probe shape).
|
||||
fn guard_poi_probe(
|
||||
time: ProbeClock,
|
||||
mut probe: ResMut<GuardPoiProbe>,
|
||||
@@ -99,7 +88,7 @@ fn guard_poi_probe(
|
||||
}
|
||||
Phase::Hopped { sent_at } => {
|
||||
if now - sent_at < 3.0 {
|
||||
return; // post-teleport settle: let the guard stream in
|
||||
return; // let the guard stream in
|
||||
}
|
||||
let me = player.pos;
|
||||
let guard = units.iter().find(|(_, net_e, store, tf)| {
|
||||
@@ -161,7 +150,7 @@ fn guard_poi_probe(
|
||||
}
|
||||
return;
|
||||
};
|
||||
// Every field against the server's own row — a mis-ordered parse cannot survive this.
|
||||
// Every field against the server's row, so a mis-ordered parse fails.
|
||||
let mut wrong: Vec<String> = Vec::new();
|
||||
if poi.name != EXPECT_NAME {
|
||||
wrong.push(format!("name {:?} != {EXPECT_NAME:?}", poi.name));
|
||||
@@ -181,8 +170,7 @@ fn guard_poi_probe(
|
||||
wrong.push(format!("flags {} != {EXPECT_FLAGS}", poi.flags));
|
||||
}
|
||||
|
||||
// What the minimap's landmark pass would do with it, from here (the default zoom's
|
||||
// 133.3-yd view radius — the probe never opens the zoom dial, so this IS the live one).
|
||||
// The minimap's landmark pass from here, at the default zoom's 133.3-yd view radius.
|
||||
let w = benilla_assets::coords::bevy_to_wow(player.pos);
|
||||
let d = ((poi.pos[0] - w[0]).powi(2) + (poi.pos[1] - w[1]).powi(2)).sqrt();
|
||||
let radius = 133.3;
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
//! `WOW_PROBE_LOOK` — the scripted **mouse-turn**, the one player action the probe harness could not
|
||||
//! synthesize (decision 0621).
|
||||
//! `WOW_PROBE_LOOK`: the scripted mouse-turn, which drives the per-frame facing stream a key press
|
||||
//! cannot.
|
||||
//!
|
||||
//! `WOW_PROBE_KEY` presses keys, which is enough for the transition opcodes; but since decision 0617
|
||||
//! the busiest thing on our wire by far is the facing stream a *mouse* turn produces (~one packet per
|
||||
//! frame). A defect that only shows up under that density — and the runaway hunt looks like exactly
|
||||
//! that — could not be reproduced headlessly at all: an agent had to ask the director to drive, every
|
||||
//! iteration. That is the tooling gap this closes.
|
||||
//!
|
||||
//! Format: `WOW_PROBE_LOOK="<deg_per_sec>@<start_s>:<duration_s>[;…]"`, e.g. `"90@20:6"` — turn the
|
||||
//! avatar's aim 90°/s for six seconds starting twenty seconds in. Negative rates turn the other way.
|
||||
//! It writes [`Player::face_yaw`] directly rather than faking `AccumulatedMouseMotion` + a held right
|
||||
//! button: the camera's look session gates on the OS cursor being inside the viewport
|
||||
//! (`cursor_in_viewport`), which is unreliable for an unfocused probe window, and the camera plumbing
|
||||
//! is not what is under test. From `face_yaw` onward this is the identical path a real mouse-turn
|
||||
//! takes — the same value `stream_self_movement` diffs to decide on a `MSG_MOVE_SET_FACING`.
|
||||
//! Format: `WOW_PROBE_LOOK="<deg_per_sec>@<start_s>:<duration_s>[;...]"`, e.g. `"90@20:6"` turns
|
||||
//! the aim 90 degrees/s for 6 s from 20 s in; a negative rate turns the other way. It writes
|
||||
//! [`Player::face_yaw`] directly, since the look session gates on the OS cursor being inside the
|
||||
//! viewport; from there it is the path a mouse-turn takes to `MSG_MOVE_SET_FACING`.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -22,11 +13,8 @@ use benilla_world::schedule::WorldStage;
|
||||
|
||||
use super::ProbeClock;
|
||||
|
||||
/// One scripted turn: `rate` (rad/s), when it starts, and how long it runs — **wall-clock seconds**
|
||||
/// ([`ProbeClock`], decision 0789). Both halves want real time here, for one reason: `90°/s for 6 s`
|
||||
/// has to produce 540° of facing stream whatever the frame rate did, and the stream it feeds is
|
||||
/// itself paced on the real clock (0615). On the virtual clock the schedule would drift *and* every
|
||||
/// hitch would silently under-rotate the turn, since its delta is clamped to 250 ms.
|
||||
/// One scripted turn: `rate` (rad/s) over a window in wall-clock seconds ([`ProbeClock`]); the
|
||||
/// virtual clock clamps its delta to 250 ms, which would under-rotate across a hitch.
|
||||
struct Turn {
|
||||
rate: f32,
|
||||
at: f32,
|
||||
@@ -38,8 +26,7 @@ pub(crate) struct ProbeLook {
|
||||
turns: Vec<Turn>,
|
||||
}
|
||||
|
||||
/// Parse the env script. Absent or unparseable entries yield no turns (with a warning), so the probe
|
||||
/// is inert unless asked for.
|
||||
/// Parses `WOW_PROBE_LOOK`; unparseable entries are skipped with a warning.
|
||||
pub(crate) fn from_env() -> Option<ProbeLook> {
|
||||
let spec = std::env::var("WOW_PROBE_LOOK").ok()?;
|
||||
let turns: Vec<Turn> = spec
|
||||
@@ -69,8 +56,8 @@ pub(crate) fn from_env() -> Option<ProbeLook> {
|
||||
(!turns.is_empty()).then_some(ProbeLook { turns })
|
||||
}
|
||||
|
||||
/// Rotate the aim for every turn whose window covers this frame. Runs in `WorldStage::Input`
|
||||
/// **before** the controller, so the frame that sees the new `face_yaw` is the frame that streams it.
|
||||
/// Rotates the aim for every turn whose window covers this frame, before the controller, so the
|
||||
/// same frame streams it.
|
||||
pub(crate) fn drive_probe_look(
|
||||
probe: Res<ProbeLook>,
|
||||
time: ProbeClock,
|
||||
@@ -93,11 +80,8 @@ pub(crate) fn drive_probe_look(
|
||||
}
|
||||
}
|
||||
|
||||
/// `WOW_PROBE_LOOK`'s registration. Added by the probe fleet when the variable is set, and inert
|
||||
/// (no resource, no systems) if the script parses to nothing — the same shape every other probe in
|
||||
/// the fleet has. It orders itself **before** [`crate::player::control`], which is what makes the
|
||||
/// frame that sees the new `face_yaw` the frame that streams it; the controller knows nothing
|
||||
/// about it (decision 1174).
|
||||
/// Registers `WOW_PROBE_LOOK` before [`crate::player::control`]; inert if the script parses to
|
||||
/// nothing.
|
||||
pub(crate) struct ProbeLookPlugin;
|
||||
|
||||
impl Plugin for ProbeLookPlugin {
|
||||
|
||||
@@ -1,48 +1,16 @@
|
||||
//! The mail-arc live probe (`WOW_PROBE_MAIL=1`) — decision 0544/0548's end-to-end instrument:
|
||||
//! GM-mail the probe's OWN character, walk to the Goldshire mailbox, open it on the real wire,
|
||||
//! and drive the inbox/take/send/delete surface through the live Lua VM exactly as a click would,
|
||||
//! printing a `PROBE_MAIL:` trace line with a PASS/FAIL/SKIP verdict per step and a final
|
||||
//! `PROBE_MAIL: DONE pass=<n> fail=<m>` summary. Modeled closely on [`super::probe_taxi`] (same
|
||||
//! phase-machine shape, same trace style, same self-terminating exit).
|
||||
//! The mail live probe (`WOW_PROBE_MAIL=1`): GM-mails the probe's own character a letter, money
|
||||
//! and an item, walks to the Goldshire mailbox, opens it on the real wire and drives the inbox
|
||||
//! through the live Lua VM as clicks would: read, copy and read the letter, take the money and the
|
||||
//! item, two refused sends, delete. One `PROBE_MAIL:` line per step with PASS/FAIL/SKIP, then
|
||||
//! `PROBE_MAIL: DONE pass=<n> fail=<m>`, and it exits. `WOW_CHAR` doubles as the send target. The
|
||||
//! switches are `docs/CONTRIBUTING.md`, "Running it unattended".
|
||||
//!
|
||||
//! ## The GM `.send` syntax (verified against the vmangos source, `Chat/Chat.cpp` +
|
||||
//! `Commands/MiscCommands.cpp`)
|
||||
//!
|
||||
//! `sendCommandTable[]` (Chat.cpp ~l.931): `.send mail <name> "subject" "text"` is
|
||||
//! `SEC_MODERATOR` (1); `.send items <name> "subject" "text" item[:count]…` and
|
||||
//! `.send money <name> "subject" "text" <copper>` are both `SEC_ADMINISTRATOR` (6)
|
||||
//! (`AccountTypes`, `shared/Common.h` l.138-145: `SEC_MODERATOR=1`, `SEC_GAMEMASTER=3`,
|
||||
//! `SEC_ADMINISTRATOR=6`). **Probe accounts are gmlevel 6**, so all three land. They did *not*
|
||||
//! until 2026-07-26 — 0645 recorded the raise but never applied it, and the accounts sat at 3
|
||||
//! (0651). While they did, steps (c)/(d) below degraded to SKIP against a permission floor that
|
||||
//! was real, and that SKIP hid a defect in the probe itself: it re-found the money/item row by
|
||||
//! predicate each tick, so a *successful* take — which zeroes the money and clears the attachment
|
||||
//! — made the row stop matching and read as "no row ever appeared". Both steps now remember the
|
||||
//! id they took from, and a missing row is a FAIL, because at gmlevel 6 there is no longer an
|
||||
//! innocent reason for one. All three GM
|
||||
//! sends build their `MailDraft` and call `MailDraft::SendMailTo` DIRECTLY
|
||||
//! (`HandleSendMailCommand`/`HandleSendItemsCommand`/`HandleSendMoneyCommand`, `MiscCommands.cpp`
|
||||
//! ~l.1012-1145) — never through `WorldSession::HandleSendMail` (`MailHandler.cpp`), whose
|
||||
//! `MAIL_ERR_CANNOT_SEND_TO_SELF` gate (l.204) lives only in the CMSG-driven path. So a GM
|
||||
//! `.send mail <self>` bypasses the self-check entirely and lands in the sender's own mailbox —
|
||||
//! confirmed by reading `MailDraft::SendMailTo` (`Mail/Mail.cpp` l.300+) end to end: no self
|
||||
//! comparison anywhere in it. The GM path's `deliver_delay` also defaults to `0`
|
||||
//! (`Mail.h` l.254; the command handlers pass none) — unlike the player CMSG path's
|
||||
//! `MailDeliveryDelay` (decision 0544's 1 h note), so a successful GM send (permission allowing)
|
||||
//! is in the inbox on the very next `CMSG_GET_MAIL_LIST`, no extra wait needed. Sender identity:
|
||||
//! `MailSender(MAIL_NORMAL, <the GM's own char guid counter>, MAIL_STATIONERY_GM)` — a real player
|
||||
//! sender guid, GM stationery (61, `ui_mail.rs`'s `STATIONERY_GM`).
|
||||
//!
|
||||
//! ## The run recipe
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_DATA=<vanilla Data dir> WOW_USER=probe2 WOW_PASS=pprobe2 WOW_CHAR=Probetwo \
|
||||
//! WOW_PROBE_MAIL=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
|
||||
//! skill). `WOW_CHAR` doubles as the probe's own mail-send target — read
|
||||
//! once at world-enter, never hardcoded. An outer `timeout` + grep on `PROBE_MAIL:` is the whole
|
||||
//! harness; the probe self-exits (the [`super::probes::ProbeExitPlugin`] pattern) once DONE.
|
||||
//! `.send mail <name> "subject" "text"` needs `SEC_MODERATOR`; `.send items … item[:count]` and
|
||||
//! `.send money … <copper>` need `SEC_ADMINISTRATOR` (vmangos `Chat.cpp:935-938`). Each builds a
|
||||
//! `MailDraft` and calls `SendMailTo` directly (`MiscCommands.cpp:1012-1166`), never through
|
||||
//! `HandleSendMail`, whose `MAIL_ERR_CANNOT_SEND_TO_SELF` check (`MailHandler.cpp:204`) is on the
|
||||
//! CMSG path only. So a GM send to oneself lands, with no delivery delay (`Mail.h:254`), from a
|
||||
//! `MAIL_NORMAL` sender carrying the GM's own guid, on GM stationery (61).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -55,33 +23,29 @@ use crate::net::{ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectSt
|
||||
use crate::player::Player;
|
||||
use crate::ui_mail::MailOpen;
|
||||
|
||||
/// The Goldshire mailbox (vmangos `gameobject` guid 2978, entry 142075, map 0) — live-DB verified
|
||||
/// position.
|
||||
/// The Goldshire mailbox (vmangos `gameobject` guid 2978, entry 142075, map 0).
|
||||
const MAILBOX_AT: [f32; 3] = [-9455.99, 45.82, 56.44];
|
||||
/// `GAMEOBJECT_TYPE_MAILBOX` (decision 0544/0548) — the GO strategy type this probe scans for.
|
||||
/// `GAMEOBJECT_TYPE_MAILBOX`, the type this probe scans for.
|
||||
const GO_TYPE_MAILBOX: i32 = 19;
|
||||
/// The mailbox's server-side interaction check is 5 yd (`CheckMailBox`, decision 0544); scan
|
||||
/// generously wide so a slightly-off `.go` landing still finds it.
|
||||
/// Wider than the server's 5 yd `CheckMailBox`, so a slightly-off `.go` landing still finds it.
|
||||
const MAILBOX_SCAN_RANGE: f32 = 10.0;
|
||||
/// The probe's GM-mailed item: Linen Cloth ×5 (the task's fixture entry).
|
||||
/// The GM-mailed item: Linen Cloth x5.
|
||||
const ITEM_ENTRY: u32 = 2589;
|
||||
const ITEM_COUNT: u32 = 5;
|
||||
/// The probe's GM-mailed money, in copper (~1234c, the task's fixture amount).
|
||||
/// The GM-mailed money, in copper.
|
||||
const MONEY_COPPER: u32 = 1234;
|
||||
|
||||
/// `checked` mask bit READ (`0x1`, vmangos `Mail.h`, decision 0544) — redeclared locally (the
|
||||
/// `ui_mail` copy is private to that module) purely for this trace's printed flags.
|
||||
/// `checked` bit READ (`0x1`, vmangos `Mail.h`), for the trace's printed flags.
|
||||
const CHECKED_READ: u32 = 0x1;
|
||||
/// `checked` mask bit COPIED (`0x4`) — the wire's `textCreated`: step (b2) asserts the
|
||||
/// `CMSG_MAIL_CREATE_TEXT_ITEM` round-trip stamps it.
|
||||
/// `checked` bit COPIED (`0x4`), the wire's `textCreated`: step (b2) asserts the
|
||||
/// `CMSG_MAIL_CREATE_TEXT_ITEM` round trip sets it.
|
||||
const CHECKED_COPIED: u32 = 0x4;
|
||||
|
||||
const SETTLE_SECS: f64 = 3.0;
|
||||
const SCAN_TIMEOUT_SECS: f64 = 15.0;
|
||||
const LIST_TIMEOUT_SECS: f64 = 15.0;
|
||||
const BODY_TIMEOUT_SECS: f64 = 10.0;
|
||||
/// How long a GM-sent row is given to show up before the dependent step FAILs — generous, but
|
||||
/// bounded (never hangs).
|
||||
/// How long a GM-sent row is given to show up before the dependent step FAILs.
|
||||
const ROW_GRACE_SECS: f64 = 5.0;
|
||||
const ACTION_TIMEOUT_SECS: f64 = 8.0;
|
||||
|
||||
@@ -94,29 +58,24 @@ impl Plugin for ProbeMailPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe's phase machine + the identities it discovers along the way (kept resource-level,
|
||||
/// not per-variant, since several later phases re-resolve the same row by its stable
|
||||
/// `message_id` — list order can shift after a take/delete re-sync).
|
||||
/// The probe's phase machine plus the identities it finds; rows are kept by `message_id`, since
|
||||
/// list order shifts after a take or delete.
|
||||
#[derive(Resource, Default)]
|
||||
struct MailProbe {
|
||||
phase: Phase,
|
||||
/// `WOW_CHAR` — the probe's own name, its GM-mail target and its self-send target. Read once
|
||||
/// at [`Phase::Wait`] (the "read it from the same place login does" instruction — `net/io.rs`
|
||||
/// reads the identical env var).
|
||||
/// `WOW_CHAR`: the probe's own name, its GM-mail and self-send target, read once at
|
||||
/// [`Phase::Wait`].
|
||||
char_name: String,
|
||||
/// The plain letter's `message_id`, once found (stable across resyncs; row order isn't).
|
||||
/// The plain letter's `message_id`, once found.
|
||||
letter_id: Option<u32>,
|
||||
passes: u32,
|
||||
fails: u32,
|
||||
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
|
||||
/// Latched once [`Phase::Done`] has fired its exit.
|
||||
exited: bool,
|
||||
}
|
||||
|
||||
/// Every field is `Copy` (f64/bool/i64) — the phase is snapshotted out of the resource each tick
|
||||
/// ([`mail_probe`]'s `let phase = probe.phase;`) so the match arms are free to mutate `probe`
|
||||
/// (counters, `letter_id`, `char_name`) without fighting the borrow checker over `probe.phase`
|
||||
/// itself; an arm that wants to "keep waiting" simply never writes `probe.phase`, leaving the
|
||||
/// pre-match value in place.
|
||||
/// Copied out of the resource each tick so the arms can mutate `probe`; an arm that keeps
|
||||
/// waiting leaves `probe.phase` alone.
|
||||
#[derive(Default, Clone, Copy, PartialEq)]
|
||||
enum Phase {
|
||||
#[default]
|
||||
@@ -125,27 +84,25 @@ enum Phase {
|
||||
Settling {
|
||||
sent_at: f64,
|
||||
},
|
||||
/// Mailbox clicked ([`MailOpen::click`], decision 0544/0548 — no packet); waiting for the
|
||||
/// first `SMSG_MAIL_LIST_RESULT` (step a).
|
||||
/// Mailbox clicked ([`MailOpen::click`], a local open with no packet); waiting for the first
|
||||
/// `SMSG_MAIL_LIST_RESULT` (step a).
|
||||
WaitList {
|
||||
clicked_at: f64,
|
||||
},
|
||||
/// The list landed — `GetInboxText` the plain letter (mark-read + ask-once body, step b).
|
||||
/// The list landed: `GetInboxText` the plain letter (mark-read and ask-once body, step b).
|
||||
OpenLetter,
|
||||
/// Waiting for the letter's `CMSG_ITEM_TEXT_QUERY` reply to land in the body cache.
|
||||
WaitBody {
|
||||
since: f64,
|
||||
},
|
||||
/// `TakeInboxTextItem` on the opened letter — the letter button's permanent-copy verb
|
||||
/// `TakeInboxTextItem` on the opened letter, the permanent-copy verb
|
||||
/// (`CMSG_MAIL_CREATE_TEXT_ITEM`, step b2): PASS when the re-synced row carries COPIED.
|
||||
CopyLetter {
|
||||
since: f64,
|
||||
sent: bool,
|
||||
},
|
||||
/// Read the permanent copy (step b3): find the Plain Letter (8383) in the bags via the
|
||||
/// container Lua, right-click it through the real `UseContainerItem` route, PASS when the
|
||||
/// reader window paints the GM letter's body — then destroy the copy (else bags accumulate
|
||||
/// one per run).
|
||||
/// Read the permanent copy (step b3): find the Plain Letter (item 8383) in the bags, click it
|
||||
/// through `UseContainerItem`, PASS when the reader shows the body, then destroy the copy.
|
||||
ReadLetter {
|
||||
since: f64,
|
||||
/// The letter's `(lua bag, 1-based slot)` once found and clicked.
|
||||
@@ -154,26 +111,22 @@ enum Phase {
|
||||
/// `TakeInboxMoney` on the money row (step c).
|
||||
TakeMoney {
|
||||
since: f64,
|
||||
/// The message id we sent the take for, once sent. It is remembered rather than re-found
|
||||
/// each tick because **taking is what makes the row stop matching**: a successful
|
||||
/// `TakeInboxMoney` zeroes `money` (or drops the row), so a predicate re-search returns
|
||||
/// `None` on exactly the ticks that prove success. That bug reported every successful take
|
||||
/// as "no money row appeared" and hid behind the old gmlevel-3 floor, where the row really
|
||||
/// never arrived (0651).
|
||||
/// The message id the take was sent for, kept because a successful take zeroes `money`
|
||||
/// or drops the row, so re-searching would read success as absence.
|
||||
taken: Option<u32>,
|
||||
},
|
||||
/// `TakeInboxItem` on the item row (step d). Same remembered-id reason as [`Self::TakeMoney`].
|
||||
/// `TakeInboxItem` on the item row (step d); the taken id is kept as in [`Self::TakeMoney`].
|
||||
TakeItem {
|
||||
since: f64,
|
||||
taken: Option<u32>,
|
||||
},
|
||||
/// `SendMail` to the probe's own name — expects `CANNOT_SEND_TO_SELF` (step e).
|
||||
/// `SendMail` to the probe's own name, expecting `CANNOT_SEND_TO_SELF` (step e).
|
||||
SendSelf {
|
||||
since: f64,
|
||||
sent: bool,
|
||||
baseline: i64,
|
||||
},
|
||||
/// `SendMail` to a name that doesn't exist — expects `RECIPIENT_NOT_FOUND` (step f).
|
||||
/// `SendMail` to a name that does not exist, expecting `RECIPIENT_NOT_FOUND` (step f).
|
||||
SendBad {
|
||||
since: f64,
|
||||
sent: bool,
|
||||
@@ -187,14 +140,12 @@ enum Phase {
|
||||
Done,
|
||||
}
|
||||
|
||||
/// Find the first row matching `pred`, by stable `message_id` (never a position — resyncs shift
|
||||
/// rows around).
|
||||
/// The first row matching `pred`, by its stable `message_id`.
|
||||
fn find_by(mail: &MailOpen, pred: impl Fn(&MailListEntry) -> bool) -> Option<u32> {
|
||||
mail.mails.iter().find(|e| pred(e)).map(|e| e.message_id)
|
||||
}
|
||||
|
||||
/// The 1-based display index a `message_id` currently sits at, or `None` if it's gone (taken/
|
||||
/// deleted/expired-and-purged).
|
||||
/// The 1-based display index a `message_id` currently sits at, `None` once it is gone.
|
||||
fn index_of(mail: &MailOpen, mail_id: u32) -> Option<u32> {
|
||||
mail.mails
|
||||
.iter()
|
||||
@@ -206,8 +157,7 @@ fn entry_of(mail: &MailOpen, mail_id: u32) -> Option<&MailListEntry> {
|
||||
mail.mails.iter().find(|e| e.message_id == mail_id)
|
||||
}
|
||||
|
||||
/// Read the Lua-side `ProbeMailEvents` log length (the `UI_ERROR_MESSAGE` hook) — `0` on any eval
|
||||
/// hiccup (treated as "nothing observed yet", never a panic).
|
||||
/// The Lua-side `ProbeMailEvents` log length (the `UI_ERROR_MESSAGE` hook); 0 on an eval error.
|
||||
fn events_len(script: &UiScript) -> i64 {
|
||||
script
|
||||
.eval::<i64>("return table.getn(ProbeMailEvents or {})")
|
||||
@@ -236,11 +186,10 @@ fn mail_probe(
|
||||
return; // not in-world yet
|
||||
}
|
||||
let Some(script) = script else {
|
||||
return; // no UI VM this build (headless net-only) — nothing this probe can drive
|
||||
return; // no UI VM in this build, so nothing to drive
|
||||
};
|
||||
let now = time.elapsed_secs_f64();
|
||||
// A cheap `Copy` snapshot (see [`Phase`]'s doc) — frees every arm below to mutate `probe`
|
||||
// freely and to leave `probe.phase` untouched when it just wants to keep waiting.
|
||||
// A `Copy` snapshot, so the arms can mutate `probe` (see `Phase`).
|
||||
let phase = probe.phase;
|
||||
|
||||
match phase {
|
||||
@@ -252,10 +201,8 @@ fn mail_probe(
|
||||
probe.phase = Phase::Done;
|
||||
return;
|
||||
}
|
||||
// The UI_ERROR_MESSAGE hook (steps e/f's observation channel) — a hidden frame that
|
||||
// logs `arg1` into a Lua table this probe polls, the same "reach out of the VM"
|
||||
// pattern as `ProbeLuaPlugin`'s `ProbeLog`, but as a readable log instead of a
|
||||
// one-shot info! line (we need to distinguish two different error texts in order).
|
||||
// The `UI_ERROR_MESSAGE` hook for steps e and f: a hidden frame logs `arg1` into a Lua
|
||||
// table the probe polls, so the two error texts are told apart in order.
|
||||
if let Err(e) = script.run(
|
||||
r#"
|
||||
if not ProbeMailHooked then
|
||||
@@ -273,8 +220,7 @@ fn mail_probe(
|
||||
}
|
||||
let name = &probe.char_name;
|
||||
info!("PROBE_MAIL: GM-mailing {name} (plain letter, money, item) then heading to the Goldshire mailbox");
|
||||
// Three GM sends (verified syntax above) + the teleport, one chat burst (the
|
||||
// `ProbeChatPlugin`/`probe_taxi` idiom: GM dot-commands ride as plain Say lines).
|
||||
// The three GM sends and the teleport, as plain Say lines.
|
||||
let [x, y, z] = MAILBOX_AT;
|
||||
for text in [
|
||||
format!(".send mail {name} \"probe letter\" \"hello\""),
|
||||
@@ -356,10 +302,10 @@ fn mail_probe(
|
||||
};
|
||||
probe.letter_id = Some(letter_id);
|
||||
let Some(idx) = index_of(&mail, letter_id) else {
|
||||
return; // shouldn't happen the same frame we just found it — re-poll
|
||||
return; // found this frame, so not expected; re-poll
|
||||
};
|
||||
// CheckInbox() called twice, idempotently (`0x4aeab0`'s 60s client-side throttle,
|
||||
// decision 0548 §2/0544) — proves a rapid re-call is a no-op, not a packet storm.
|
||||
// `CheckInbox()` twice: `0x4aeab0` throttles it to once per 60 s, so the re-call is a
|
||||
// no-op.
|
||||
if let Err(e) = script.run(&format!("CheckInbox() CheckInbox() GetInboxText({idx})")) {
|
||||
error!("PROBE_MAIL: FAIL (b) — GetInboxText({idx}) errored: {e}");
|
||||
probe.fails += 1;
|
||||
@@ -468,8 +414,8 @@ fn mail_probe(
|
||||
Phase::ReadLetter { since, slot } => {
|
||||
match slot {
|
||||
None => {
|
||||
// Find the Plain Letter (8383) in the bags via the container Lua the bag UI
|
||||
// itself uses; encoded bag*100+slot (-1 = not there yet).
|
||||
// Find the Plain Letter (item 8383) via the bag UI's own container Lua;
|
||||
// encoded bag*100+slot (-1 = not there yet).
|
||||
let found = script
|
||||
.eval::<i64>(
|
||||
"for bag=0,4 do local n=GetContainerNumSlots(bag) or 0 \
|
||||
@@ -535,10 +481,8 @@ fn mail_probe(
|
||||
}
|
||||
}
|
||||
Phase::TakeMoney { since, taken } => {
|
||||
// The id is REMEMBERED across ticks, never re-found: a successful take is exactly what
|
||||
// makes the row stop matching `money > 0`, so re-searching would read success as
|
||||
// absence (0651). Matched as a pair rather than with guards — guarded `Some(_) if …`
|
||||
// arms can't be proven exhaustive by rustc, forcing a dead catch-all.
|
||||
// The id is kept across ticks: a successful take makes the row stop matching
|
||||
// `money > 0`. Matched as a pair, since guarded arms would need a dead catch-all.
|
||||
let next = Phase::TakeItem {
|
||||
since: now,
|
||||
taken: None,
|
||||
@@ -778,9 +722,8 @@ fn mail_probe(
|
||||
"PROBE_MAIL: DONE pass={} fail={}",
|
||||
probe.passes, probe.fails
|
||||
);
|
||||
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
|
||||
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
|
||||
// client holding the probe account.
|
||||
// `ProbeExitPlugin::fire_probe_exit`'s pattern: `AppExit` plus a hard backstop, so a
|
||||
// teardown hang cannot leave a client holding the probe account.
|
||||
exit.write(AppExit::Success);
|
||||
std::thread::spawn(|| {
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
//! The melee live probe (`WOW_PROBE=melee`) — an agent-side instrument, inert without the env:
|
||||
//! once in-world, periodically fire the attack-nearest core ([`AttackNearestRequest`] — the
|
||||
//! action-bar attack's own no-selection path), so the probe character fights whatever is closest
|
||||
//! while the `benilla_assets::trace` sink (`WOW_MOVE_TRACE=<path>`) records the swing→impact→spawn combat-text
|
||||
//! timeline for pair-birth analysis. Pair with the checkout's probe identity (the `probe` skill) + an outer
|
||||
//! `timeout`.
|
||||
//! The melee live probe (`WOW_PROBE=melee`), inert without the env: once in-world it fires the
|
||||
//! attack-nearest core ([`AttackNearestRequest`]) every few seconds, so the character fights
|
||||
//! whatever is closest while `WOW_MOVE_TRACE=<path>` records the swing, impact and combat-text
|
||||
//! timeline.
|
||||
//!
|
||||
//! **Do NOT run this unattended** (director's rule, 2026-07-14, `docs/METHOD.md` "The local vmangos
|
||||
//! server"): the probe fights back-to-back with no health awareness and the character DIES (Tri
|
||||
//! did). Combat runs are director-assisted — ask, or hand an eyeball script.
|
||||
//! Never run it unattended (`docs/METHOD.md`, "The local server"): it fights with no health
|
||||
//! awareness and the character dies.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -23,9 +20,8 @@ impl Plugin for ProbeMeleePlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every few seconds: with a live selection, (re)send the attack swing at it (idempotent while
|
||||
/// already auto-attacking; covers the auto-acquired-attacker case the acquire core skips); with
|
||||
/// none, run the acquire core. Re-acquires the next victim after a kill clears the selection.
|
||||
/// Every 3 s: swing at the selection (idempotent while auto-attacking, and it covers an attacker
|
||||
/// the acquire core skips), or with none, run the acquire core.
|
||||
fn melee_probe(
|
||||
time: ProbeClock,
|
||||
mut last: Local<f64>,
|
||||
|
||||
@@ -1,44 +1,21 @@
|
||||
//! `WOW_PROBE_MODEL_CAMERA=1` — the live probe for the `<Model>` widget's **perspective leg**
|
||||
//! (decision 2027): a plain `CreateFrame("Model")` pointed at a camera-bearing file, framed by the
|
||||
//! file's own camera through a camera of its own into its cell of the tile atlas.
|
||||
//! `WOW_PROBE_MODEL_CAMERA=1`: the live probe for the `<Model>` widget's perspective leg. It builds
|
||||
//! a plain `CreateFrame("Model")` on a camera-bearing file and reads the camera and root the
|
||||
//! renderer actually built, which the unit tests in `crate::ui_models` cannot reach.
|
||||
//!
|
||||
//! ## What it asks, and why it is a probe rather than a test
|
||||
//! Each leg projects three model-space points through that camera and root and compares the NDC
|
||||
//! with the baseline's:
|
||||
//!
|
||||
//! The unit tests in `crate::ui_models` pin the leg's arithmetic — the client's diagonal-FOV matrix
|
||||
//! (`0x5c3cc0`) against its worked numbers, and the three cancellations. What they cannot reach is
|
||||
//! the half that only exists at run time: does the engine resolve the camera at all, does the
|
||||
//! renderer find the record, does a camera get spawned, aimed, and pointed at a real cell of a
|
||||
//! real atlas. So this probe drives the **whole live pipeline** from Lua and reads the result off
|
||||
//! the entities the renderer actually built.
|
||||
//! - `scale`: `SetModelScale(3)` moves nothing, as the authored eye rides the model's root.
|
||||
//! - `position`: `SetPosition(0.4, -0.3, 0.9)` moves nothing, for the same reason.
|
||||
//! - `facing`: `SetFacing(1.0)` moves nothing: the up vector `0x7ac640` builds is model-space `+Z`
|
||||
//! at `roll = 0`, the facing's own axis. Only `<PlayerModel>`'s frozen camera (`0x7acf10`)
|
||||
//! shows a facing.
|
||||
//! - `ortho`, the control: `SetCamera(9)` is past the file's camera count, so the widget installs
|
||||
//! the NULL camera and falls to the orthographic leg, and the perspective camera goes inactive.
|
||||
//!
|
||||
//! The measurement is numeric, not visual (docs/METHOD.md's rule: a capture can confirm an existence
|
||||
//! fact, it cannot measure one). Each leg projects three model-space probe points through the
|
||||
//! camera and the root the renderer placed this frame, and compares the resulting NDC against the
|
||||
//! baseline's:
|
||||
//!
|
||||
//! - `scale` — `SetModelScale(3)` must not move a pixel. The authored eye is carried through the
|
||||
//! model's root transform, so the camera scales with the model.
|
||||
//! - `position` — `SetPosition(0.4, −0.3, 0.9)` must not move a pixel, for the same reason.
|
||||
//! - `facing` — `SetFacing(1.0)` must not move a pixel **either**: the up vector `0x7ac640` builds
|
||||
//! is model-space `+Z` at `roll = 0`, which is the axis the facing turns about, so eye, target,
|
||||
//! geometry and up all turn together. The rule that only `SetFacing` shows is scoped to
|
||||
//! `<PlayerModel>`'s frozen camera (`0x7acf10`).
|
||||
//! - `ortho` — the control that must NOT hold: `SetCamera(9)` is past the file's camera count, so
|
||||
//! the widget installs the NULL camera and falls to the orthographic leg, where a facing is a
|
||||
//! plain roll in the screen plane. A probe whose "identical" legs all passed because nothing was
|
||||
//! drawing would fail here.
|
||||
//!
|
||||
//! Three screenshots ride along — the baseline, the scaled/offset one and the turned one — as the
|
||||
//! existence evidence and for the director's own eye. They are the whole window, so they are not
|
||||
//! compared: the world behind the pane moves.
|
||||
//!
|
||||
//! ## The run recipe
|
||||
//! ```text
|
||||
//! WOW_USER=probe5 WOW_PASS=pprobe5 WOW_CHAR=Probefive WOW_UNATTENDED=1 WOW_NOSOUND=1 \
|
||||
//! WOW_PROBE_MODEL_CAMERA=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill)). `WOW_TILE_TRACE=1`
|
||||
//! alongside it prints the leg, the record, the eye and the matrix per pane per frame.
|
||||
//! Three whole-window screenshots go to the local-state folder as existence evidence; they are not
|
||||
//! compared. `WOW_TILE_TRACE=1` prints the leg, record, eye and matrix per pane per frame. The
|
||||
//! switches are `docs/CONTRIBUTING.md`, "Running it unattended".
|
||||
|
||||
use bevy::camera::visibility::RenderLayers;
|
||||
use bevy::prelude::*;
|
||||
@@ -50,24 +27,20 @@ use super::probes::ProbeClock;
|
||||
use crate::net::SelfPlayer;
|
||||
use crate::ui_models::{TilePerspectiveCamera, TileRoot, UiModelTiles};
|
||||
|
||||
/// The file the probe frames. `Creature\Wolf\Wolf.m2` carries **two** cameras
|
||||
/// (`benilla-extract m2cam`: index 0 type 0 at eye `(1.6927, 0.7238, 0.8837)` fov `0.95002`,
|
||||
/// index 1 type 1 at eye `(5.5556, 0, 1.8056)` fov `0.67620`), so it exercises the raw-index
|
||||
/// selection AND has an index past the count for the orthographic control.
|
||||
/// The file framed. `Creature\Wolf\Wolf.m2` carries two cameras (`benilla-extract m2cam`: index
|
||||
/// 0 type 0, eye `(1.6927, 0.7238, 0.8837)`, fov `0.95002`; index 1 type 1, eye
|
||||
/// `(5.5556, 0, 1.8056)`, fov `0.67620`), so index 9 is past the count.
|
||||
const FILE: &str = r"Creature\\Wolf\\Wolf.mdx";
|
||||
/// The pane's size in FrameXML units — the pet pane's own `318×224`, whose aspect is a worked
|
||||
/// example of the projection `0x5c3cc0` (`θ = 0.287938 · fov`).
|
||||
/// The pane's size in FrameXML units: the pet pane's `318x224`, a worked example of the
|
||||
/// projection `0x5c3cc0` (`theta = 0.287938 * fov`).
|
||||
const PANE_W: f32 = 318.0;
|
||||
const PANE_H: f32 = 224.0;
|
||||
/// Frames to let a change settle before the numbers are read: the extract republishes the request
|
||||
/// on the next conversion and the renderer aims the camera the frame after that.
|
||||
/// Frames to let a change settle: the extract republishes it, then the renderer aims the camera.
|
||||
const SETTLE_FRAMES: u32 = 8;
|
||||
/// NDC agreement the cancellation legs must hold to. Generous against f32 round-off through two
|
||||
/// 4×4 composes; a leg that failed to cancel misses by tens of percent, not by a thousandth.
|
||||
/// NDC agreement the cancelling legs must hold; a failed cancel misses by tens of percent.
|
||||
const NDC_EPS: f32 = 1.0e-3;
|
||||
|
||||
/// The Lua that builds the pane. Anonymous is the corpus shape (pfUI's autocast shine), but the
|
||||
/// probe needs a handle on it, so it is global.
|
||||
/// The Lua that builds the pane, named so the legs can reach it.
|
||||
const BUILD: &str = r#"
|
||||
ProbeModelCam = CreateFrame("Model", "ProbeModelCam", UIParent)
|
||||
ProbeModelCam:SetWidth(318)
|
||||
@@ -88,7 +61,7 @@ impl Plugin for ProbeModelCameraPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// One leg's reading: the NDC of the three probe points, and the label to report it under.
|
||||
/// One leg's reading: the NDC of the three probe points.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Reading([Vec2; 3]);
|
||||
|
||||
@@ -110,7 +83,7 @@ enum Phase {
|
||||
Await {
|
||||
since: f64,
|
||||
},
|
||||
/// A change is in flight; read it in `at`.
|
||||
/// A change is in flight; read after [`SETTLE_FRAMES`].
|
||||
Settle {
|
||||
frames: u32,
|
||||
leg: Leg,
|
||||
@@ -128,7 +101,7 @@ enum Leg {
|
||||
}
|
||||
|
||||
impl Leg {
|
||||
/// The Lua that puts the pane INTO this leg — run when the leg is entered, before its reading.
|
||||
/// The Lua that puts the pane into this leg, run before its reading.
|
||||
fn setup(self) -> &'static str {
|
||||
match self {
|
||||
Leg::Baseline => "",
|
||||
@@ -137,9 +110,7 @@ impl Leg {
|
||||
"ProbeModelCam:SetModelScale(1) ProbeModelCam:SetPosition(0.4, -0.3, 0.9)"
|
||||
}
|
||||
Leg::Facing => "ProbeModelCam:SetPosition(0, 0, 0) ProbeModelCam:SetFacing(1.0)",
|
||||
// The control: index 9 is past Wolf's two cameras, so the widget installs the NULL
|
||||
// camera and the pane drops to the orthographic leg. Its perspective camera goes
|
||||
// inactive, which is what the reading detects.
|
||||
// Past Wolf's two cameras: the NULL camera, the orthographic leg.
|
||||
Leg::Ortho => "ProbeModelCam:SetCamera(9)",
|
||||
}
|
||||
}
|
||||
@@ -155,8 +126,7 @@ impl Leg {
|
||||
}
|
||||
}
|
||||
|
||||
/// The three model-space points the legs are compared on — a wolf-sized spread, in Bevy space
|
||||
/// (the frame the tile root and the camera both work in).
|
||||
/// The three model-space points the legs compare, a wolf-sized spread in Bevy space.
|
||||
fn probe_points() -> [Vec3; 3] {
|
||||
[
|
||||
benilla_assets::coords::wow_to_bevy([0.0, 0.0, 0.8]),
|
||||
@@ -165,9 +135,8 @@ fn probe_points() -> [Vec3; 3] {
|
||||
]
|
||||
}
|
||||
|
||||
/// Project the three points through whatever the renderer built this frame — the ACTIVE
|
||||
/// perspective camera and the tile root under its layer. `None` when no perspective camera is
|
||||
/// aimed (which is the orthographic leg, and the control's expected answer).
|
||||
/// Projects the points through the active perspective camera and the tile root on its layer;
|
||||
/// `None` with no perspective camera aimed, the orthographic leg.
|
||||
fn read_leg(
|
||||
cams: &Query<
|
||||
(&Camera, &GlobalTransform, &Projection, &RenderLayers),
|
||||
@@ -176,9 +145,7 @@ fn read_leg(
|
||||
roots: &Query<(&GlobalTransform, &RenderLayers), With<TileRoot>>,
|
||||
) -> Option<Reading> {
|
||||
let (_, cam_tf, proj, layers) = cams.iter().find(|(c, _, _, _)| c.is_active)?;
|
||||
// The pane's root is the one on the camera's OWN layer — a perspective camera sees exactly one
|
||||
// tile by construction, and the interface has other tiles up (the minimap ping, the map arrow)
|
||||
// whose roots would otherwise be picked at random.
|
||||
// The root on the camera's own layer: other tiles (the minimap ping, the map arrow) are up.
|
||||
let (root, _) = roots.iter().find(|(_, l)| *l == layers)?;
|
||||
let clip = proj.get_clip_from_view() * cam_tf.to_matrix().inverse() * root.to_matrix();
|
||||
let mut out = [Vec2::ZERO; 3];
|
||||
@@ -190,8 +157,7 @@ fn read_leg(
|
||||
}
|
||||
|
||||
fn shoot(commands: &mut Commands, name: &str) {
|
||||
// Every file we write goes through `local_state` — never into the WoW install, never a
|
||||
// platform config dir (docs/METHOD.md's one-folder rule).
|
||||
// Written under `local_state`, never into the install.
|
||||
let Some(path) = crate::local_state::home().map(|d| d.join(format!("{name}.png"))) else {
|
||||
warn!("PROBE_MODEL_CAMERA: no local-state folder — skipping the {name} shot");
|
||||
return;
|
||||
@@ -243,8 +209,7 @@ fn model_camera_probe(
|
||||
leg: Leg::Baseline,
|
||||
};
|
||||
} else if now - since > 30.0 {
|
||||
// The asset never landed, or the pane never reached the paint list. An
|
||||
// environmental miss, not a wrong answer.
|
||||
// The asset never landed or the pane never painted: a SKIP, not a wrong answer.
|
||||
error!(
|
||||
"PROBE_MODEL_CAMERA: SKIP — no tile after 30 s (cells={}, camera aimed={})",
|
||||
tiles.cells.len(),
|
||||
@@ -342,9 +307,7 @@ fn model_camera_probe(
|
||||
probe.passes, probe.fails, probe.skips
|
||||
);
|
||||
exit.write(AppExit::Success);
|
||||
// The polite `AppExit` plus a hard backstop, the harness's standard pair
|
||||
// (`ProbeExitPlugin::fire_probe_exit`): a net or winit teardown hang must not leave a
|
||||
// zombie client holding the probe account.
|
||||
// A hard backstop, so a teardown hang cannot keep the account held.
|
||||
std::thread::spawn(|| {
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
warn!("PROBE_MODEL_CAMERA: still alive 5s after AppExit — hard exit");
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
//! The partner live probe (`WOW_PROBE=partner`) — the party arc's instrument (decision 0434):
|
||||
//! a second client that says YES. Once in-world it auto-accepts every group invite, so the
|
||||
//! director can exercise the whole party surface — invite, roster lines, member frames,
|
||||
//! leader/loot management, pings — solo, with the probe account as the other member. It also
|
||||
//! accepts **duel** challenges (decision 0637), which is what makes the duel arc's two-client
|
||||
//! questions answerable at all: whether the opponent turns hostile, whether the arbiter flag
|
||||
//! plants. Non-combat (the unattended-combat ban, `docs/METHOD.md` "The local vmangos server", stands
|
||||
//! untouched: this probe answers a challenge but never swings, and a duel nobody strikes in
|
||||
//! simply times out). Pair with the checkout's probe identity (the `probe` skill).
|
||||
//! The partner live probe (`WOW_PROBE=partner`): a second client that accepts every group
|
||||
//! invite and duel challenge, so the party and duel surfaces can be exercised with it as the
|
||||
//! other player. It never swings, so an unstruck duel times out; the switches are
|
||||
//! `docs/CONTRIBUTING.md`, "Running it unattended".
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -21,10 +16,8 @@ impl Plugin for ProbePartnerPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// A pending invite is answered with `CMSG_GROUP_ACCEPT` the frame it lands. Clearing
|
||||
/// `pending_invite` here keeps the probe's own PARTY_INVITE popup from ever arming (and if the
|
||||
/// popup won a same-frame race, its hide-path `DeclineGroup` is a server no-op once we're
|
||||
/// grouped — vmangos ignores a decline with no pending invite).
|
||||
/// Accept a pending invite the frame it lands; taking `pending_invite` keeps the invite popup
|
||||
/// from arming, and a racing `DeclineGroup` is a no-op once grouped (`GroupHandler.cpp:200`).
|
||||
fn partner_probe(
|
||||
self_player: Query<(), With<SelfPlayer>>,
|
||||
mut group: ResMut<GroupState>,
|
||||
@@ -38,9 +31,7 @@ fn partner_probe(
|
||||
info!("partner probe: accepting {inviter}'s group invite");
|
||||
let _ = net.0.send(crate::net::ClientCommand::GroupAccept);
|
||||
}
|
||||
// A pending challenge is answered with `CMSG_DUEL_ACCEPTED` the frame it lands. Taking the
|
||||
// challenger discharges the popup debt the same way the UI feed's DUEL_REQUESTED edge would,
|
||||
// so no dialog is left owed on the probe.
|
||||
// Taking the challenger discharges the DUEL_REQUESTED popup, as the UI feed would.
|
||||
let arbiter = duel.arbiter;
|
||||
if let Some(challenger) = duel.take_challenger() {
|
||||
info!("partner probe: accepting duel from {challenger:#018x} (arbiter {arbiter:#018x})");
|
||||
|
||||
@@ -1,28 +1,12 @@
|
||||
//! `WOW_PROBE_PITCH` — the scripted **dive**: aim a swimming avatar's nose up or down without a
|
||||
//! human on the mouse. The twin of [`super::probe_look`]'s scripted turn, and it closes the same
|
||||
//! shape of gap.
|
||||
//! `WOW_PROBE_PITCH`: the scripted dive, aiming a swimming avatar's nose up or down. In play the
|
||||
//! swim pitch (the wire's pitch tail, the body-pitch render `0x60a110`) is only written by
|
||||
//! mouse-look, which needs a moving OS cursor in the viewport.
|
||||
//!
|
||||
//! The swim body pitch has exactly one writer in gameplay — the mouse-look push in
|
||||
//! `player::controller`, gated on `mouselook` (a held right button or MOVEANDSTEER) *and* a moving
|
||||
//! OS cursor inside the viewport. An unfocused probe window has neither. So the whole observed-swim
|
||||
//! lane — the wire's pitch tail, the dead-reckon's pitched travel basis, and the body-pitch render
|
||||
//! law (`0x60a110`, decision 0464) — was **unreachable from any script**: a benilla client could be
|
||||
//! made to swim, but only ever level. The tilt shipped in July 2026 with no test, no trace field
|
||||
//! and no way to drive it, which is why "do other swimmers tilt?" had no answer short of the
|
||||
//! director's eye, and why a two-client probe of it could not be written at all.
|
||||
//!
|
||||
//! Format: `WOW_PROBE_PITCH="<deg>@<start_s>[:<deg_per_sec>][;…]"` — `deg` is nose-**up** positive,
|
||||
//! matching `Player::mover_pitch` and the wire's own sign. `"-30@22;30@40"` aims 30° down at
|
||||
//! twenty-two seconds and 30° up at forty; `"-45@20:9"` starts level-ish and sweeps at 9°/s from
|
||||
//! twenty seconds on, which is what a slow real dive looks like on the wire. The last entry whose
|
||||
//! start has passed wins, and it is re-asserted **every frame** — the reference holds an unsteered
|
||||
//! pitch rather than levelling it, so a one-shot write would be indistinguishable from a
|
||||
//! held one; re-asserting is what makes the two separable when only one of them is under test.
|
||||
//!
|
||||
//! It writes [`Player::aim_pitch`] — the field the reference's `SetPitch` (`0x7c6f70`) writes,
|
||||
//! under the same ±89° clamp — rather than faking mouse motion, for [`super::probe_look`]'s reason
|
||||
//! exactly: the camera plumbing is not what is under test, and its `cursor_in_viewport` gate is
|
||||
//! unreliable for a window nobody is looking at.
|
||||
//! Format: `WOW_PROBE_PITCH="<deg>@<start_s>[:<deg_per_sec>][;...]"`, nose-up positive like
|
||||
//! `Player::mover_pitch` and the wire. `"-30@22;30@40"` aims 30 degrees down at 22 s and up at 40;
|
||||
//! `"-45@20:9"` sweeps at 9 degrees/s from 20 s. The latest started entry wins and is re-applied
|
||||
//! every frame. It writes [`Player::aim_pitch`], the field the reference's `SetPitch`
|
||||
//! (`0x7c6f70`) writes, under the same 89-degree clamp.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -31,10 +15,8 @@ use benilla_world::schedule::WorldStage;
|
||||
|
||||
use super::ProbeClock;
|
||||
|
||||
/// One scripted aim: the target pitch (radians, +up), when it takes effect, and an optional sweep
|
||||
/// rate (rad/s) applied from that moment — **wall-clock seconds** ([`ProbeClock`], decision 0789),
|
||||
/// for [`super::probe_look`]'s reason: a dive scripted to reach 30° by second 25 has to reach it at
|
||||
/// second 25 whatever the frame rate did, and the wire tail it feeds is paced on the real clock.
|
||||
/// One scripted aim: pitch (radians, +up), start and sweep rate (rad/s), in wall-clock seconds
|
||||
/// ([`ProbeClock`]).
|
||||
struct Aim {
|
||||
pitch: f32,
|
||||
at: f32,
|
||||
@@ -46,8 +28,7 @@ pub(crate) struct ProbePitch {
|
||||
aims: Vec<Aim>,
|
||||
}
|
||||
|
||||
/// Parse the env script. Absent or unparseable entries yield no aims (with a warning), so the probe
|
||||
/// is inert unless asked for.
|
||||
/// Parses `WOW_PROBE_PITCH`; unparseable entries are skipped with a warning.
|
||||
pub(crate) fn from_env() -> Option<ProbePitch> {
|
||||
let spec = std::env::var("WOW_PROBE_PITCH").ok()?;
|
||||
let aims: Vec<Aim> = spec
|
||||
@@ -76,8 +57,8 @@ pub(crate) fn from_env() -> Option<ProbePitch> {
|
||||
(!aims.is_empty()).then_some(ProbePitch { aims })
|
||||
}
|
||||
|
||||
/// Hold the aim of the latest entry whose start has passed. Runs in `WorldStage::Input` **before**
|
||||
/// the controller, so the swim frame that reads the pitch is the frame that renders and streams it.
|
||||
/// Holds the latest started entry's aim, before the controller, so the same frame renders and
|
||||
/// streams it.
|
||||
pub(crate) fn drive_probe_pitch(
|
||||
probe: Res<ProbePitch>,
|
||||
time: ProbeClock,
|
||||
@@ -89,8 +70,7 @@ pub(crate) fn drive_probe_pitch(
|
||||
return; // in-world only, like the other probes
|
||||
}
|
||||
let now = time.elapsed_secs();
|
||||
// The LAST armed entry wins, so a script reads as a timeline rather than a sum — a dive
|
||||
// followed by a climb is two entries, not an arithmetic accident.
|
||||
// The latest armed entry wins: a script is a timeline, not a sum.
|
||||
let Some(aim) = probe
|
||||
.aims
|
||||
.iter()
|
||||
@@ -99,21 +79,17 @@ pub(crate) fn drive_probe_pitch(
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// The STORED value, not the requested one: `aim_pitch` clamps at ±89°, and a sweep that runs
|
||||
// past it must not log a number the body never held.
|
||||
// Log the stored value: `aim_pitch` clamps at 89 degrees either way.
|
||||
let held = player.aim_pitch(aim.pitch + aim.sweep * (now - aim.at));
|
||||
// One line per whole degree crossed: enough to read the timeline back out of the log, few
|
||||
// enough not to drown it. The observer's own reading is the point of comparison.
|
||||
// One line per whole degree crossed.
|
||||
let whole = held.to_degrees().round();
|
||||
if announced.replace(whole) != Some(whole) {
|
||||
info!("probe-pitch: aiming {whole:+.0}° at t={now:.1}s");
|
||||
}
|
||||
}
|
||||
|
||||
/// `WOW_PROBE_PITCH`'s registration — the same shape (and the same ordering rationale) as
|
||||
/// [`super::probe_look`]'s: inert with no resource and no systems when the script parses to
|
||||
/// nothing, and ordered before [`crate::player::PlayerControlSet`] so the controller knows nothing
|
||||
/// about it (decision 1174).
|
||||
/// Registers `WOW_PROBE_PITCH` before [`crate::player::PlayerControlSet`]; inert if the script
|
||||
/// parses to nothing.
|
||||
pub(crate) struct ProbePitchPlugin;
|
||||
|
||||
impl Plugin for ProbePitchPlugin {
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
//! The probe **rig** (`WOW_RIG="<spec>"`, decision 0651) — one command that puts this checkout's probe
|
||||
//! account into a chosen **body** and hands the session a world that is ready to test.
|
||||
//!
|
||||
//! ## Why it exists
|
||||
//!
|
||||
//! Every probe account owns one level-1 character, and every session that needs anything else has
|
||||
//! been hand-assembling it out of GM commands. Mined from 507 MB of this project's own session
|
||||
//! transcripts, the standing tax is: `.go xyz` 750×, `.additem` 434×, `.modify` 345×, `.gm off`
|
||||
//! 257×, `.revive` **121×**, `.learn <one spell id>` a few dozen times — while
|
||||
//! `.character premade` (96 ready-made BiS/twink **gear** templates plus 53 **talent** ones sitting
|
||||
//! in this deploy's world DB, every class at 19/29/39/49/60) has been used **zero** times, because
|
||||
//! nobody knew it was there. That is the shape of the problem: the primitives exist and are undiscoverable, so
|
||||
//! each session re-derives a worse version of them.
|
||||
//!
|
||||
//! The rig is the one verb that hides all of it: name a body, get a body.
|
||||
//! The probe rig (`WOW_RIG="<spec>"`): puts this checkout's probe account into a chosen body
|
||||
//! through GM commands and hands over a world ready to test. Non-combat: it creates, configures,
|
||||
//! places and stops.
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_RIG="tauren druid 60 gear:heal-preraid-bis spec:heal-preraid-bis at:ThunderBluff"
|
||||
@@ -21,58 +9,26 @@
|
||||
//! WOW_RIG="nightelf druid" # just a body of that shape, level 1
|
||||
//! ```
|
||||
//!
|
||||
//! ## What it does
|
||||
//! Race and class name a character, since vmangos has no class-change command: the name is
|
||||
//! `<Race3><Class3><word>[f]`, the word this checkout's (`run_mode::rig_suffix`), so a later run
|
||||
//! reuses the same body. Without race and class the rig configures whatever `WOW_CHAR` logs in as.
|
||||
//! When a create fails with `CHAR_CREATE_SERVER_LIMIT`, the rig deletes this checkout's rig-named
|
||||
//! character cheapest to rebuild and retries once; no other character is ever deleted.
|
||||
//!
|
||||
//! **Race + class name a character, so the rig owns the pick.** vmangos has no class-change command
|
||||
//! (`.character race` exists; there is no `.character class`), so a different class *is* a different
|
||||
//! character — which makes creating one the honest primitive rather than a fallback. The name is
|
||||
//! derived, never invented: `<Race3><Class3><word>[f]`, the word being this checkout's
|
||||
//! (`run_mode::rig_suffix`), e.g. a Tauren Druid for `Probeone` → `Taudruone`. Deterministic means
|
||||
//! the *next* session reuses the same body instead of littering a second one, and the pattern is
|
||||
//! what makes eviction safe (below). Omit race+class and the rig configures whatever `WOW_CHAR`
|
||||
//! already logs in as.
|
||||
//! The GM commands behind it, with the level each needs (vmangos `Chat.cpp`):
|
||||
//! - a dead body: `.revive` (SEC_GAMEMASTER 3), always.
|
||||
//! - `<level>`: `.character level N` (SEC_DEVELOPER 5), then `.learn all_myclass` (5), which
|
||||
//! teaches all class spells and talents.
|
||||
//! - `gear:<t>`: `.character premade gear <t>` (SEC_BASIC_ADMIN 4), which levels up and equips.
|
||||
//! - `spec:<t>`: `.character premade spec <t>` (4), which resets talents and learns the tree.
|
||||
//! - `at:<name>`: `.tele <name>` (SEC_TICKETMASTER 2), a `game_tele` row.
|
||||
//! - `at:m,x,y,z[,o]`: `.go xyz x y z m`, or `.go xyzo` to pin the facing (2).
|
||||
//! - `gm:on|off`: `.gm on|off` (SEC_TICKETMASTER 2).
|
||||
//!
|
||||
//! **The roster is a cache with an eviction policy.** `CharactersPerRealm` is 10, and there are 40
|
||||
//! valid race/class pairs, so a busy account fills. The rig does not hardcode the limit: it tries
|
||||
//! the create, and only on `CHAR_CREATE_SERVER_LIMIT` evicts **the rig-named character of this checkout
|
||||
//! that is cheapest to rebuild** — lowest level first, ties broken by oldest — then retries once.
|
||||
//! Nothing outside the rig's own naming pattern is ever deleted, so `Probe<N>` and anything a human
|
||||
//! made are untouchable.
|
||||
//!
|
||||
//! **Then it applies state, in an order that matters:** revive before anything (a ghost's commands
|
||||
//! half-apply), level before gear (`ApplyPremadeGearTemplateToPlayer` only levels *up*, never
|
||||
//! down), spells after level, teleport last (so a fixup lands where you asked, not where you were).
|
||||
//! It always revives a dead or ghost body, whether or not you asked — that is the 121× command, and
|
||||
//! there is no session that wants to keep testing on a corpse.
|
||||
//!
|
||||
//! ## The GM verbs behind it (all verified against the vmangos source, github.com/vmangos/core)
|
||||
//!
|
||||
//! | rig token | command | needs | note |
|
||||
//! |---|---|---|---|
|
||||
//! | (always, if dead) | `.revive` | SEC_GAMEMASTER 3 | |
|
||||
//! | `<level>` | `.character level N` | SEC_DEVELOPER 5 | no name arg ⇒ self |
|
||||
//! | (with a level) | `.learn all_myclass` | SEC_DEVELOPER 5 | = all class spells **+** all talents |
|
||||
//! | `gear:<t>` | `.character premade gear <t>` | SEC_BASIC_ADMIN 4 | name or entry; levels up + equips |
|
||||
//! | `spec:<t>` | `.character premade spec <t>` | SEC_BASIC_ADMIN 4 | resets talents, learns the tree |
|
||||
//! | `at:<name>` | `.tele <name>` | SEC_TICKETMASTER 2 | 997 named rows in `game_tele` |
|
||||
//! | `at:m,x,y,z[,o]` | `.go xyz x y z m` (`.go xyzo` with `o`) | SEC_TICKETMASTER 2 | pin the facing when a motion probe walks |
|
||||
//! | `gm:on\|off` | `.gm on\|off` | SEC_GAMEMASTER 3 | see 0649 on why `off` matters |
|
||||
//!
|
||||
//! Probe accounts are gmlevel **6**, so every one of these lands. Pass `gear:?` (or `spec:?`) to
|
||||
//! make the server *list* the templates its class has instead of applying one — the discovery path,
|
||||
//! since the catalog lives in the world DB and not in any client data.
|
||||
//!
|
||||
//! **Two things about `gear:` that have each cost a session.** First, the templates are not all full
|
||||
//! sets: of the 96 in this deploy, `pvp-r14-hunter-fx` holds 5 items and the priest/warrior
|
||||
//! `heal-r14`/`tank-r14` hold 8, against a 16.8 mean — so a sparse body can be exactly what the
|
||||
//! template says, and only a body under `GEAR_FLOOR` is evidence of a refusal. Second, applying gear
|
||||
//! **strips before it dresses** (`ApplyPremadeGearTemplateToPlayer` unequips all 19 slots, then
|
||||
//! `StoreNewItemInBestSlots` per item), so re-dressing a body leaves the old copy in its bags and
|
||||
//! mails the overflow once they fill. Repeated rigs on one character accumulate; a fresh rig-named
|
||||
//! character (give `WOW_RIG` a race+class) starts with empty bags and is the cheaper path.
|
||||
//!
|
||||
//! Non-combat throughout: the rig creates, configures, places and stops. It never fights, so the
|
||||
//! unattended-combat ban (docs/METHOD.md) is untouched.
|
||||
//! `gear:?` or `spec:?` makes the server list its templates for the class, as the catalog lives in
|
||||
//! the world DB. Applying gear unequips all 19 slots before it equips
|
||||
//! (`ObjectMgr.cpp:12079-12117`), so re-dressing a body leaves the old set in its bags or mailed;
|
||||
//! a fresh rig-named character starts with empty bags.
|
||||
|
||||
use benilla_protocol::{messages, CharAction, CharCreateReq};
|
||||
use bevy::prelude::*;
|
||||
@@ -84,31 +40,24 @@ use crate::net::{
|
||||
ObjectStore, SelfPlayer,
|
||||
};
|
||||
|
||||
/// Grace after world entry before the first GM line — the descriptor and the UI VM both have to be
|
||||
/// up, and a command sent into a half-built session is silently dropped.
|
||||
/// Grace after world entry before the first GM line, while the descriptor and the UI VM come up;
|
||||
/// a command sent into a half-built session is dropped.
|
||||
const SETTLE_SECS: f32 = 3.0;
|
||||
|
||||
/// Spacing between GM lines. Each one is a server-side mutation whose result the next may depend on
|
||||
/// (level → gear → spec), and two flips inside one net drain merge to a no-op (0441's lesson).
|
||||
/// Spacing between GM lines: each may depend on the last (level, gear, spec), and two flips inside
|
||||
/// one net drain merge to a no-op.
|
||||
const STEP_SECS: f32 = 0.8;
|
||||
|
||||
/// Grace after the last GM line before reading the result back — the level-up, the equip sweep and
|
||||
/// the teleport all land as descriptor deltas a frame or two later.
|
||||
/// Grace after the last GM line before reading the result back, as its effects land as descriptor
|
||||
/// deltas a frame or two later.
|
||||
const VERIFY_SECS: f32 = 2.0;
|
||||
|
||||
/// The fewest items any real gear template in this deploy's world DB actually holds, so a body that
|
||||
/// wears fewer than this after a `gear:` **cannot** be the template landing — something refused.
|
||||
///
|
||||
/// Derived, not invented: `SELECT COUNT(*) … player_premade_item GROUP BY entry` over the 96 rows of
|
||||
/// `player_premade_item_template` runs 5…23 (mean 16.8). Only three templates are partial —
|
||||
/// `pvp-r14-hunter-fx` (5), and the priest/warrior `heal-r14`/`tank-r14` (8 each) — so a **low but
|
||||
/// legitimate** count is a real outcome and this floor deliberately sits under it: the warning fires
|
||||
/// only for a body that is essentially naked. (Entry 910 has one item row and no template row at
|
||||
/// all — an orphan; asking for it by entry errors server-side.)
|
||||
/// The fewest items any gear template in the local world DB holds (`pvp-r14-hunter-fx`, 5), so a
|
||||
/// body wearing fewer after a `gear:` was refused.
|
||||
const GEAR_FLOOR: usize = 5;
|
||||
|
||||
/// Did a `gear:` ask come back with a body that cannot be wearing the template? `gear:?` is the
|
||||
/// discovery path — it lists and dresses nothing, so it never counts as a refusal.
|
||||
/// Whether a `gear:` ask came back with a body that cannot be wearing the template; `gear:?` only
|
||||
/// lists, so it never counts.
|
||||
fn gear_was_refused(gear: Option<&str>, equipped: usize) -> bool {
|
||||
gear.is_some_and(|g| g != "?") && equipped < GEAR_FLOOR
|
||||
}
|
||||
@@ -125,9 +74,7 @@ impl Plugin for ProbeRigPlugin {
|
||||
return; // inert without a parseable spec (parse() has already said why)
|
||||
};
|
||||
info!("rig: {}", spec.describe());
|
||||
// Publish the claim on the character pick as an always-present run fact (decision 1174).
|
||||
// The roster reads it to know the pick is already spoken for; before 1174 it called
|
||||
// `rig_char_name_from_env` directly, which is gameplay naming the harness.
|
||||
// Publish the claim on the character pick, so the roster knows the pick is spoken for.
|
||||
if let Some(name) = rig_char_name(&spec) {
|
||||
app.insert_resource(crate::run_mode::RigCharacter(name));
|
||||
}
|
||||
@@ -144,10 +91,10 @@ impl Plugin for ProbeRigPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// The parsed `WOW_RIG` spec. Every field is optional — the rig only touches what was asked for.
|
||||
/// The parsed `WOW_RIG` spec; the rig touches only what was asked for.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
struct RigSpec {
|
||||
/// `(race id, class id, gender)` — present together or not at all; they name the character.
|
||||
/// `(race id, class id, gender)`, which name the character.
|
||||
body: Option<(u8, u8, u8)>,
|
||||
level: Option<u8>,
|
||||
gear: Option<String>,
|
||||
@@ -155,23 +102,20 @@ struct RigSpec {
|
||||
/// `.tele <name>`, or `.go xyz` when the token parsed as `map,x,y,z`.
|
||||
at: Option<String>,
|
||||
gm: Option<bool>,
|
||||
/// Whether to `.learn all_myclass`. Defaults to "yes if a level was asked for" — a level-60
|
||||
/// body with a level-1 spellbook is not a level-60 body.
|
||||
/// Whether to `.learn all_myclass`; defaults to yes when a level was asked for.
|
||||
learn: Option<bool>,
|
||||
}
|
||||
|
||||
impl RigSpec {
|
||||
/// Parse the whitespace-separated, order-free, case-insensitive token soup. Returns `None` (with
|
||||
/// a `warn!` naming the offending token) rather than silently rigging the wrong thing.
|
||||
/// Parses the whitespace-separated, order-free, case-insensitive tokens; a bad token warns and
|
||||
/// rigs nothing.
|
||||
fn parse(spec: &str) -> Option<Self> {
|
||||
let mut out = Self::default();
|
||||
let (mut race, mut class, mut gender) = (None, None, None);
|
||||
for tok in spec.split_whitespace() {
|
||||
let lower = tok.to_ascii_lowercase();
|
||||
if let Some((key, _)) = lower.split_once(':') {
|
||||
// The KEY is matched case-insensitively, but the VALUE is taken from the original
|
||||
// token: a premade-template name and a `game_tele` name are both looked up verbatim
|
||||
// server-side, so lowercasing them would break the lookup.
|
||||
// The value keeps its case: template and `game_tele` names are looked up verbatim.
|
||||
let val = tok.split_once(':').map_or("", |(_, v)| v).to_string();
|
||||
match key {
|
||||
"gear" => out.gear = Some(val),
|
||||
@@ -215,14 +159,12 @@ impl RigSpec {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Whether the class's spellbook should be filled in (`learn:` if given, else "a level implies
|
||||
/// the spells that come with it").
|
||||
/// Whether to fill the class's spellbook: as asked, else when a level above 1 was asked for.
|
||||
fn wants_spells(&self) -> bool {
|
||||
self.learn.unwrap_or(self.level.is_some_and(|l| l > 1))
|
||||
}
|
||||
|
||||
/// The one-line echo of what was asked for — printed at startup so a mis-parse is obvious
|
||||
/// before the socket even opens.
|
||||
/// The one-line echo of the spec, printed at startup.
|
||||
fn describe(&self) -> String {
|
||||
let body = self
|
||||
.body
|
||||
@@ -275,9 +217,8 @@ enum RigPhase {
|
||||
Settling(f32),
|
||||
/// Sending the batch, one line per [`STEP_SECS`].
|
||||
Commanding,
|
||||
/// The batch is out; re-read the descriptor at this `Time::elapsed_secs` and report what
|
||||
/// actually landed. A command the server refused is otherwise invisible — the send always
|
||||
/// "succeeds", and the preflight banner describes the body we *arrived* in, not the rigged one.
|
||||
/// The batch is out; at this `Time::elapsed_secs` the descriptor is re-read to report what
|
||||
/// landed, since a refused GM command gets no error.
|
||||
Verifying(f32),
|
||||
Done,
|
||||
}
|
||||
@@ -286,9 +227,9 @@ enum RigPhase {
|
||||
struct Rig {
|
||||
spec: RigSpec,
|
||||
phase: RigPhase,
|
||||
/// The freshest roster (each successful create/delete is preceded by a new enum).
|
||||
/// The freshest roster.
|
||||
roster: Vec<benilla_protocol::Character>,
|
||||
/// A server-limit eviction has already been spent — one is a full roster, two is a bug.
|
||||
/// A server-limit eviction has been spent; only one is allowed.
|
||||
evicted: bool,
|
||||
steps: Vec<String>,
|
||||
sent: usize,
|
||||
@@ -315,11 +256,10 @@ fn drive_rig(
|
||||
match rig.phase {
|
||||
RigPhase::AwaitRoster => {
|
||||
if rig.roster.is_empty() && rig.spec.body.is_none() {
|
||||
return; // no roster yet, and nothing to create — wait for the enum
|
||||
return; // no roster yet, and nothing to create
|
||||
}
|
||||
let Some(want) = rig_char_name(&rig.spec) else {
|
||||
// No body asked for: let char_select's own WOW_CHAR/default pick stand, and just
|
||||
// configure whatever walks into the world.
|
||||
// No body asked for: char_select's own pick stands.
|
||||
rig.phase = RigPhase::Entering;
|
||||
return;
|
||||
};
|
||||
@@ -444,8 +384,7 @@ fn drive_rig(
|
||||
maxhp = store.0.unit_max_health().unwrap_or(0),
|
||||
faction = store.0.unit_faction_template().unwrap_or(0),
|
||||
);
|
||||
// The one failure that reads as success: the batch went out, the server refused every
|
||||
// line, and the body is exactly as found. Level is the cheapest tell.
|
||||
// A batch the server refused leaves the body as found; the level is the cheapest tell.
|
||||
if let Some(want) = rig.spec.level {
|
||||
let got = store.0.unit_level().unwrap_or(0);
|
||||
if got < u32::from(want) {
|
||||
@@ -457,13 +396,8 @@ fn drive_rig(
|
||||
);
|
||||
}
|
||||
}
|
||||
// The other failure that reads as success: `.character premade gear` went out, the body
|
||||
// came back wearing almost nothing, and every later reading is of the wrong body. The
|
||||
// server unequips all 19 slots *before* it equips (`ApplyPremadeGearTemplateToPlayer` →
|
||||
// `AutoUnequipItemFromSlot` → `StoreNewItemInBestSlots`, vmangos `ObjectMgr.cpp`), so a
|
||||
// near-naked body means the equip half was refused while the strip half already ran —
|
||||
// the set is in the bags, or mailed if they were full. A session that misses this line
|
||||
// measures a probe in its underwear.
|
||||
// The server unequips all 19 slots before it equips (`ObjectMgr.cpp:12079-12117`), so
|
||||
// a near-naked body means the equip half was refused after the strip ran.
|
||||
if gear_was_refused(rig.spec.gear.as_deref(), equipped) {
|
||||
warn!(
|
||||
"rig: asked for gear but the body wears only {equipped} item(s) — the leanest \
|
||||
@@ -479,9 +413,8 @@ fn drive_rig(
|
||||
}
|
||||
}
|
||||
|
||||
/// The GM batch, in the one order that works: revive first (a ghost half-applies everything else),
|
||||
/// level before gear (a premade template only levels *up*), spells after the level they belong to,
|
||||
/// and the teleport last so a fixup lands where you asked rather than where you started.
|
||||
/// The GM batch in the order that works: revive first (a ghost half-applies the rest), level
|
||||
/// before gear (a template only levels up), spells after the level, the teleport last.
|
||||
fn build_steps(spec: &RigSpec, dead: bool) -> Vec<String> {
|
||||
let mut steps = Vec::new();
|
||||
if dead {
|
||||
@@ -498,8 +431,7 @@ fn build_steps(spec: &RigSpec, dead: bool) -> Vec<String> {
|
||||
}
|
||||
for (token, verb) in [(&spec.gear, "gear"), (&spec.spec, "spec")] {
|
||||
if let Some(t) = token {
|
||||
// `?` asks the server to LIST this class's templates instead of applying one — the
|
||||
// catalog lives in the world DB, so the server is the only thing that can enumerate it.
|
||||
// `?` asks the server to list this class's templates instead of applying one.
|
||||
let arg = if t == "?" { "" } else { t.as_str() };
|
||||
steps.push(format!(".character premade {verb} {arg}").trim_end().into());
|
||||
}
|
||||
@@ -514,12 +446,9 @@ fn build_steps(spec: &RigSpec, dead: bool) -> Vec<String> {
|
||||
steps
|
||||
}
|
||||
|
||||
/// `map,x,y,z[,o]` → `.go xyz x y z map`, or `.go xyzo x y z o map` when a facing is given
|
||||
/// (VERIFIED vmangos `Chat.cpp:408`: `xyzo`, SEC_TICKETMASTER like `xyz`). Anything else is a
|
||||
/// `game_tele` name. The facing is load-bearing exactly when a motion probe walks from the
|
||||
/// pin: `W` follows the BODY's facing, and each probe character keeps whatever facing it last
|
||||
/// had — an unpinned facing sent probe0 and probe4 down different routes from the same point,
|
||||
/// which invalidated a cross-checkout A/B before 1462's sitting caught it.
|
||||
/// `map,x,y,z[,o]` for `.go xyz x y z map`, or `.go xyzo x y z o map` with a facing (vmangos
|
||||
/// `Chat.cpp:408`); anything else is a `game_tele` name. A motion probe pins the facing, since
|
||||
/// `W` follows the body's facing and a character keeps whatever facing it last had.
|
||||
fn parse_point(at: &str) -> Option<(i32, f32, f32, f32, Option<f32>)> {
|
||||
let mut parts = at.split(',').map(str::trim);
|
||||
let map = parts.next()?.parse().ok()?;
|
||||
@@ -544,27 +473,16 @@ fn rig_char_name(spec: &RigSpec) -> Option<String> {
|
||||
class_code(class)?,
|
||||
if gender == 1 { "f" } else { "" }
|
||||
);
|
||||
// vmangos caps player names at 12 (`MAX_PLAYER_NAME`); the longest this can build is
|
||||
// `Nel` + `Wlk` + `three` + `f` = 12. Normalized like the server does: leading capital, rest
|
||||
// lower.
|
||||
// vmangos caps player names at 12 (`ObjectMgr.h:398`); the longest built here is
|
||||
// `Nel` + `Wlk` + `three` + `f`. Normalized as the server does: leading capital, rest lower.
|
||||
let mut chars = name.chars();
|
||||
Some(chars.next()?.to_ascii_uppercase().to_string() + &chars.as_str().to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// The rig-named character of this checkout that costs least to lose: **lowest level first**, ties
|
||||
/// broken by oldest (the enum is ordered by `create_time` — vmangos `HandleCharEnumOpcode` — so an
|
||||
/// earlier index *is* older). Level is the proxy for invested setup: a level-1 body is 15 seconds to
|
||||
/// rebuild, a geared 60 is a minute and a talent tree. Evicting by age alone would throw away the
|
||||
/// most valuable body on the account first, which the live fill test made obvious.
|
||||
///
|
||||
/// Anything that is not a rig name for `word` — the probe body itself, a hand-made character,
|
||||
/// another checkout's leftovers — is invisible here, and that is what makes automatic deletion safe.
|
||||
///
|
||||
/// `word` is a **parameter, not a `rig_suffix()` call inside**: reading the ambient word here made
|
||||
/// the eviction test pass only in the checkout it was written in (whose rig names end `…one`) and
|
||||
/// fail in every other — and in a checkout with no declaration, where `rig_suffix()` is `None` and
|
||||
/// nothing matches at all. A unit test's answer must not depend on which directory the build
|
||||
/// happened in.
|
||||
/// This checkout's rig-named character cheapest to rebuild: lowest level first, ties to the oldest
|
||||
/// (the enum is ordered by `create_time`, vmangos `CharacterHandler.cpp:180`). Anything not a rig
|
||||
/// name for `word` is never chosen. `word` is a parameter so a test does not depend on the
|
||||
/// checkout it runs in.
|
||||
fn evictable(
|
||||
roster: &[benilla_protocol::Character],
|
||||
want: Option<&str>,
|
||||
@@ -592,8 +510,6 @@ fn is_rig_name(name: &str, word: &str) -> bool {
|
||||
has_codes && (rest == word || rest == format!("{word}f"))
|
||||
}
|
||||
|
||||
/// Race id → the 3-letter name code. `Nel`/`Und` rather than the DBC's own prefixes: unambiguous,
|
||||
/// and readable in a roster line.
|
||||
const RACE_CODES: [(u8, &str); 8] = [
|
||||
(1, "hum"),
|
||||
(2, "orc"),
|
||||
@@ -605,7 +521,7 @@ const RACE_CODES: [(u8, &str); 8] = [
|
||||
(8, "tro"),
|
||||
];
|
||||
|
||||
/// Class id → the 3-letter name code (`wlk` for warlock — `war` is already the warrior's).
|
||||
/// Class id to its 3-letter name code (`wlk` for warlock, as `war` is the warrior's).
|
||||
const CLASS_CODES: [(u8, &str); 9] = [
|
||||
(1, "war"),
|
||||
(2, "pal"),
|
||||
@@ -626,8 +542,7 @@ fn class_code(id: u8) -> Option<&'static str> {
|
||||
CLASS_CODES.iter().find(|(i, _)| *i == id).map(|(_, c)| *c)
|
||||
}
|
||||
|
||||
/// Spec word → `ChrRaces.dbc` id. Both the one-word and the two-word spellings, because a spec is
|
||||
/// typed by hand and `nightelf`/`night-elf` are the same intent.
|
||||
/// Spec word to `ChrRaces.dbc` id, with the common spellings.
|
||||
fn race_id(word: &str) -> Option<u8> {
|
||||
Some(match word {
|
||||
"human" => 1,
|
||||
@@ -642,7 +557,7 @@ fn race_id(word: &str) -> Option<u8> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Spec word → `ChrClasses.dbc` id (6 and 10 are unused in 1.12).
|
||||
/// Spec word to `ChrClasses.dbc` id (6 and 10 are unused in 1.12).
|
||||
fn class_id(word: &str) -> Option<u8> {
|
||||
Some(match word {
|
||||
"warrior" => 1,
|
||||
@@ -664,8 +579,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_naked_body_after_a_gear_ask_is_a_refusal_but_a_lean_template_is_not() {
|
||||
// The three partial templates this deploy really ships (5 and 8 items) must stay quiet —
|
||||
// the floor exists to catch a stripped body, not to second-guess the world DB.
|
||||
// The partial templates (5 and 8 items) must stay quiet.
|
||||
assert!(!gear_was_refused(Some("pvp-r14-hunter-fx"), 5));
|
||||
assert!(!gear_was_refused(Some("tank-r14"), 8));
|
||||
assert!(
|
||||
@@ -689,8 +603,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn template_names_keep_their_case() {
|
||||
// The token soup is matched case-insensitively, but a premade template name and a
|
||||
// `game_tele` name are looked up VERBATIM server-side — lowercasing them would break both.
|
||||
// Template and `game_tele` names are looked up verbatim server-side.
|
||||
let s = RigSpec::parse("at:ThunderBluff gear:DPS-PreRaid-BiS").unwrap();
|
||||
assert_eq!(s.at.as_deref(), Some("ThunderBluff"));
|
||||
assert_eq!(s.gear.as_deref(), Some("DPS-PreRaid-BiS"));
|
||||
@@ -698,12 +611,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_half_named_body_is_refused_rather_than_guessed() {
|
||||
// A race with no class (or the reverse) cannot name a character, and guessing the other
|
||||
// half would silently rig the wrong body.
|
||||
// A race with no class, or the reverse, cannot name a character.
|
||||
assert!(RigSpec::parse("tauren 60").is_none());
|
||||
assert!(RigSpec::parse("druid").is_none());
|
||||
assert!(RigSpec::parse("tauren druid").is_some());
|
||||
// No body at all is legal — that rigs whatever already logs in.
|
||||
// No body at all rigs whatever already logs in.
|
||||
assert_eq!(RigSpec::parse("60 gear:x").unwrap().body, None);
|
||||
// A typo is refused, never ignored.
|
||||
assert!(RigSpec::parse("taruen druid").is_none());
|
||||
@@ -784,9 +696,7 @@ mod tests {
|
||||
};
|
||||
assert_eq!(name("tauren druid", "one"), "taudruone");
|
||||
assert_eq!(name("nightelf warlock female", "three"), "nelwlkthreef");
|
||||
// The longest name this scheme can mint is exactly vmangos's MAX_PLAYER_NAME of 12 — a
|
||||
// 13th character would be refused by the server as an invalid name, and the rig would
|
||||
// recreate it every run.
|
||||
// The longest name this scheme can mint is exactly vmangos's `MAX_PLAYER_NAME`.
|
||||
const MAX_PLAYER_NAME: usize = 12;
|
||||
let longest = RACE_CODES
|
||||
.iter()
|
||||
@@ -836,17 +746,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn eviction_spends_the_body_that_is_cheapest_to_rebuild() {
|
||||
// The word is passed in, never read from the ambient checkout — these names are one
|
||||
// checkout's, and taking the word from `rig_suffix()` made this test pass there and fail
|
||||
// in every other checkout (and in one with no declaration, where it is `None`).
|
||||
// The word is passed in, never read from the checkout the test runs in.
|
||||
let slot = "one";
|
||||
// Roster order IS create order (vmangos enumerates by `create_time`).
|
||||
// Roster order is create order (vmangos enumerates by `create_time`).
|
||||
let roster = [
|
||||
row("Probeone", 60, 1), // the identity — never evictable
|
||||
row("Watcher", 60, 2), // hand-made — never evictable
|
||||
row("Probeone", 60, 1), // the identity, never evictable
|
||||
row("Watcher", 60, 2), // hand-made, never evictable
|
||||
row("Taudruone", 60, 3), // oldest rig body, but a geared 60
|
||||
row("Orcwarone", 1, 4), // a level-1 filler: the cheapest to lose
|
||||
row("Undmagone", 1, 5), // same level, but younger — the tie goes to the older
|
||||
row("Undmagone", 1, 5), // same level, but younger: the tie goes to the older
|
||||
row("Nelwlkonef", 40, 6),
|
||||
];
|
||||
assert_eq!(
|
||||
@@ -858,10 +766,9 @@ mod tests {
|
||||
evictable(&roster, Some("Orcwarone"), slot),
|
||||
Some((5, "Undmagone".into())),
|
||||
);
|
||||
// Nothing rig-named on this slot ⇒ nothing to evict; the caller errors rather than guessing.
|
||||
// Nothing rig-named on this slot, nothing to evict; the caller errors rather than guessing.
|
||||
assert_eq!(evictable(&roster[..2], None, slot), None);
|
||||
// And another checkout's leftovers are invisible: the same roster, read with another word,
|
||||
// evicts nothing. This is the assertion that would have caught the ambient read.
|
||||
// Another checkout's rig characters are invisible.
|
||||
assert_eq!(evictable(&roster, None, "three"), None);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
//! The **NPC-service ladder** live probe (`WOW_PROBE_SERVICE=1`) — decision 1861's end-to-end
|
||||
//! instrument, and the answer to "does right-clicking a trainer or a quest giver really open their
|
||||
//! window instead of a gossip menu?".
|
||||
//!
|
||||
//! 1861 replaced a cursor-kind dispatch with the reference's own first-match-wins walk over
|
||||
//! `UNIT_NPC_FLAGS` ([`crate::target::click::service_arm`]; `0x5f0130`). **Bit 0 (GOSSIP) is
|
||||
//! tested first**, so the flag — not the profession — decides: a trainer or questgiver that also
|
||||
//! carries GOSSIP still opens a gossip menu, and only a flagless one opens its own window. That
|
||||
//! precedence is the whole question, and it is invisible to a unit test: the bits come off the
|
||||
//! wire, and what appears on screen is the *server's* answer to the opcode the ladder picked.
|
||||
//!
|
||||
//! So the probe walks four real Northshire/Stormwind NPCs, one per interesting flag shape, and for
|
||||
//! each one reports the wire's `UNIT_NPC_FLAGS`, the arm the shipped ladder takes, the opcode it
|
||||
//! sends, and which window actually opened:
|
||||
//! The NPC-service live probe (`WOW_PROBE_SERVICE=1`): does right-clicking a trainer or a quest
|
||||
//! giver open their window rather than a gossip menu? The click walks `UNIT_NPC_FLAGS` first
|
||||
//! match wins ([`crate::target::click::service_arm`]; `0x5f0130`), bit 0 (GOSSIP) first, so a
|
||||
//! trainer or questgiver that also carries GOSSIP opens a gossip menu. The probe hops to four real
|
||||
//! NPCs, calls the shipped `service_arm`/`service_action` on the wire's flags and
|
||||
//! [`QuestGiver::status`], and reports the arm, the opcode and the window that opened:
|
||||
//!
|
||||
//! | NPC | `npc_flags` | arm | window |
|
||||
//! |---|---|---|---|
|
||||
@@ -20,37 +12,16 @@
|
||||
//! | *any pure questgiver with an offer* | `0x02`, GOSSIP clear | Questgiver | quest frame |
|
||||
//! | Alma Jainrose (812) | `0x10` TRAINER only | Trainer | trainer frame |
|
||||
//!
|
||||
//! The flag column is live-DB verified against the local vmangos (`mangos`,
|
||||
//! `SELECT entry, name, npc_flags FROM creature_template`), and reported again from the wire on
|
||||
//! every run — a template edit shows up as a note beside the reading rather than as a mystery FAIL.
|
||||
//! The pure-questgiver row is found by predicate: the bit-1 arm is gated on the quest status
|
||||
//! (`[unit+0xcb8] ∉ {0,1}`), which depends on what the character has turned in, so the leg takes
|
||||
//! the first streamed unit with QUESTGIVER, no GOSSIP and an offer, and SKIPs if there is none.
|
||||
//! Each leg also checks that the re-click gate arms with the window. A last leg forks the pure
|
||||
//! vendor: an empty cursor opens the list, a held item sells.
|
||||
//!
|
||||
//! **The ladder is called, never re-implemented.** The probe reads the same two inputs the click
|
||||
//! reads (`ObjectStore::unit_npc_flags` and [`QuestGiver::status`]) and calls the shipped
|
||||
//! `service_arm`/`service_action`; only the mouse hit-test and the cursor's range gray sit outside
|
||||
//! it. A private copy of the bit table is exactly how a probe goes quietly stale (the B249 icon
|
||||
//! map), so there isn't one.
|
||||
//!
|
||||
//! **The pure-questgiver row is found by predicate, not by entry.** Whether Deputy Willem has
|
||||
//! anything for *this* probe character depends on what it has already turned in, and the bit-1 arm
|
||||
//! is gated on that status (`[unit+0xcb8] ∉ {0,1}`). So that leg hops into Northshire and takes the
|
||||
//! first streamed unit that is QUESTGIVER, not GOSSIP, and actually has an offer — reporting which
|
||||
//! one it picked. If the character has cleared the whole valley it SKIPs and says so.
|
||||
//!
|
||||
//! One `PROBE_SERVICE: <leg> PASS/FAIL/SKIP <detail>` line per leg, then a final
|
||||
//! `PROBE_SERVICE: DONE pass=<n> fail=<m> skip=<k>`. A wrong arm or a wrong window is a FAIL; an
|
||||
//! environmental problem (the `.go` refused, the NPC never streamed, no offer left in the valley)
|
||||
//! is a SKIP with the reason.
|
||||
//!
|
||||
//! ## The run recipe
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_DATA=WoW/Data WOW_USER=probe4 WOW_PASS=pprobe4 WOW_CHAR=Probefour \
|
||||
//! WOW_UNATTENDED=1 WOW_PROBE_SERVICE=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill)). Non-combat, GM mode
|
||||
//! left as found, nothing bought and nothing turned in: every leg opens a window and closes it
|
||||
//! client-side, which is what the reference's own close does (`ui_gossip`: there is no
|
||||
//! `CMSG_GOSSIP_CLOSE` in 1.12).
|
||||
//! One `PROBE_SERVICE: <leg> PASS/FAIL/SKIP <detail>` line per leg, then `PROBE_SERVICE: DONE
|
||||
//! pass=<n> fail=<m> skip=<k>`. A wrong arm or window FAILs; an environmental problem SKIPs.
|
||||
//! Non-combat; each window is closed client-side, as the reference's own close is (1.12 has no
|
||||
//! `CMSG_GOSSIP_CLOSE`). The switches are `docs/CONTRIBUTING.md`, "Running it unattended".
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -69,56 +40,38 @@ use crate::ui_quest::QuestGiver;
|
||||
use crate::ui_session::InteractNpc;
|
||||
use crate::ui_trainer::TrainerOpen;
|
||||
|
||||
/// How wide to look around a `.go` landing for the leg's NPC — **and no wider than the reference's
|
||||
/// own service reach**, because the click cannot act past it either: beyond
|
||||
/// [`crate::target::SERVICE_RANGE_SQ`] (5.5556 yd, `0xc4c28c`/`0xb4b32c`) the cursor goes gray, the
|
||||
/// click sends nothing, and vmangos's `GetNPCIfCanInteractWith` refuses the opcode silently.
|
||||
///
|
||||
/// This constant is the reason the questgiver leg first read as a client defect: its hop point was
|
||||
/// an invented Northshire "hub" ~29 yd from Deputy Willem, so the probe called a ladder the click
|
||||
/// would never have reached, the server dropped the packet on its own distance check, and the
|
||||
/// timeout printed a FAIL about the client (docs/METHOD.md §6 — prove the run before reading the
|
||||
/// result). Every hop point is now an NPC's own spawn, and the scan re-checks the reach.
|
||||
/// How wide to look around a `.go` landing: the reference's service reach,
|
||||
/// [`crate::target::SERVICE_RANGE_SQ`] (5.5556 yd, `0xc4c28c`/`0xb4b32c`). Past it the cursor goes
|
||||
/// gray, the click sends nothing, and vmangos's `GetNPCIfCanInteractWith` refuses silently.
|
||||
const SCAN_RANGE_SQ: f32 = crate::target::SERVICE_RANGE_SQ;
|
||||
/// Let the hop land and the tile stream before the first scan.
|
||||
const SETTLE_SECS: f64 = 3.0;
|
||||
/// How long a leg waits for its NPC to stream in before calling the hop environmental.
|
||||
const SCAN_TIMEOUT_SECS: f64 = 20.0;
|
||||
/// How long a leg waits for its window after the opcode goes out. Generous for the same reason the
|
||||
/// binder probe's is: the observer is starved during a terrain load, never the wire.
|
||||
/// How long a leg waits for its window after the opcode goes out; a terrain load starves the
|
||||
/// observer, not the wire.
|
||||
const WINDOW_TIMEOUT_SECS: f64 = 20.0;
|
||||
/// How long the re-click gate's latch may lag the window it belongs to.
|
||||
///
|
||||
/// It no longer lags by construction: `feed_interact_npc` is seated after the net apply since
|
||||
/// decision 2022, so the token arms on the very frame the window opens. It USED to be allowed to
|
||||
/// lag a frame (`ui_session` was "deliberately unordered" against the apply, on decision 1741's
|
||||
/// reading that a window's first frame is invisible) — and "one frame" and "never" are the same
|
||||
/// reading if you sample once, which is exactly what the first run of this assert did: it failed
|
||||
/// the quest and trainer legs, whose windows open on the packet, and passed the gossip legs only
|
||||
/// because the gossip frame holds shut for several frames waiting on its greeting query (B292).
|
||||
/// The poll stays for the other reason a single sample lies — a starved observer during a terrain
|
||||
/// load — and prints how long the latch actually took.
|
||||
/// How long the re-click gate's latch may lag its window. `feed_interact_npc` runs after the net
|
||||
/// apply, so the token arms on the window's own frame; the poll covers a starved observer and
|
||||
/// reports the lag.
|
||||
const GATE_TIMEOUT_SECS: f64 = 3.0;
|
||||
|
||||
/// **The vendor fork's leg** (decision 1914) — Brother Danil, `creature_template.entry = 152`,
|
||||
/// `npc_flags = 4` (VENDOR and nothing else, so the ladder cannot reach him by any other arm),
|
||||
/// spawned in Northshire at his own `creature.position_*` (live-DB verified this session).
|
||||
/// The vendor fork's NPC: Brother Danil (entry 152), `npc_flags` 4, vendor only, so no other arm
|
||||
/// reaches him; at his own Northshire spawn.
|
||||
const VENDOR_ENTRY: u32 = 152;
|
||||
const VENDOR_AT: [f32; 3] = [-8901.59, -112.716, 82.0314];
|
||||
const VENDOR_MAP: u32 = 0;
|
||||
/// Tough Jerky — a 1-copper vendor trash item, `.additem`'d so the sell leg has something of its
|
||||
/// own to sell and never touches whatever the probe character was already carrying.
|
||||
/// Tough Jerky, a 1-copper item `.additem`ed so the sell leg sells only its own copy.
|
||||
const SELL_ITEM_ENTRY: u32 = 117;
|
||||
/// How long to wait for `.additem` to land in a bag, and for the sold item to leave it.
|
||||
const BAG_TIMEOUT_SECS: f64 = 20.0;
|
||||
|
||||
/// Which NPC a leg is looking for.
|
||||
enum Ident {
|
||||
/// A specific `creature_template.entry` — the identity check that cannot be confused by a
|
||||
/// neighbour who happens to share a flag.
|
||||
/// A specific `creature_template.entry`, which a neighbour sharing a flag cannot match.
|
||||
Entry(u32),
|
||||
/// The bit-1 arm's own shape: QUESTGIVER set, GOSSIP clear, and an offer live for THIS
|
||||
/// character (`SMSG_QUESTGIVER_STATUS` ∉ {0,1}) — see the module doc.
|
||||
/// The bit-1 arm's shape: QUESTGIVER set, GOSSIP clear, and an offer live for this character
|
||||
/// (`SMSG_QUESTGIVER_STATUS` ∉ {0,1}).
|
||||
PureQuestgiverWithOffer,
|
||||
}
|
||||
|
||||
@@ -136,20 +89,17 @@ struct Leg {
|
||||
name: &'static str,
|
||||
ident: Ident,
|
||||
map: u32,
|
||||
/// The spawn points to try, in order — each an NPC's own `creature.position_*`, so the landing
|
||||
/// is inside the service reach. A leg with several is one whose subject depends on what this
|
||||
/// character has already turned in (the questgiver leg); it moves to the next candidate when
|
||||
/// the one it hopped to has nothing to offer.
|
||||
/// The spawn points to try, in order, each an NPC's own position so the landing is inside the
|
||||
/// service reach; the next is tried when one has nothing to offer.
|
||||
at: &'static [[f32; 3]],
|
||||
/// `creature_template.npc_flags` as the world DB held it when this probe was written — printed
|
||||
/// beside the wire's value so a template edit reads as a note rather than a mystery.
|
||||
/// `creature_template.npc_flags` in the world DB, printed beside the wire's value.
|
||||
db_flags: u32,
|
||||
want_arm: ServiceArm,
|
||||
want_window: Window,
|
||||
}
|
||||
|
||||
/// The four flag shapes worth a live reading (module doc's table). Northshire first — three of the
|
||||
/// four legs share one hop's worth of terrain — then the Stormwind first-aid trainer.
|
||||
/// The four flag shapes worth a live reading (the module doc's table): Northshire, then the
|
||||
/// Stormwind first-aid trainer.
|
||||
const LEGS: &[Leg] = &[
|
||||
Leg {
|
||||
name: "Marshal McBride — GOSSIP|QUESTGIVER",
|
||||
@@ -173,8 +123,8 @@ const LEGS: &[Leg] = &[
|
||||
name: "a pure questgiver with an offer — QUESTGIVER, no GOSSIP",
|
||||
ident: Ident::PureQuestgiverWithOffer,
|
||||
map: 0,
|
||||
// Deputy Willem (823), Eagan Peltskinner (196), Falkhaan Isenstrider (6774) — the three
|
||||
// pure questgivers of Northshire, each at its own spawn (live-DB verified this session).
|
||||
// Northshire's three pure questgivers at their own spawns: Deputy Willem, Eagan
|
||||
// Peltskinner and Falkhaan Isenstrider (entries 823, 196, 6774).
|
||||
at: &[
|
||||
[-8933.54, -136.523, 83.4466],
|
||||
[-8869.22, -163.237, 80.9719],
|
||||
@@ -210,14 +160,10 @@ struct ServiceProbe {
|
||||
passes: u32,
|
||||
fails: u32,
|
||||
skips: u32,
|
||||
/// How many frames the current scan actually got to poll in. A starved observer and a missing
|
||||
/// NPC look identical in a SKIP line otherwise — and on a loaded machine the first is far more
|
||||
/// likely: at load 61 this probe logged `frame hitch: ~1010 ms` on repeat (the ~1 fps regime
|
||||
/// decisions 0713/0777/1355 name) and its 20-second scan window bought about twenty samples.
|
||||
/// A leg runner answers the same problem with a load guard (1157); a probe that does one thing and
|
||||
/// exits is better served by reporting what it actually got.
|
||||
/// How many frames the current scan got to poll in, so a SKIP tells a starved observer from a
|
||||
/// missing NPC.
|
||||
polls: u32,
|
||||
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
|
||||
/// Latched once [`Phase::Done`] has fired its exit.
|
||||
exited: bool,
|
||||
}
|
||||
|
||||
@@ -238,19 +184,18 @@ enum Phase {
|
||||
since: f64,
|
||||
guid: u64,
|
||||
},
|
||||
/// The window is open; waiting for the re-click gate's `"npc"` token to name its NPC
|
||||
/// (decision 1905). Its own phase because the latch is allowed to lag the window by a frame —
|
||||
/// see [`GATE_TIMEOUT_SECS`].
|
||||
/// The window is open; waiting for the re-click gate's `"npc"` token to name its NPC (see
|
||||
/// [`GATE_TIMEOUT_SECS`]).
|
||||
Gate {
|
||||
i: usize,
|
||||
since: f64,
|
||||
guid: u64,
|
||||
},
|
||||
/// The vendor fork's own chain (decision 1914) — `.go` to the pure vendor.
|
||||
/// The vendor fork's own chain: `.go` to the pure vendor.
|
||||
VendorHop {
|
||||
sent_at: f64,
|
||||
},
|
||||
/// The EMPTY-cursor leg: `CMSG_LIST_INVENTORY` sent, waiting for the merchant window.
|
||||
/// The empty-cursor leg: `CMSG_LIST_INVENTORY` sent, waiting for the merchant window.
|
||||
VendorOpen {
|
||||
since: f64,
|
||||
guid: u64,
|
||||
@@ -260,7 +205,7 @@ enum Phase {
|
||||
since: f64,
|
||||
guid: u64,
|
||||
},
|
||||
/// The HELD-cursor leg: `CMSG_SELL_ITEM` sent, waiting for the slot to empty.
|
||||
/// The held-cursor leg: `CMSG_SELL_ITEM` sent, waiting for the slot to empty.
|
||||
VendorSell {
|
||||
since: f64,
|
||||
bag: i64,
|
||||
@@ -269,9 +214,7 @@ enum Phase {
|
||||
Done,
|
||||
}
|
||||
|
||||
/// The bag slot holding [`SELL_ITEM_ENTRY`], as `"bag,slot"`, or `""` — asked of the live VM
|
||||
/// through the same bindings a player's bag UI uses, so the probe never needs its own view of the
|
||||
/// container fields.
|
||||
/// The bag slot holding [`SELL_ITEM_ENTRY`], asked of the live VM through the bag UI's bindings.
|
||||
fn find_item_slot(script: &UiScript) -> Option<(i64, u32)> {
|
||||
let found = script
|
||||
.eval::<String>(&format!(
|
||||
@@ -288,8 +231,7 @@ fn find_item_slot(script: &UiScript) -> Option<(i64, u32)> {
|
||||
Some((b.parse().ok()?, s.parse().ok()?))
|
||||
}
|
||||
|
||||
/// A flag word as the log wants it — the hex plus the bit names, so a reader never has to decode
|
||||
/// `0x13` by hand.
|
||||
/// A flag word as hex plus bit names.
|
||||
fn flag_names(flags: u32) -> String {
|
||||
const NAMES: [(u32, &str); 14] = [
|
||||
(f::GOSSIP, "GOSSIP"),
|
||||
@@ -318,15 +260,15 @@ fn flag_names(flags: u32) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which window is open on `guid`, if any — the observation every leg is judged on.
|
||||
/// Which window is open on `guid`, if any.
|
||||
fn open_window(
|
||||
guid: u64,
|
||||
gossip: &GossipState,
|
||||
giver: &QuestGiver,
|
||||
trainer: &TrainerOpen,
|
||||
) -> Option<Window> {
|
||||
// The gossip frame does not open on the packet: it holds closed until the greeting's
|
||||
// `CMSG_NPC_TEXT_QUERY` answers (B292's hold, `ui_gossip`), so the greeting is part of "open".
|
||||
// The gossip frame stays closed until the greeting's `CMSG_NPC_TEXT_QUERY` answers
|
||||
// (`ui_gossip`), so the greeting is part of open.
|
||||
if gossip.npc == Some(guid) && gossip.greeting.is_some() {
|
||||
return Some(Window::Gossip);
|
||||
}
|
||||
@@ -345,13 +287,10 @@ fn service_probe(
|
||||
mut gossip: ResMut<GossipState>,
|
||||
mut giver: ResMut<QuestGiver>,
|
||||
mut trainer: ResMut<TrainerOpen>,
|
||||
// `[0xb4e2d0]`'s mirror — what the re-click gate reads (decision 1905). The probe asserts it
|
||||
// live because the gate's risk is never the `==`, it is whether this really is armed by a
|
||||
// window and only by a window.
|
||||
// `[0xb4e2d0]`'s mirror, what the re-click gate reads: armed by a window and only by one.
|
||||
interact: Res<InteractNpc>,
|
||||
// The vendor fork's leg (decision 1914): the window the empty-cursor leg must open, the VM the
|
||||
// held-cursor leg picks an item up in, and the two the app needs to turn a (bag, slot) into
|
||||
// the guid the wire addresses.
|
||||
// The vendor fork: the merchant window, the VM the held-cursor leg picks up in, and what
|
||||
// turns a (bag, slot) into the item guid.
|
||||
mut merchant: ResMut<MerchantOpen>,
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
objects: crate::net::Objects,
|
||||
@@ -380,7 +319,7 @@ fn service_probe(
|
||||
let me = player.pos;
|
||||
let found = units.iter().find(|(guid, net_e, store, tf)| {
|
||||
// The click's own gate: `dist² > SERVICE_RANGE_SQ` is the cursor's `unable`, and
|
||||
// the arm never runs. Acting outside it would measure nothing.
|
||||
// the arm never runs.
|
||||
if net_e.kind != EntityKind::Unit
|
||||
|| tf.translation.distance_squared(me) > SCAN_RANGE_SQ
|
||||
{
|
||||
@@ -421,8 +360,7 @@ fn service_probe(
|
||||
SCAN_RANGE_SQ.sqrt(),
|
||||
leg.at.len(),
|
||||
match fps < 5.0 {
|
||||
// The reading is about the OBSERVER, not the world: below a handful of
|
||||
// frames a second this leg never really looked (0713/0777/1355).
|
||||
// Below a handful of frames a second the leg never really looked.
|
||||
true =>
|
||||
" — THE OBSERVER WAS STARVED, so this SKIP says nothing about \
|
||||
the NPC. Re-run on an idle machine (`uptime`; a leg runner's guard \
|
||||
@@ -487,11 +425,8 @@ fn service_probe(
|
||||
giver.clear();
|
||||
trainer.clear();
|
||||
|
||||
// **The first click must always get through** (decision 1905). Nothing is open here —
|
||||
// this leg has just cleared, and the reference arms `[0xb4e2d0]` from window openers
|
||||
// only — so the re-click gate must read disarmed at the moment of the send. If it ever
|
||||
// reads armed here, the gate would be eating first clicks and this probe would still
|
||||
// pass every other leg, which is why the assert is at the send and not after it.
|
||||
// The first click must get through: nothing is open, and the reference arms
|
||||
// `[0xb4e2d0]` only from window openers, so the gate must read disarmed at the send.
|
||||
if interact.1.is_some() {
|
||||
error!(
|
||||
"PROBE_SERVICE: FAIL ({}) — the npc token is armed on {:#x} BEFORE the send; \
|
||||
@@ -504,8 +439,7 @@ fn service_probe(
|
||||
return;
|
||||
}
|
||||
|
||||
// `None` = an empty cursor: no leg here holds an item, and the vendor arm's fork is
|
||||
// the merchant probe's business, not this one's (decision 1914).
|
||||
// `None` = an empty cursor; the vendor fork's held-cursor leg runs after the ladder.
|
||||
let sent = match service_action(arm, guid, false, None) {
|
||||
ServiceAction::Send(cmd) => {
|
||||
let named = format!("{cmd:?}");
|
||||
@@ -587,9 +521,8 @@ fn service_probe(
|
||||
next(&mut probe, &net, i, now);
|
||||
}
|
||||
None if now - since > WINDOW_TIMEOUT_SECS => {
|
||||
// Distinguish the one near-miss that is a real defect from a dead wire: the
|
||||
// gossip session latched but its greeting never resolved, so the frame stayed
|
||||
// shut (`GossipState::resolve_greeting`'s "Missing gossip text!" path).
|
||||
// A gossip session latched with an unresolved greeting keeps the frame shut
|
||||
// (`GossipState::resolve_greeting`'s "Missing gossip text!" path).
|
||||
let held = gossip.npc == Some(guid) && gossip.greeting.is_none();
|
||||
error!(
|
||||
"PROBE_SERVICE: FAIL ({}) — no {:?} window on {guid:#x} within \
|
||||
@@ -633,7 +566,7 @@ fn service_probe(
|
||||
next(&mut probe, &net, i, now);
|
||||
}
|
||||
}
|
||||
// ── The vendor fork (decision 1914) ──────────────────────────────────────────────────
|
||||
// ── The vendor fork ──────────────────────────────────────────────────
|
||||
Phase::VendorHop { sent_at } => {
|
||||
if now - sent_at < SETTLE_SECS {
|
||||
return;
|
||||
@@ -662,8 +595,8 @@ fn service_probe(
|
||||
trainer.clear();
|
||||
merchant.clear();
|
||||
|
||||
// The EMPTY-cursor leg. The arm is asserted, not assumed: a vendor who had grown a
|
||||
// GOSSIP bit would take the gossip arm and this leg would be measuring nothing.
|
||||
// The empty-cursor leg. The arm is asserted: a vendor with a GOSSIP bit takes the
|
||||
// gossip arm.
|
||||
let arm = service_arm(flags, None);
|
||||
if arm != Some(ServiceArm::Vendor) {
|
||||
warn!(
|
||||
@@ -738,7 +671,7 @@ fn service_probe(
|
||||
}
|
||||
return;
|
||||
};
|
||||
// Pick it up exactly as a player does — through the binding, not by writing the model.
|
||||
// Pick it up as a player does, through the binding, not by writing the model.
|
||||
if let Err(e) = script.run(&format!("PickupContainerItem({bag}, {slot})")) {
|
||||
error!("PROBE_SERVICE: SKIP (vendor fork, held cursor) — PickupContainerItem: {e}");
|
||||
probe.skips += 1;
|
||||
@@ -809,8 +742,7 @@ fn service_probe(
|
||||
probe.phase = Phase::Done;
|
||||
return;
|
||||
};
|
||||
// The server's own verdict: the sold stack leaves the bag. Nothing client-side can
|
||||
// fake this — it is the inventory update answering the packet.
|
||||
// The server's verdict: the sold stack leaves the bag.
|
||||
let gone = script
|
||||
.eval::<bool>(&format!(
|
||||
"return GetContainerItemLink({bag}, {slot}) == nil"
|
||||
@@ -843,9 +775,8 @@ fn service_probe(
|
||||
"PROBE_SERVICE: DONE pass={} fail={} skip={}",
|
||||
probe.passes, probe.fails, probe.skips
|
||||
);
|
||||
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
|
||||
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
|
||||
// client holding the probe account.
|
||||
// `ProbeExitPlugin::fire_probe_exit`'s pattern: `AppExit` plus a hard backstop, so a
|
||||
// teardown hang cannot leave a client holding the probe account.
|
||||
exit.write(AppExit::Success);
|
||||
std::thread::spawn(|| {
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
@@ -856,8 +787,7 @@ fn service_probe(
|
||||
}
|
||||
}
|
||||
|
||||
/// The open quest panel's shape, for the PASS line — `Greeting` (the multi-quest list) vs
|
||||
/// `Detail` (the single quest's own frame) is exactly the distinction a reader wants here.
|
||||
/// The open quest panel's shape, for the PASS line.
|
||||
fn discr(view: &crate::ui_quest::QuestView) -> &'static str {
|
||||
use crate::ui_quest::QuestView as V;
|
||||
match view {
|
||||
|
||||
@@ -1,56 +1,28 @@
|
||||
//! The meeting-stone live probe (`WOW_PROBE_STONE=1`) — decision 2283's instrument: **can a
|
||||
//! player actually get into the LFG queue, and does everything downstream of that light up?**
|
||||
//! The meeting-stone live probe (`WOW_PROBE_STONE=1`): joins a real meeting stone's LFG queue and
|
||||
//! reads the answer out of the live VM. A stone is joined with `CMSG 0x292`; vmangos ignores a
|
||||
//! `CMSG_GAMEOBJ_USE` on type 23 (`GameObject.cpp:1836`).
|
||||
//!
|
||||
//! Until 2283 a right-click on a meeting stone sent `CMSG_GAMEOBJ_USE`, which vmangos' own
|
||||
//! `GameObject::Use` answers with an explicit `return` for type 23. So the whole receiving half —
|
||||
//! the two globals, the five-way status table, `MEETINGSTONE_CHANGED`, the stock
|
||||
//! `MiniMapMeetingStoneFrame` — had been built and tested against synthetic packets and had never
|
||||
//! once run off a real one. This probe is what closes that: it drives the click's own route and
|
||||
//! then reads the answer out of the **live VM**, not out of our own state.
|
||||
//!
|
||||
//! **Why a probe and not a capture:** none of this is a picture. Every reading here is a number or
|
||||
//! a Lua truth value — the queued area id, `IsInMeetingStoneQueue()`, whether the stock minimap
|
||||
//! button is shown — so the verdict needs no eye in the loop (`docs/METHOD.md` step 4).
|
||||
//!
|
||||
//! **Where it enters the chain, exactly.** It writes the very
|
||||
//! [`MeetingStoneUse`] the click ladder writes, so everything **downstream** of that seam runs
|
||||
//! for real — the validator, `CMSG 0x292`, the server, `SMSG 0x295`, the feed, the two globals,
|
||||
//! the stock frame. It does **not** cover the mouse hit-test or the shared gates *above* the seam
|
||||
//! (highlightable, mounted, range); those are the click test's
|
||||
//! It writes the [`MeetingStoneUse`] the click ladder writes, so everything downstream runs for
|
||||
//! real (the validator, `CMSG 0x292`, `SMSG 0x295`, the feed, the two globals, the stock frame).
|
||||
//! The hit-test and the gates above that seam are the click test's
|
||||
//! (`target::click::tests::a_right_click_on_a_meeting_stone_joins_it_and_sends_no_gameobj_use`).
|
||||
//! Neither alone closes the chain; together they do.
|
||||
//!
|
||||
//! ## The five legs, in order
|
||||
//! The five legs, in order:
|
||||
//!
|
||||
//! 1. **CONTROL** — parked at the stone, nothing clicked: `IsInMeetingStoneQueue()` must be `nil`
|
||||
//! and the stock icon hidden. A probe that only ever sees the queued state cannot tell a fix
|
||||
//! from a stuck flag.
|
||||
//! 2. **REFUSED** — the body demoted **below** the stone's `data[0]`, then clicked: the client's
|
||||
//! own level refusal must fire, and **nothing** may reach the wire. This is the one of the four
|
||||
//! refusals the server does not duplicate — `HandleMeetingStoneJoinOpcode` never looks at the
|
||||
//! stone's level band — so if our copy is wrong, the server queues a level-1 character for
|
||||
//! Deadmines and nobody notices.
|
||||
//! 3. **JOINED** — the body levelled **into** the band, then clicked: `SMSG 0x295` must arrive with
|
||||
//! this stone's own `data[2]`, `IsInMeetingStoneQueue()` must answer the number `1`,
|
||||
//! `GetMeetingStoneStatusText()` a real string, and `MiniMapMeetingStoneFrame` must be shown.
|
||||
//! 4. **DARKENED** — with that queue held, the stone's own `highlightable` slot must now refuse it
|
||||
//! ([`crate::target::cursor_mode::meeting_stone_queued`] over the live area): the stone you are
|
||||
//! queued at stops being clickable, which is the whole of `0x5f6990`.
|
||||
//! 5. **LEFT** — `CancelMeetingStoneRequest()` through the live VM: back to `nil`, icon hidden.
|
||||
//! 1. Control: parked, nothing clicked: `IsInMeetingStoneQueue()` is `nil`, the icon hidden.
|
||||
//! 2. Refused: demoted below the stone's `data[0]`, then clicked: the client's level refusal fires
|
||||
//! and nothing reaches the wire. The server never checks the level band
|
||||
//! (`LFGHandler.cpp:31`), so this refusal is the client's alone.
|
||||
//! 3. Joined: levelled into the band, then clicked: `SMSG 0x295` carries the stone's `data[2]`,
|
||||
//! `IsInMeetingStoneQueue()` is `1`, `GetMeetingStoneStatusText()` a string, and
|
||||
//! `MiniMapMeetingStoneFrame` shown.
|
||||
//! 4. Darkened: the stone queued at is no longer highlightable
|
||||
//! ([`crate::target::cursor_mode::meeting_stone_queued`], `0x5f6990`).
|
||||
//! 5. Left: `CancelMeetingStoneRequest()` through the VM: back to `nil`, icon hidden.
|
||||
//!
|
||||
//! ## The run recipe
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_NOSOUND=1 WOW_UNATTENDED=1 WOW_USER=probe3 WOW_PASS=pprobe3 WOW_CHAR=Probethree \
|
||||
//! WOW_PROBE_STONE=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the checkout's probe identity — `.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR; the `probe`
|
||||
//! skill). `WOW_PROBE_STONE=<x>,<y>,<z>[,<map>]` aims it elsewhere.
|
||||
//!
|
||||
//! **It leaves the probe body at the stone's minimum level**, deliberately and without putting it
|
||||
//! back: the level is the probe's own instrument (leg 2 needs one side of the band and leg 3 the
|
||||
//! other), and a probe character's level is scratch. Grep `PROBE_STONE:` for the verdict; the
|
||||
//! probe self-exits when it lands.
|
||||
//! `WOW_PROBE_STONE=<x>,<y>,<z>[,<map>]` aims it elsewhere. It leaves the body at the stone's
|
||||
//! minimum level. Grep `PROBE_STONE:` for the verdict; the switches are `docs/CONTRIBUTING.md`,
|
||||
//! "Running it unattended".
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -64,24 +36,20 @@ use crate::player::Player;
|
||||
use crate::target::cursor_mode::meeting_stone_queued;
|
||||
use crate::ui_dialog_verbs::{MeetingStone, MeetingStoneUse};
|
||||
|
||||
/// The probe's default object: the **Stockade** meeting stone in Stormwind (`gameobject.guid`
|
||||
/// 26635, template 179595 — `data[0..2] = 24, 32, 717`). Chosen because it is a plain unpooled
|
||||
/// spawn in a capital, its level band is narrow enough that a level-1 body is comfortably outside
|
||||
/// it, and area 717 resolves to a real `AreaTable` row, so the status text comes out a sentence
|
||||
/// ("Looking for more for The Stockade") rather than the `UNKNOWN` fallback — which makes the
|
||||
/// JOINED leg's `text=yes` mean the whole rebuild ran, not just that a string exists.
|
||||
/// The default object: the Stockade meeting stone in Stormwind (`gameobject.guid` 26635,
|
||||
/// template 179595, `data[0..2] = 24, 32, 717`), an unpooled spawn whose area 717 is a real
|
||||
/// `AreaTable` row, so the status text is a sentence rather than the `UNKNOWN` fallback.
|
||||
const STONE_AT: [f32; 4] = [-8810.5, 798.0, 98.2, 0.0];
|
||||
/// `GAMEOBJECT_TYPE_MEETINGSTONE`.
|
||||
const GO_TYPE_MEETINGSTONE: i32 = 23;
|
||||
/// The level leg 2 demotes to — below every shipped stone's `data[0]`.
|
||||
/// The level leg 2 demotes to, below every shipped stone's `data[0]`.
|
||||
const LEVEL_BELOW: u32 = 1;
|
||||
|
||||
const SCAN_RANGE: f32 = 30.0;
|
||||
const SETTLE_SECS: f64 = 6.0;
|
||||
const SCAN_TIMEOUT_SECS: f64 = 30.0;
|
||||
const LEVEL_TIMEOUT_SECS: f64 = 20.0;
|
||||
/// How long a refusal is given to *not* happen. The join round trip below is the same server, so
|
||||
/// anything that was going to arrive has arrived.
|
||||
/// How long the refused click is watched for a reply that must not come.
|
||||
const REFUSAL_WINDOW_SECS: f64 = 6.0;
|
||||
const QUEUE_TIMEOUT_SECS: f64 = 20.0;
|
||||
|
||||
@@ -108,7 +76,7 @@ struct StoneProbe {
|
||||
tmpl: Option<MeetingStoneTemplate>,
|
||||
fails: u32,
|
||||
exited: bool,
|
||||
/// What each leg actually read, for the DONE line.
|
||||
/// What each leg read, for the DONE line.
|
||||
joined_area: u32,
|
||||
status_text: Option<String>,
|
||||
icon_shown: bool,
|
||||
@@ -127,9 +95,8 @@ enum Phase {
|
||||
since: f64,
|
||||
},
|
||||
Control,
|
||||
/// `.character level` issued; waiting for the store to report it. `join` says which leg the
|
||||
/// click that follows belongs to — **not** the level value, because a stone whose `data[0]`
|
||||
/// is 1 would make the two indistinguishable and loop the probe forever.
|
||||
/// `.character level` issued; waiting for the store to report it. `join` names the next
|
||||
/// click's leg, since a stone whose `data[0]` is 1 makes the two levels equal.
|
||||
WaitLevel {
|
||||
want: u32,
|
||||
since: f64,
|
||||
@@ -151,8 +118,7 @@ enum Phase {
|
||||
Done,
|
||||
}
|
||||
|
||||
/// Where the probe is aimed: `WOW_PROBE_STONE=<x>,<y>,<z>[,<map>]`, else [`STONE_AT`]. Anything
|
||||
/// unparseable falls back to the default rather than failing the run — the common value is `1`.
|
||||
/// `WOW_PROBE_STONE=<x>,<y>,<z>[,<map>]`, else [`STONE_AT`] (the common value is `1`).
|
||||
fn target() -> [f32; 4] {
|
||||
let Ok(raw) = std::env::var("WOW_PROBE_STONE") else {
|
||||
return STONE_AT;
|
||||
@@ -168,8 +134,7 @@ fn target() -> [f32; 4] {
|
||||
}
|
||||
}
|
||||
|
||||
/// One leg's verdict line. `got == want` is the whole test; the line says both either way, so a
|
||||
/// failure reads as a measurement rather than as an assertion name.
|
||||
/// One leg's verdict line, saying both `want` and `got`.
|
||||
fn check(label: &str, want: &str, got: &str) -> u32 {
|
||||
if want == got {
|
||||
info!("PROBE_STONE: {label:<9} PASS — {got}");
|
||||
@@ -180,8 +145,7 @@ fn check(label: &str, want: &str, got: &str) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The three live VM readings every leg is written in, asked of the **stock files**: the two
|
||||
/// bindings `Minimap.xml` calls, and whether its button is actually up.
|
||||
/// The live VM readings: the two bindings stock `Minimap.xml` calls, and whether its button is up.
|
||||
fn vm_state(script: &UiScript) -> (bool, Option<String>, bool) {
|
||||
let queued = script
|
||||
.eval::<bool>("return IsInMeetingStoneQueue() and true or false")
|
||||
@@ -215,7 +179,7 @@ fn stone_probe(
|
||||
return; // not in-world yet
|
||||
};
|
||||
let Some(script) = script else {
|
||||
return; // no UI VM this build — nothing this probe can read
|
||||
return; // no UI VM in this build
|
||||
};
|
||||
let now = time.elapsed_secs_f64();
|
||||
let level = store.0.unit_level().unwrap_or(0);
|
||||
@@ -331,8 +295,7 @@ fn stone_probe(
|
||||
if queued { "" } else { "un" }
|
||||
),
|
||||
);
|
||||
// `.character level 0` is not a level; a template that ships `data[0] = 0` means
|
||||
// "no floor", which for this leg is simply level 1.
|
||||
// `data[0] = 0` means no floor, which for this leg is level 1.
|
||||
let want = probe.tmpl.map_or(1, |t| t.min_level.max(1));
|
||||
info!("PROBE_STONE: levelling to {want} for the join leg");
|
||||
let _ = net.0.send(ClientCommand::Chat {
|
||||
@@ -375,8 +338,7 @@ fn stone_probe(
|
||||
if text.is_some() { "yes" } else { "no" }
|
||||
),
|
||||
);
|
||||
// Leg 4 — `0x5f6990` over the LIVE queue: this stone is now the one we are queued at,
|
||||
// so its highlightable slot must refuse it.
|
||||
// Leg 4: `0x5f6990` over the live queue refuses the stone we are queued at.
|
||||
probe.darkened = meeting_stone_queued(Some(area), queue.area);
|
||||
probe.fails += check("DARKENED", "true", &probe.darkened.to_string());
|
||||
info!("PROBE_STONE: leaving through the live VM's own CancelMeetingStoneRequest()");
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
//! The taxi-flight live probe (`WOW_PROBE=taxi`) — decision 0484's end-to-end instrument, inert
|
||||
//! without the env: once in-world, GM-hop to the Stormwind flight master (Dungar Longdrink),
|
||||
//! open the taxi menu on the real wire (`CMSG_TAXIQUERYAVAILABLENODES` → `SMSG_SHOWTAXINODES`),
|
||||
//! activate the short verified hop Stormwind → Sentinel Hill (nodes 2 → 4, TaxiPath 6), and ride
|
||||
//! the server's flying spline to arrival. Every phase edge prints a `PROBE taxi:` line and the
|
||||
//! landing prints a SUCCESS/FAILURE verdict with the two machine checks of the 0484 gate:
|
||||
//! arrival distance to the destination node's DBC position, and measured flight duration vs the
|
||||
//! DBC prediction `Σ path-segment length ÷ 32 yd/s` (`PLAYER_FLIGHT_SPEED`) — timing measured,
|
||||
//! never eyeballed (decision 0404). An outer `timeout`d run + grep is the whole harness.
|
||||
//! Non-combat. Pair with the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill). Uses `.taxicheat on` so
|
||||
//! the fresh probe character can fly to an unvisited node (and so the SHOWTAXINODES mask
|
||||
//! exercises the full-network branch); the 110-copper fare is DB-seeded (see the Wait phase —
|
||||
//! `.modify money` outranks the probe account).
|
||||
//! The taxi-flight live probe (`WOW_PROBE=taxi`): hops to Stormwind's flight master, opens the
|
||||
//! menu (`CMSG_TAXIQUERYAVAILABLENODES`, `SMSG_SHOWTAXINODES`), flies Stormwind to Sentinel Hill
|
||||
//! and logs a `PROBE taxi:` line at each edge. The landing verdict checks the distance to the
|
||||
//! destination node's DBC position, the flight time against the path length over 32 yd/s, the
|
||||
//! in-flight anims and the flying pitch and bank; then it holds `W` and logs when movement starts.
|
||||
//!
|
||||
//! Non-combat. `.taxicheat on` lets a fresh character fly to an unvisited node; the ~110-copper
|
||||
//! fare comes from the character's own purse. The switches are `docs/CONTRIBUTING.md`, "Running
|
||||
//! it unattended".
|
||||
|
||||
use benilla_assets::coords::bevy_to_wow;
|
||||
use benilla_formats::{load_taxi_nodes, load_taxi_path_nodes};
|
||||
@@ -22,19 +18,16 @@ use crate::player::Player;
|
||||
use crate::ui_taxi::TaxiState;
|
||||
use benilla_assets::{LockRecover, WorldAssets};
|
||||
|
||||
/// The flight under test: Stormwind (node 2) → Sentinel Hill (node 4), TaxiPath id 6 — the pair
|
||||
/// byte-verified against the real 5875 tables by the phase-1 catalog tests.
|
||||
/// The flight under test: Stormwind (node 2) to Sentinel Hill (node 4), TaxiPath 6.
|
||||
const SRC_NODE: u32 = 2;
|
||||
const DEST_NODE: u32 = 4;
|
||||
const TAXI_PATH: u32 = 6;
|
||||
/// Dungar Longdrink's spawn (vmangos `creature` guid 79658, entry 352, map 0) — the `.go xyz`
|
||||
/// target; the flight master is then scanned from the streamed world by its npc flag, never by a
|
||||
/// hardcoded guid.
|
||||
/// Dungar Longdrink's spawn (vmangos `creature` guid 79658, entry 352, map 0); the flight master
|
||||
/// is then found in the streamed world by his npc flag.
|
||||
const FLIGHTMASTER_AT: [f32; 3] = [-8835.8, 490.1, 109.7];
|
||||
/// `UNIT_NPC_FLAG_FLIGHTMASTER` (bit 3) — the same bit the cursor classifier keys on.
|
||||
/// `UNIT_NPC_FLAG_FLIGHTMASTER` (bit 3).
|
||||
const NPC_FLAG_FLIGHTMASTER: u32 = 0x8;
|
||||
/// vmangos `PLAYER_FLIGHT_SPEED` (yd/s, hardcoded server-side) — the duration prediction's
|
||||
/// divisor.
|
||||
/// vmangos `PLAYER_FLIGHT_SPEED` in yd/s (`WaypointMovementGenerator.cpp:390`).
|
||||
const FLIGHT_SPEED: f32 = 32.0;
|
||||
|
||||
pub(crate) struct ProbeTaxiPlugin;
|
||||
@@ -49,8 +42,7 @@ impl Plugin for ProbeTaxiPlugin {
|
||||
.add_systems(Update, taxi_probe)
|
||||
.add_systems(
|
||||
PreUpdate,
|
||||
// After the loading cover's input swallow as well as winit's input pass — see
|
||||
// `probes::act::ProbeKeyPlugin` for why a synthetic press orders past it.
|
||||
// After winit's input pass and the loading cover's input swallow.
|
||||
hold_w_post_land
|
||||
.after(bevy::input::InputSystems)
|
||||
.after(crate::loading_screen::CoverInput),
|
||||
@@ -58,17 +50,15 @@ impl Plugin for ProbeTaxiPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// The DBC-derived expectations, loaded once off the patch chain: the destination node's world
|
||||
/// position (the arrival assert) and the path's total length (the duration prediction).
|
||||
/// From the DBCs: the destination node's position and the path's total length.
|
||||
#[derive(Default)]
|
||||
struct Expectations {
|
||||
dest_pos: Option<[f32; 3]>,
|
||||
path_len: Option<f32>,
|
||||
}
|
||||
|
||||
/// The probe's phase machine. `Wait` → (GM hop sent) `Hopped` → (flight master streamed in,
|
||||
/// query sent) `Queried` → (map opened, activate sent) `Activated` → (self-spline riding)
|
||||
/// `Flying` → (ride ended) verdict → `Done`.
|
||||
/// `Wait`, `Hopped` (hop sent), `Queried` (query sent), `Activated` (activate sent), `Flying`
|
||||
/// (riding the spline), `PostLand`, `Done`.
|
||||
#[derive(Resource, Default)]
|
||||
struct TaxiProbe {
|
||||
expect: Expectations,
|
||||
@@ -93,21 +83,15 @@ enum Phase {
|
||||
Flying {
|
||||
started_at: f64,
|
||||
last_report: f64,
|
||||
/// Latched true the first in-flight frame the anim pair reads right — rider base
|
||||
/// Mount(91), mount child base Fly(135) (`0x5fd19c` + the 0441 mount pin).
|
||||
/// Latched once the rider plays Mount (91) and the mount child Fly (135) (`0x5fd19c`).
|
||||
gait_ok: bool,
|
||||
/// The largest |flying pitch| (radians) seen on the SELF transform mid-flight — the
|
||||
/// `sample_splines` tangent-climb attitude (decisions 0501/0516). The route climbs
|
||||
/// Westfall's hills, so a working tilt shows ≳0.1 rad; ~0 means it never rendered.
|
||||
/// The largest flying pitch magnitude (radians) on the self transform.
|
||||
max_pitch: f32,
|
||||
/// The largest |flying BANK| (radians) mid-flight — the 0516 look-ahead lean. The route
|
||||
/// turns repeatedly, so a working bank shows ≳0.05 rad; ~0 means no lean rendered.
|
||||
/// The largest flying bank magnitude (radians), the look-ahead lean.
|
||||
max_bank: f32,
|
||||
},
|
||||
/// Post-landing diagnosis (the director's "we float a char height and can't move for ~5 s"):
|
||||
/// W is held synthetically ([`hold_w_post_land`]) from the moment the verdict prints; each
|
||||
/// second this logs the pose — height over the ground (a conform-style down-ray), distance
|
||||
/// from the landing point, ride/mount state — and stamps when movement actually began.
|
||||
/// After landing, with `W` held ([`hold_w_post_land`]): logs height over ground, distance
|
||||
/// moved and ride state each second, and when movement began.
|
||||
PostLand {
|
||||
landed_at: f64,
|
||||
landed_pos: Vec3,
|
||||
@@ -143,7 +127,6 @@ fn load_expectations(mut probe: ResMut<TaxiProbe>, world_assets: Option<Res<Worl
|
||||
}
|
||||
}
|
||||
|
||||
// One Bevy system's full input set (the crossing-probe shape) + its self query tuple.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn taxi_probe(
|
||||
time: ProbeClock,
|
||||
@@ -174,14 +157,8 @@ fn taxi_probe(
|
||||
Phase::Wait => {
|
||||
let [x, y, z] = FLIGHTMASTER_AT;
|
||||
info!("PROBE taxi: hopping to the Stormwind flight master at ({x}, {y}, {z})");
|
||||
// The fare is seeded offline in `characters.money` (100000 copper; a flight spends
|
||||
// ~110) — re-seed via `UPDATE characters SET money=100000 WHERE name LIKE 'Probe%'
|
||||
// AND online=0;` if a NOT_ENOUGH_MONEY reply ever shows up. The seed used to be the
|
||||
// *only* option: `.modify money` is SEC_BASIC_ADMIN(4) and the probe accounts were
|
||||
// gmlevel 3 (two runs bounced on NOT_ENOUGH_MONEY before that was traced). Since they
|
||||
// were actually raised to 6 (0651) this probe could grant its own fare in-band the way
|
||||
// `probe_bank` now does — left alone because the offline seed works and is one fewer
|
||||
// command inside the flight window.
|
||||
// The fare comes from the character's purse; a NOT_ENOUGH_MONEY reply means it is
|
||||
// empty (`.modify money` refills it).
|
||||
let _ = net
|
||||
.0
|
||||
.send(ClientCommand::SetSelection { guid: self_guid.0 });
|
||||
@@ -198,8 +175,7 @@ fn taxi_probe(
|
||||
probe.phase = Phase::Hopped { sent_at: now };
|
||||
}
|
||||
Phase::Hopped { sent_at } => {
|
||||
// Post-teleport settle, then scan the streamed world for a flight-master-flagged
|
||||
// unit in interaction range — the guid comes from the wire, never hardcoded.
|
||||
// Settle, then find a flight-master-flagged unit in range.
|
||||
if now - sent_at < 3.0 {
|
||||
return;
|
||||
}
|
||||
@@ -251,8 +227,8 @@ fn taxi_probe(
|
||||
});
|
||||
probe.phase = Phase::Activated { sent_at: now };
|
||||
} else if now - sent_at > 2.0 && !retried {
|
||||
// First contact LEARNS, never opens (vmangos SendLearnNewTaxiNode) — the second
|
||||
// query opens the menu.
|
||||
// A first visit learns the node and opens nothing (vmangos
|
||||
// `TaxiHandler.cpp:75`); the second query opens the menu.
|
||||
info!("PROBE taxi: no menu yet (first-visit learn?) — querying again");
|
||||
let _ = net
|
||||
.0
|
||||
@@ -307,16 +283,14 @@ fn taxi_probe(
|
||||
} => {
|
||||
let wow = bevy_to_wow(player.pos);
|
||||
if player.server_riding() {
|
||||
// The anim pair the flight must show (`0x5fd19c` + the 0441 mount pin):
|
||||
// rider base Mount(91), mount child base Fly(135). Latched — the first frames
|
||||
// legitimately lag (mount attach, first selection).
|
||||
// Rider Mount (91), mount child Fly (135) (`0x5fd19c`); latched, as the first
|
||||
// frames lag the mount attach.
|
||||
let rider = drivers.get(self_entity).ok().map(|d| d.playing().0);
|
||||
let mount = mount_child
|
||||
.and_then(|mc| drivers.get(mc.0).ok())
|
||||
.map(|d| d.playing().0);
|
||||
let pair_ok = rider == Some(Some(91)) && mount == Some(Some(135));
|
||||
// The flying attitude (decisions 0501/0516): `sample_splines` composes
|
||||
// `Ry(f)·Rx(pitch)·Rz(bank)`, so the YXZ euler reads back (yaw, pitch, bank).
|
||||
// `sample_splines` composes `Ry(f) * Rx(pitch) * Rz(bank)`, so YXZ reads it back.
|
||||
let (_, pitch, bank) = self_tf.rotation.to_euler(EulerRot::YXZ);
|
||||
let max_pitch = max_pitch.max(pitch.abs());
|
||||
let max_bank = max_bank.max(bank.abs());
|
||||
@@ -350,8 +324,8 @@ fn taxi_probe(
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The ride ended (server_ride snapped to the endpoint and sent CMSG_MOVE_SPLINE_DONE
|
||||
// — its own log line). Verdict time.
|
||||
// The ride ended: `server_ride` snapped to the endpoint and sent
|
||||
// `CMSG_MOVE_SPLINE_DONE`.
|
||||
let flew_for = now - started_at;
|
||||
let dist = probe.expect.dest_pos.map(|p| {
|
||||
let (dx, dy, dz) = (wow[0] - p[0], wow[1] - p[1], wow[2] - p[2]);
|
||||
@@ -359,13 +333,10 @@ fn taxi_probe(
|
||||
});
|
||||
let predicted = probe.expect.path_len.map(|l| l / FLIGHT_SPEED);
|
||||
let dist_ok = dist.is_some_and(|d| d < 20.0);
|
||||
// The server's duration also stretches with the mount-up delays and our
|
||||
// spline-arrival timing; a ±15% band catches a wrong curve/speed without flaking.
|
||||
// A 15% band: the server's duration also holds the mount-up delays.
|
||||
let time_ok =
|
||||
predicted.is_some_and(|p| (flew_for - f64::from(p)).abs() < f64::from(p) * 0.15);
|
||||
// The route climbs Westfall's hills AND turns repeatedly: a working flying attitude
|
||||
// shows well over 0.05 rad (~3°) on each axis somewhere along it; ~0 means that
|
||||
// axis never rendered (decisions 0501/0516).
|
||||
// The route climbs and turns, so each axis passes 0.05 rad somewhere along it.
|
||||
let pitch_ok = max_pitch > 0.05;
|
||||
let bank_ok = max_bank > 0.05;
|
||||
let verdict = if dist_ok && time_ok && gait_ok && pitch_ok && bank_ok {
|
||||
@@ -415,7 +386,7 @@ fn taxi_probe(
|
||||
if since - last_log >= 1.0 {
|
||||
last_log = since;
|
||||
let wow = bevy_to_wow(player.pos);
|
||||
// Conform-style ground probe: from 2 yd above the feet, 50 yd down.
|
||||
// Ground probe: from 2 yd above the feet, 50 yd down.
|
||||
let origin = player.pos + Vec3::Y * 2.0;
|
||||
let ground = spatial
|
||||
.cast_ray_predicate(
|
||||
@@ -460,9 +431,8 @@ fn taxi_probe(
|
||||
}
|
||||
}
|
||||
|
||||
/// Hold W from the landing verdict on (PreUpdate, after winit's input processing so the synthetic
|
||||
/// press is visible to the controller the same frame — the [`super::probes::ProbeKeyPlugin`]
|
||||
/// pattern): the post-land phase measures when the avatar actually starts moving.
|
||||
/// Holds `W` from the landing verdict on, in `PreUpdate` after winit's input pass so the
|
||||
/// controller sees it the same frame.
|
||||
fn hold_w_post_land(probe: Res<TaxiProbe>, mut keys: ResMut<ButtonInput<KeyCode>>) {
|
||||
if matches!(probe.phase, Phase::PostLand { .. }) {
|
||||
keys.press(KeyCode::KeyW);
|
||||
|
||||
@@ -1,42 +1,23 @@
|
||||
//! The **vendor-swap** live probe (`WOW_PROBE_VENDOR_SWAP=1`) — decision 2022's end-to-end
|
||||
//! instrument: does a second vendor, opened while the first vendor's window is still up, swap the
|
||||
//! WHOLE window — the stock `MerchantFrame_UpdateMerchantInfo`'s `UnitName("NPC")` title and its
|
||||
//! `SetPortraitTexture(MerchantFramePortrait, "NPC")` face — on the very dispatch that raises
|
||||
//! `MERCHANT_SHOW`; and does closing the window clear the `"npc"` token?
|
||||
//!
|
||||
//! The director's report (2026-09-05): the Goldshire inn's vendor window titled "Innkeeper
|
||||
//! Farley" over Brog Hamfist's stock, with an empty portrait ring; then "clicking between vendors
|
||||
//! while the window is open doesn't swap name and some other things maybe too". Both faces of
|
||||
//! one seam — the decision has the mechanism. This probe stands in that inn, between its two
|
||||
//! pure vendors (**Brog Hamfist** 151 and **Barkeep Dobbins** 465, 5.45 yd apart — live-DB
|
||||
//! verified), so both are inside the service reach at once and the second open lands on top of
|
||||
//! the first window exactly as the click does.
|
||||
//! The vendor-swap live probe (`WOW_PROBE_VENDOR_SWAP=1`): does a second vendor, opened over the
|
||||
//! first's window, swap the whole window (stock `MerchantFrame.lua:67-68`, the `UnitName("NPC")`
|
||||
//! title and the `"NPC"` portrait) on the dispatch that raises `MERCHANT_SHOW`, and does closing
|
||||
//! the window clear the `"npc"` token? It stands in the Goldshire inn between Brog Hamfist (151)
|
||||
//! and Barkeep Dobbins (465), 5.45 yd apart, both within service reach.
|
||||
//!
|
||||
//! Legs, one `PROBE_VENDOR_SWAP: <leg> PASS/FAIL/SKIP` line each:
|
||||
//! 1. **open** — `CMSG_LIST_INVENTORY` to Brog; at the `MERCHANT_SHOW` dispatch, `UnitName("npc")`
|
||||
//! and the title both read Brog's name.
|
||||
//! 2. **portrait** — the round region is being sampled under the canonical `"npc"` key
|
||||
//! (`portrait::BoothPanes`) and the booth publishes a live bake for it (`PortraitImages`).
|
||||
//! 3. **swap** — with Brog's window still open, `CMSG_LIST_INVENTORY` to Dobbins; at that
|
||||
//! `MERCHANT_SHOW`, both readings are Dobbins' name.
|
||||
//! 4. **title** — a second later, the title the window is actually left showing is his too.
|
||||
//! 5. **clear** — `HideUIPanel(MerchantFrame)` (the stock OnHide → `CloseMerchant`), and once the
|
||||
//! session is gone `UnitExists("npc")` is nil.
|
||||
//! 1. open: `CMSG_LIST_INVENTORY` to Brog; at `MERCHANT_SHOW`, `UnitName("npc")` and the title
|
||||
//! both read his name.
|
||||
//! 2. portrait: the round region is sampled under the `"npc"` key (`portrait::BoothPanes`) and the
|
||||
//! booth publishes a live bake for it (`PortraitImages`).
|
||||
//! 3. swap: with Brog's window open, `CMSG_LIST_INVENTORY` to Dobbins; at that `MERCHANT_SHOW`,
|
||||
//! both read Dobbins' name.
|
||||
//! 4. title: a second later the window still shows his name.
|
||||
//! 5. clear: `HideUIPanel(MerchantFrame)` (stock OnHide calls `CloseMerchant`); once the session
|
||||
//! is gone `UnitExists("npc")` is nil.
|
||||
//!
|
||||
//! The event readings are taken **inside the event** by a Lua hook frame registered after the
|
||||
//! stock window's, so a later `MERCHANT_UPDATE` repaint cannot mask what the open frame saw —
|
||||
//! which is how the bug hid: a vendor whose items were already cached got no repaint, and one
|
||||
//! whose items were still streaming got one a moment later that quietly corrected the title.
|
||||
//!
|
||||
//! Non-combat, nothing bought, nothing sold; the window is closed the way its own close button
|
||||
//! closes it (no packet).
|
||||
//!
|
||||
//! ## The run recipe
|
||||
//! ```text
|
||||
//! WOW_USER=probe1 WOW_PASS=pprobe1 WOW_CHAR=Probeone WOW_UNATTENDED=1 WOW_NOSOUND=1 \
|
||||
//! WOW_PROBE_VENDOR_SWAP=1 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! (the checkout's probe identity (`.probe-identity`, or WOW_USER/WOW_PASS/WOW_CHAR — the `probe` skill)).
|
||||
//! The event readings are taken inside the event by a hook frame registered after the stock one,
|
||||
//! so a later `MERCHANT_UPDATE` repaint cannot mask what the open left. Non-combat, nothing bought
|
||||
//! or sold. The switches are `docs/CONTRIBUTING.md`, "Running it unattended".
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -50,32 +31,26 @@ use crate::player::Player;
|
||||
use crate::portrait::{BoothPanes, PortraitImages, PortraitSource};
|
||||
use crate::ui_merchant::MerchantOpen;
|
||||
|
||||
/// Brog Hamfist `<General Supplies>` — `creature_template.entry = 151`, `npc_flags = 4`.
|
||||
/// Brog Hamfist `<General Supplies>`: `creature_template.entry = 151`, `npc_flags = 4`.
|
||||
const BROG_ENTRY: u32 = 151;
|
||||
/// Barkeep Dobbins — `entry = 465`, `npc_flags = 4`.
|
||||
/// Barkeep Dobbins: `entry = 465`, `npc_flags = 4`.
|
||||
const DOBBINS_ENTRY: u32 = 465;
|
||||
/// Midway between the two spawns (`creature.position_*`: Brog −9465.29, 9.63; Dobbins −9459.98,
|
||||
/// 8.41; both z 57.15), so each is ~2.8 yd away — inside the 5.5556 yd service reach.
|
||||
/// Midway between the two spawns (Brog -9465.29, 9.63; Dobbins -9459.98, 8.41; both z 57.15),
|
||||
/// ~2.8 yd from each, inside the 5.5556 yd service reach.
|
||||
const STAND_AT: [f32; 3] = [-9462.6, 9.0, 57.15];
|
||||
const MAP: u32 = 0;
|
||||
|
||||
/// Let the hop land and the inn stream before the first scan.
|
||||
const SETTLE_SECS: f64 = 3.0;
|
||||
/// How long to wait for both vendors (and their names) before calling the hop environmental.
|
||||
/// How long to wait for both vendors and their names before a SKIP.
|
||||
const SCAN_TIMEOUT_SECS: f64 = 20.0;
|
||||
/// How long a leg waits for its window / its event after the opcode goes out.
|
||||
const WINDOW_TIMEOUT_SECS: f64 = 15.0;
|
||||
/// How long the round portrait may take to be sampled and baked once the window is open.
|
||||
const PORTRAIT_TIMEOUT_SECS: f64 = 10.0;
|
||||
/// How long after the swap to read the title the window is actually left showing.
|
||||
/// How long after the swap to read the title the window is left showing.
|
||||
const SETTLED_SECS: f64 = 1.0;
|
||||
/// The service reach the click cannot act past (`crate::target::SERVICE_RANGE_SQ`).
|
||||
const SCAN_RANGE_SQ: f32 = crate::target::SERVICE_RANGE_SQ;
|
||||
|
||||
/// The hook frame: one line per merchant event, read INSIDE the dispatch. Registered after the
|
||||
/// stock `MerchantFrame` (the probe installs it in-world, long after the FrameXML load), so the
|
||||
/// stock `OnEvent` has already repainted by the time this runs and `MerchantNameText` shows what
|
||||
/// the open frame left there.
|
||||
/// The hook frame: one line per merchant event, read inside the dispatch. Registered after the
|
||||
/// stock `MerchantFrame`, so `MerchantNameText` shows what the stock `OnEvent` left there.
|
||||
const HOOK: &str = r#"
|
||||
if not ProbeVendorSwapHooked then
|
||||
ProbeVendorSwapHooked = true
|
||||
@@ -217,8 +192,7 @@ fn vendor_swap_probe(
|
||||
.map(|(guid, ..)| guid.0)
|
||||
};
|
||||
let (brog, dobbins) = (find(BROG_ENTRY), find(DOBBINS_ENTRY));
|
||||
// Both names must be cached before the legs read them back out of the VM — the
|
||||
// ask-once resolve is what the unit feed itself would send on the open frame.
|
||||
// Both names must be cached before the legs read them back out of the VM.
|
||||
let named = |g: Option<u64>| g.is_some_and(|g| names.resolve(g, &net).is_some());
|
||||
let (brog_named, dobbins_named) = (named(brog), named(dobbins));
|
||||
if brog_named && dobbins_named {
|
||||
@@ -379,11 +353,9 @@ fn vendor_swap_probe(
|
||||
probe.phase = Phase::Done;
|
||||
}
|
||||
None => {}
|
||||
// One more poll after the clear: the unit feed pushes the token's absence on the
|
||||
// frame after the session clears.
|
||||
// The unit feed drops the token the frame after the session clears.
|
||||
Some(at) if now - at >= 0.25 => {
|
||||
// `and 1 or 0`: the binding answers a Lua boolean where the reference
|
||||
// answers `1`/nil (its own noted question), and a `false ~= nil` is true.
|
||||
// `and 1 or 0` turns `UnitExists`'s `1` or nil into a number.
|
||||
let exists: i64 = script
|
||||
.eval(r#"return UnitExists("npc") and 1 or 0"#)
|
||||
.unwrap_or(1);
|
||||
@@ -411,9 +383,7 @@ fn vendor_swap_probe(
|
||||
"PROBE_VENDOR_SWAP: DONE pass={} fail={} skip={}",
|
||||
probe.passes, probe.fails, probe.skips
|
||||
);
|
||||
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
|
||||
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
|
||||
// client holding the probe account.
|
||||
// `AppExit` plus a hard backstop, so a teardown hang cannot keep the account held.
|
||||
exit.write(AppExit::Success);
|
||||
std::thread::spawn(|| {
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
//! The **actuation** probes: the three headless channels into a live session — scripted chat
|
||||
//! sends ([`ProbeChatPlugin`]), synthetic key taps ([`ProbeKeyPlugin`]), and a Lua chunk in the
|
||||
//! live UI VM ([`ProbeLuaPlugin`]). Each is env-gated, waits for the session to be in-world, and
|
||||
//! schedules on [`ProbeClock`]; together they are "do what the director would do, unattended".
|
||||
//! The actuation probes: chat lines, key taps, a Lua chunk, a pointer drag and a hover sweep
|
||||
//! driven into a live session. Each is env-gated, waits for the world and runs on [`ProbeClock`].
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::ProbeClock;
|
||||
|
||||
/// The PROBE CHAT one-shot (`WOW_PROBE_CHAT="<line>[;<line>…]"`, delay via `WOW_PROBE_CHAT_AT`
|
||||
/// seconds, default 8): send each `;`-separated line as Say once we are in-world — the "park the
|
||||
/// probe character anywhere" instrument. The probe account (gmlevel 6) makes `.go xyz …`, `.gm on`,
|
||||
/// `.additem` etc. work headlessly, which a direct `characters` DB edit does NOT (the live world
|
||||
/// server's logout save overwrites it, and the row is only re-read at login). Pair with
|
||||
/// [`LiveShotPlugin`] at a later `WOW_LIVE_SHOT_AT` so the destination has streamed in.
|
||||
/// `WOW_PROBE_CHAT_EVERY=<secs>` spaces the lines apart instead of sending them in one burst —
|
||||
/// the "do X, wait, then do Y" probe (a mount-then-dismount transition, a buff-then-cancel):
|
||||
/// two field flips inside one drain merge to a no-op, so time-separated sends are what actually
|
||||
/// exercise a transition (decision 0441's teardown verification).
|
||||
/// `WOW_PROBE_CHAT="<line>[;<line>…]"`: types each line into the chat box once in-world, from
|
||||
/// `WOW_PROBE_CHAT_AT` seconds (default 8), `WOW_PROBE_CHAT_EVERY` seconds apart (default one
|
||||
/// burst). Spacing matters: two field flips inside one drain merge to a no-op.
|
||||
pub(crate) struct ProbeChatPlugin;
|
||||
|
||||
impl Plugin for ProbeChatPlugin {
|
||||
@@ -40,8 +31,7 @@ impl Plugin for ProbeChatPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ProbeChatPlugin`] state: the `;`-separated lines, the first-fire time, the per-line spacing
|
||||
/// (`0` = one burst), and how many lines have gone out.
|
||||
/// [`ProbeChatPlugin`] state; `every` of 0 sends every line in one burst.
|
||||
#[derive(Resource)]
|
||||
struct ProbeChat {
|
||||
lines: String,
|
||||
@@ -50,14 +40,8 @@ struct ProbeChat {
|
||||
sent: usize,
|
||||
}
|
||||
|
||||
/// Submit the probe lines once the delay has elapsed AND the session is in-world (the self player
|
||||
/// exists) — a `.go` sent before world-enter would be dropped server-side.
|
||||
///
|
||||
/// Lines go in through the **chat EditBox seam**, not straight to the wire: a probe line is "what
|
||||
/// the director would type", so a client-side slash command (`/duel`, `/reaction`) is parsed by
|
||||
/// the same drain that serves the real chat box, while plain text and `.gm`/`.go` server commands
|
||||
/// still leave as Say exactly as before. Sending them as Say instead — the original shape — meant
|
||||
/// every client-side command silently went out as public chat and did nothing (decision 0637).
|
||||
/// Submits due lines once a self player exists (the server drops a `.go` sent before world entry),
|
||||
/// through the chat box so client-side slash commands parse as typed ones do.
|
||||
fn fire_probe_chat(
|
||||
mut probe: ResMut<ProbeChat>,
|
||||
time: ProbeClock,
|
||||
@@ -68,13 +52,12 @@ fn fire_probe_chat(
|
||||
return;
|
||||
}
|
||||
if self_player.is_empty() {
|
||||
return; // not in-world yet — keep waiting past the delay
|
||||
return; // not in-world yet
|
||||
}
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
// With no spacing every line goes in the first eligible frame (the original burst); with
|
||||
// `every`, line N waits until `at + N·every` — the "do X, wait, then do Y" cadence.
|
||||
// Line N is due at `at + N·every`.
|
||||
loop {
|
||||
let Some(line) = probe
|
||||
.lines
|
||||
@@ -95,23 +78,10 @@ fn fire_probe_chat(
|
||||
}
|
||||
}
|
||||
|
||||
/// The PROBE KEY one-shots (`WOW_PROBE_KEY="<key>@<secs>[:<hold>][;…]"`): synthesize a key press
|
||||
/// at each given time once in-world, released `<hold>` seconds later ([`PROBE_KEY_TAP_SECS`] when
|
||||
/// omitted — the tap this instrument shipped with). The "press space headlessly" instrument for
|
||||
/// input-gated behavior (the mounted flourish, a jump, the X/Z toggles), which neither a chat
|
||||
/// command nor a Lua chunk can reach (1.12 has no jump Lua API; the gate lives in the
|
||||
/// controller's key read).
|
||||
///
|
||||
/// The optional hold is what makes *sustained* locomotion reachable headlessly: a 0.25 s W tap
|
||||
/// travels ~1.2 yd, far too little to cross a liquid surface's own slope, so a swim defect that
|
||||
/// only appears while moving over water could not be reproduced without asking the director to
|
||||
/// drive (decision 0644 — the gap `WOW_PROBE_LOOK` closed for mouse-turns, on the key side).
|
||||
///
|
||||
/// Runs in `PreUpdate`
|
||||
/// after winit's input processing ([`bevy::input::InputSystems`]) so the synthetic
|
||||
/// `just_pressed` is visible to every `Update` reader that same frame — a press from inside
|
||||
/// `Update` would be cleared at the next frame's input pass before an earlier-ordered
|
||||
/// controller ever saw it.
|
||||
/// `WOW_PROBE_KEY="<key>@<secs>[:<hold>][;…]"`: presses each key at its time once in-world and
|
||||
/// releases it `<hold>` seconds later ([`PROBE_KEY_TAP_SECS`] when omitted). Reaches input no chat
|
||||
/// line or Lua chunk can (1.12 has no jump Lua API). Runs in `PreUpdate` after
|
||||
/// [`bevy::input::InputSystems`], so every `Update` reader sees the `just_pressed` that frame.
|
||||
pub(crate) struct ProbeKeyPlugin;
|
||||
|
||||
impl Plugin for ProbeKeyPlugin {
|
||||
@@ -147,19 +117,16 @@ impl Plugin for ProbeKeyPlugin {
|
||||
bevy::app::PreUpdate,
|
||||
fire_probe_key
|
||||
.after(bevy::input::InputSystems)
|
||||
// …and after the loading cover's input swallow, which runs in the same window
|
||||
// and empties every button plane: an instrument driving the client is the
|
||||
// operator, not the player, so its press has to land on the far side of it.
|
||||
// After the loading cover empties every button plane, so the press survives.
|
||||
.after(crate::loading_screen::CoverInput),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a probe press stays held when the spec gives no `:<hold>`. Long enough that a
|
||||
/// `pressed`-reader (a held-key gate) sees it across several frames; short enough to stay a tap.
|
||||
/// Default hold: several frames for a held-key reader, still a tap.
|
||||
const PROBE_KEY_TAP_SECS: f32 = 0.25;
|
||||
|
||||
/// The key names [`ProbeKeyPlugin`] accepts — the controller-read set; extend as probes need.
|
||||
/// The key names [`ProbeKeyPlugin`] accepts.
|
||||
fn probe_key_by_name(name: &str) -> Option<KeyCode> {
|
||||
Some(match name {
|
||||
"Space" => KeyCode::Space,
|
||||
@@ -172,86 +139,52 @@ fn probe_key_by_name(name: &str) -> Option<KeyCode> {
|
||||
"X" => KeyCode::KeyX,
|
||||
"Z" => KeyCode::KeyZ,
|
||||
"Tab" => KeyCode::Tab,
|
||||
// TOGGLEAUTORUN's 1.12 default. The moving-axis protocol (task: grade streaming while
|
||||
// running) engages autorun with one tap and steers with `WOW_PROBE_LOOK` — the whole
|
||||
// scripted drive out of two instruments that already existed.
|
||||
// TOGGLEAUTORUN's 1.12 default; with `WOW_PROBE_LOOK` it scripts a drive.
|
||||
"NumLock" => KeyCode::NumLock,
|
||||
// The nameplate toggles (`vplates`): bare `V` is enemy plates, `Shift`-held `V` is
|
||||
// friendly. A probe that wants plates over a town's *friendly* NPCs — the only way to
|
||||
// measure plate behaviour without standing in aggro range of something — presses this.
|
||||
// The nameplate toggle: bare `V` shows enemy plates, `Shift`+`V` friendly ones.
|
||||
"V" => KeyCode::KeyV,
|
||||
// The detached free-fly toggle (`player.rs`) and its Ctrl speed boost (`camera.rs`, ×5).
|
||||
// Added because the harness could not reach the leg that broke decision 0793: the director's
|
||||
// first real run was a boosted free-fly, whose camera crosses the art radius in ~5 s, and
|
||||
// reproducing it needed a held `Ctrl` + `W` behind an `F`.
|
||||
//
|
||||
// The toggle is the dev chord + `F` since decision 1043 (it was a bare `F`), so a probe that
|
||||
// wants free-fly holds `Ctrl` and `Shift` across the `F` tap — hence `Shift` here. `Ctrl`
|
||||
// stays held afterwards for the ×5 boost, which is unchanged.
|
||||
// Free-fly is the dev chord (`Ctrl`+`Shift`) + `F` (`player.rs`); a held `Ctrl` is its ×5
|
||||
// speed boost (`camera.rs`).
|
||||
"F" => KeyCode::KeyF,
|
||||
"Ctrl" => KeyCode::ControlLeft,
|
||||
"Shift" => KeyCode::ShiftLeft,
|
||||
// The cost pill's toggle, dev chord + `P` (`perf::hud::toggle_hud`). Reachable no other
|
||||
// way — it is a host chord, not a binding or a Lua verb — and since the pill starts hidden
|
||||
// (2099) a probe that wants to see it in a shot has to press it.
|
||||
// The cost pill's toggle, dev chord + `P` (`perf::hud::toggle_hud`); it starts hidden.
|
||||
"P" => KeyCode::KeyP,
|
||||
// The text-editing keys. Added for decision 1077's hyperlink-atomicity law, whose whole
|
||||
// observable — "one BACKSPACE removes a whole item link" — is a keypress no chat command
|
||||
// and no Lua chunk can reach (`EditBox` has no Lua deletion API; the law lives behind the
|
||||
// host's chord table). Without these a caret/deletion defect could only be reproduced by
|
||||
// asking the director to type, which is the asymmetry probes exist to remove.
|
||||
// Text editing: `EditBox` has no Lua deletion API, so caret and deletion need real keys.
|
||||
"Backspace" => KeyCode::Backspace,
|
||||
"Delete" => KeyCode::Delete,
|
||||
"Left" => KeyCode::ArrowLeft,
|
||||
"Right" => KeyCode::ArrowRight,
|
||||
"Home" => KeyCode::Home,
|
||||
"End" => KeyCode::End,
|
||||
// Enter at the character-select screen IS "enter world" (`char_select::input`), which makes
|
||||
// the **second** login of a run reachable headlessly: `/logout` then `Enter` reproduces a
|
||||
// character switch in one process. Session-scoped state that survives that boundary is a
|
||||
// whole bug class, and until this key existed no probe could reach it — 1284's stale channel
|
||||
// membership had to be found by the director, twice. `WOW_CHAR` is a deliberate one-shot
|
||||
// (`run_mode`), so synthesizing the keypress is the only way back into the world in-process.
|
||||
// Enter at character select enters the world (`char_select::input`): `/logout` then
|
||||
// `Enter` is a relog in one process, since `WOW_CHAR` applies once (`run_mode`).
|
||||
"Enter" => KeyCode::Enter,
|
||||
// Print screen (decision 1487). The whole player path — key → SCREENSHOT binding →
|
||||
// `TakeScreenshot()` → `Screenshot()` → the writer → SCREENSHOT_SUCCEEDED — is reachable
|
||||
// no other way: a Lua chunk can call the verb but skips the binding, and B261's contract
|
||||
// ("the message is not in the file") is a property of the KEY press, since the whole
|
||||
// reason `TakeScreenshot` hides the status line is a second press landing on a live one.
|
||||
// On macOS this arrives as F13 (`bindings::chord`'s fold) and the token is the same.
|
||||
// The SCREENSHOT binding's key; a Lua `TakeScreenshot()` skips the binding. On macOS it
|
||||
// arrives as F13 (`bindings::chord`'s fold) under the same token.
|
||||
"PrintScreen" => KeyCode::PrintScreen,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// [`ProbeKeyPlugin`] state: one entry per scheduled tap, plus the once-armed latch.
|
||||
#[derive(Resource)]
|
||||
struct ProbeKeys {
|
||||
taps: Vec<ProbeKeyTap>,
|
||||
/// **A self player has existed at some point this run.** The gate used to be "a self player
|
||||
/// exists *right now*", which is what keeps a tap from firing into the loading screen — but it
|
||||
/// also made every glue screen unreachable, and the second half of a `/logout` + `Enter`
|
||||
/// character-switch probe happens at exactly one of those (1284). Latching it keeps the "not
|
||||
/// before the world" intent and drops the "only while in it" accident.
|
||||
/// A self player has existed this run: latched, so taps reach glue screens after a `/logout`.
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
struct ProbeKeyTap {
|
||||
key: KeyCode,
|
||||
at: f32,
|
||||
/// Seconds the key stays down — the spec's `:<hold>`, else [`PROBE_KEY_TAP_SECS`].
|
||||
/// Seconds held: the spec's `:<hold>`, else [`PROBE_KEY_TAP_SECS`].
|
||||
hold: f32,
|
||||
pressed: bool,
|
||||
released: bool,
|
||||
}
|
||||
|
||||
/// Press each due tap (in-world gated, like the chat probe) and release it after its hold window.
|
||||
///
|
||||
/// Both input currencies, deliberately (0997): `ButtonInput` for every held-state reader (and
|
||||
/// the binding dispatch's stuck-latch sweep, which treats "latched but not pressed" as a missed
|
||||
/// release), plus the raw [`KeyboardInput`] message the binding dispatch's press/release edges
|
||||
/// actually consume — a state-only synthetic press was invisible to the chord latcher, which
|
||||
/// would have silently killed this instrument the day the dispatch landed.
|
||||
/// Presses and releases due taps through both `ButtonInput` (held-state readers) and a raw
|
||||
/// [`KeyboardInput`] message (the binding dispatch's press/release edges); both are needed.
|
||||
fn fire_probe_key(
|
||||
mut probe: ResMut<ProbeKeys>,
|
||||
time: ProbeClock,
|
||||
@@ -292,12 +225,9 @@ fn fire_probe_key(
|
||||
tap.released = true;
|
||||
}
|
||||
}
|
||||
// Publish what we are holding, so the macOS stuck-modifier reconciler
|
||||
// ([`benilla_world::modkeys`]) doesn't undo it: it polls the *hardware* flags, which read
|
||||
// "up" for a key no hand is on, so before this every synthesized chord — `SHIFT-V`,
|
||||
// `Ctrl`+`Shift`+`F` — was released the frame after it was pressed, and the release was
|
||||
// logged as a stuck-key correction. Rewritten only when the held set changes (a plain
|
||||
// `ResMut` write every frame would mark the resource changed forever).
|
||||
// Publish the held keys so the macOS stuck-modifier reconciler ([`benilla_world::modkeys`]),
|
||||
// which polls hardware flags, does not release them. Written only on change, to keep the
|
||||
// resource's change tick meaningful.
|
||||
let held: Vec<KeyCode> = probe
|
||||
.taps
|
||||
.iter()
|
||||
@@ -309,26 +239,10 @@ fn fire_probe_key(
|
||||
}
|
||||
}
|
||||
|
||||
/// The PROBE LUA driver (`WOW_PROBE_LUA="<chunk>"`, first fire at `WOW_PROBE_LUA_AT` seconds,
|
||||
/// default 10; re-armed `WOW_PROBE_LUA_AGAIN` seconds into every LATER world entry, default 4):
|
||||
/// run one Lua chunk in the live UI VM — the "press the button headlessly" instrument. The chunk
|
||||
/// drives the REAL FrameXML API surface (`CastSpell`, `UseAction`, `TargetUnit`, …), so whatever
|
||||
/// it triggers takes the exact app path a click takes — a headless wire probe can measure the
|
||||
/// server, but only the live VM exercises the button feed and the widget clock.
|
||||
///
|
||||
/// **The chunk fires once per WORLD ENTRY, not once per process** (decision 2116's named gap).
|
||||
/// The UI VM is destroyed and rebuilt at every logout/login (1290/1291), taking `_G` and the
|
||||
/// `ProbeLog` channel below with it — so while this was a process-wide one-shot, a probe could
|
||||
/// not read the same value before and after a relog *at all*. 2116 needed exactly that (the
|
||||
/// cooldown triple across a `/logout`) and had to fall back to `WOW_PROBE_CHAT` sending
|
||||
/// `/script … error("…")` on a schedule and reading the warn line the input drain logs — a trick
|
||||
/// standing in for an instrument, and a morning instead of an hour.
|
||||
///
|
||||
/// **World entry, and deliberately not "VM rebuild"**, which is the wider edge and the wrong one:
|
||||
/// `ReloadUI()` rebuilds the VM without leaving the world, and `WOW_PROBE_LUA="ReloadUI()"` is a
|
||||
/// probe we actually run (decision 2028). Re-arming on the VM would make that chunk reload the
|
||||
/// interface forever. [`ProbeLog`](install_probe_log) *does* follow the VM, because it is a
|
||||
/// channel rather than an action.
|
||||
/// `WOW_PROBE_LUA="<chunk>"`: runs the chunk in the live UI VM once per world entry, first at
|
||||
/// `WOW_PROBE_LUA_AT` seconds (default 10), then `WOW_PROBE_LUA_AGAIN` seconds (default 4) into
|
||||
/// each later entry. Re-armed on world entry, not VM rebuild, or `ReloadUI()` would loop; the
|
||||
/// [`ProbeLog`](install_probe_log) channel does follow the VM.
|
||||
pub(crate) struct ProbeLuaPlugin;
|
||||
|
||||
impl Plugin for ProbeLuaPlugin {
|
||||
@@ -352,41 +266,27 @@ impl Plugin for ProbeLuaPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ProbeLuaPlugin`] state: the chunk, the two schedules, and the per-entry bookkeeping that
|
||||
/// replaced the process-wide once-latch.
|
||||
#[derive(Resource, Default)]
|
||||
struct ProbeLua {
|
||||
chunk: String,
|
||||
/// `WOW_PROBE_LUA_AT` — the FIRST fire, on the run's own [`ProbeClock`].
|
||||
at: f32,
|
||||
/// `WOW_PROBE_LUA_AGAIN` — the settle every LATER entry gets, on its own clock.
|
||||
again: f32,
|
||||
/// World entries that have become able to take a chunk — counted on the rising edge.
|
||||
/// Ready world entries, counted on the rising edge.
|
||||
entries: u32,
|
||||
/// Entries already fired into; `fired < entries` is "this entry is still owed its chunk".
|
||||
/// Entries already fired into; `fired < entries` means one is owed.
|
||||
fired: u32,
|
||||
/// When the current entry became ready — the re-arm's zero.
|
||||
ready_at: f32,
|
||||
/// Last frame's readiness, so the count above is an edge and not a per-frame increment.
|
||||
was_ready: bool,
|
||||
/// The VM session `ProbeLog` was last installed into. `0` is "none" —
|
||||
/// [`benilla_ui::script::UiScript::session`] hands out from 1, so a fresh probe matches no VM
|
||||
/// and the first ready frame installs.
|
||||
/// The VM session `ProbeLog` is installed in; `0` matches none, since
|
||||
/// [`benilla_ui::script::UiScript::session`] counts from 1.
|
||||
logged_vm: u64,
|
||||
}
|
||||
|
||||
/// **Is this entry's chunk due?** — the whole schedule, as one decision a test can hold.
|
||||
///
|
||||
/// The first fire keeps `WOW_PROBE_LUA_AT`'s run-relative meaning ([`ProbeClock`], whose zero
|
||||
/// trails process start by the boot), and that is not inertia: every recorded probe line in
|
||||
/// `decisions/` is tuned against it (`WOW_PROBE_LUA_AT=34` in `scripts/summon-live.sh`, and a
|
||||
/// dozen records besides), and re-reading it as "34 s after the loading screen" would quietly
|
||||
/// stop reproducing all of them. A later entry cannot use that
|
||||
/// clock at all — "34 s into the run" is long past by the time a relog lands — so it runs on
|
||||
/// its own settle instead, measured from the moment that entry could take a chunk.
|
||||
/// Whether this entry's chunk is due. The first fire is run-relative (`WOW_PROBE_LUA_AT`, which
|
||||
/// `scripts/summon-live.sh` relies on); a later entry settles from when it became ready.
|
||||
fn probe_lua_due(probe: &ProbeLua, now: f32) -> bool {
|
||||
if probe.fired >= probe.entries {
|
||||
return false; // this entry has had its chunk (or there is no entry yet)
|
||||
return false; // fired already, or no entry yet
|
||||
}
|
||||
if probe.fired == 0 {
|
||||
now >= probe.at
|
||||
@@ -395,15 +295,8 @@ fn probe_lua_due(probe: &ProbeLua, now: f32) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Install `ProbeLog(text)` — the chunk's data channel OUT of the VM, as greppable `probe-log:`
|
||||
/// lines; before it existed a probe could only report through screenshots or by erroring.
|
||||
///
|
||||
/// **Into whichever VM is live, and re-installed whenever that changes** (decision 2116). A
|
||||
/// global installed once is gone at the next logout/login *and* at the next `ReloadUI()`, both of
|
||||
/// which build a new Lua state (1290/1291) — so a chunk that logged happily on its first run died
|
||||
/// on a nil call afterwards, which is precisely the shape of failure that reads as "the probe did
|
||||
/// nothing". Installed only while a probe chunk is configured; never part of the shipping API
|
||||
/// surface.
|
||||
/// Installs `ProbeLog(text)`, which logs `probe-log:` lines, into the live VM. Relog and
|
||||
/// `ReloadUI()` build a new Lua state, so it is reinstalled per VM; only while a chunk is set.
|
||||
fn install_probe_log(script: &benilla_ui::script::UiScript) {
|
||||
match script.lua().create_function(|_, text: String| {
|
||||
info!("probe-log: {text}");
|
||||
@@ -418,7 +311,7 @@ fn install_probe_log(script: &benilla_ui::script::UiScript) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the probe chunk once per world entry, on [`probe_lua_due`]'s schedule.
|
||||
/// Runs the chunk once per world entry, on [`probe_lua_due`]'s schedule.
|
||||
fn fire_probe_lua(
|
||||
mut probe: ResMut<ProbeLua>,
|
||||
time: ProbeClock,
|
||||
@@ -431,24 +324,14 @@ fn fire_probe_lua(
|
||||
return;
|
||||
}
|
||||
let Some(script) = script else {
|
||||
// No VM in the world at all — the deferral window between the world-entry edge and the
|
||||
// entry load, where the boot VM is parked out of every feed's reach
|
||||
// (`ui_script::ParkedBootVm`). Not an entry yet.
|
||||
// Between the world-entry edge and the entry load the boot VM is parked
|
||||
// (`ui_script::ParkedBootVm`): not an entry yet.
|
||||
probe.was_ready = false;
|
||||
return;
|
||||
};
|
||||
// **Ready = this entry can take a chunk**, which is three more facts on top of the VM the
|
||||
// block above just took. A chunk driving `CastSpell` against the *boot* VM — strings, emote
|
||||
// tokens and fonts, not one frame — dies on a nil, and "attempt to call a nil value" out of a
|
||||
// probe reads as a benilla bug rather than as a mis-scheduled probe.
|
||||
//
|
||||
// - **`InWorld`** — the entry edge has been taken, which is what ARMS the deferral window
|
||||
// below. Without it the window has a hole on its leading side: the entered-world message
|
||||
// and our first object update can arrive in the same drain, a frame ahead of the state
|
||||
// transition that parks the VM, so a chunk already past `at` would run there.
|
||||
// - **`PendingEntryUiLoad` gone** — the same window named from the other side: the in-game
|
||||
// UI is built, not merely owed.
|
||||
// - **a self player** — `UnitName("player")` is in half the chunks anyone writes.
|
||||
// Ready means the in-game VM, not the boot one: `InWorld` (the entered-world message and the
|
||||
// first object update can share a drain a frame before the VM is parked), the entry UI load
|
||||
// done, and a self player for `UnitName("player")`.
|
||||
let in_world = *state.get() == crate::char_select::ClientState::InWorld;
|
||||
let ready = in_world && entry_ui_pending.is_none() && !self_player.is_empty();
|
||||
let now = time.elapsed_secs();
|
||||
@@ -478,49 +361,10 @@ fn fire_probe_lua(
|
||||
}
|
||||
}
|
||||
|
||||
/// The PROBE DRAG driver (`WOW_PROBE_DRAG="A>B[;C>D…]"`, delay via `WOW_PROBE_DRAG_AT` seconds
|
||||
/// (default 14), one gesture step per `WOW_PROBE_DRAG_STEP` seconds (default 0.1)): drag one named
|
||||
/// frame onto another **through the real pointer path**, headlessly, in a live session.
|
||||
///
|
||||
/// **Why this exists as an instrument rather than a unit test.** The UI's own harness can already
|
||||
/// press, move and release ([`UiScript::mouse_button`]/`mouse_move`), and a drag test written
|
||||
/// there passes while the live client's drag is broken — because the harness supplies the whole
|
||||
/// world: no per-frame app feed between the press and the release, no real frames elapsing, no
|
||||
/// resolve pass, no `feed_party` re-push, no OS cursor. That gap cost a session (B310: the raid
|
||||
/// grid's second drag). This closes it by driving the SAME gesture in a real client: the press,
|
||||
/// the threshold-crossing move, the path, and the release each land on their own frame, with
|
||||
/// every app system running in between exactly as it does for a hand on the mouse.
|
||||
///
|
||||
/// It names FRAMES, not coordinates, and reads their centres out of the live layout, so a probe
|
||||
/// line survives a window moving. `WOW_PROBE_DRAG_LUA="<chunk>"` runs a chunk after each gesture —
|
||||
/// the report channel (`ProbeLog` from [`ProbeLuaPlugin`] is installed by that probe; this one
|
||||
/// prints the chunk's own returned string).
|
||||
/// The HOVER SWEEP (`WOW_PROBE_HOVER="Frame1;Frame2;…"`, `WOW_PROBE_HOVER_AT` seconds, default 14,
|
||||
/// `WOW_PROBE_HOVER_STEP` seconds per frame-to-frame step, default 0.25, looping until exit) —
|
||||
/// the cursor crosses each named frame's centre through the REAL pointer path, pressing nothing.
|
||||
///
|
||||
/// **A leg that does not ALTERNATE cannot be read.** `WOW_PROBE_HOVER_DUTY=<on>:<off>` sweeps for
|
||||
/// `on` seconds, parks the pointer over nothing for `off`, and repeats. Compare-the-two-halves
|
||||
/// designs that put all the parked frames at the start and all the swept frames after read the
|
||||
/// run's own drift as the effect: three legs of the same gesture, same binary, gave within-run
|
||||
/// `cpu_ms` deltas of +2.03, +1.02 and −0.61 ms while their per-phase µs columns agreed to 3%
|
||||
/// (decision 1634). Alternating pools both regimes across the same minutes, so drift cancels
|
||||
/// instead of landing on one side.
|
||||
///
|
||||
/// **`WOW_PROBE_HOVER_STEP` is the dial that decides what is being measured, and the default is
|
||||
/// not the director's gesture.** A hand spamming hovers moves the mouse *every frame*; at the
|
||||
/// 0.25 s default only 4 frames in 60 carry a pointer move, which divides whatever a move costs by
|
||||
/// fifteen and reads as "hovering is free" (it did, three times, before this note existed). Set it
|
||||
/// to ~0.016 to sweep at frame rate. `WOW_PROBE_HOVER_JITTER=<px>` then splits the two halves the
|
||||
/// sweep otherwise fuses: with one name and a jitter, the pointer moves every frame while the
|
||||
/// hovered frame and its tooltip stay put.
|
||||
///
|
||||
/// Built because a hover-cost pin was taken with a Lua driver that called `GameTooltip:SetOwner`
|
||||
/// directly, and that driver is not the gesture: it never moves `model.mouseover`, so it skips the
|
||||
/// hit test, `OnEnter`/`OnLeave`, the button's state textures and highlight, and whatever the
|
||||
/// shipped UI hangs off those. The reported symptom was "hovering costs 2 ms" and the instrument
|
||||
/// could not hover. This is the missing half of `WOW_HOVER_LOG`: that one records what a hand on
|
||||
/// the mouse does, and this one supplies the hand.
|
||||
/// `WOW_PROBE_HOVER="Frame1;Frame2;…"`: moves the pointer across each named frame's centre
|
||||
/// through the real pointer path, pressing nothing, from `WOW_PROBE_HOVER_AT` (default 14 s),
|
||||
/// one step per `WOW_PROBE_HOVER_STEP` (default 0.25 s; ~0.016 moves every frame), until exit.
|
||||
/// `WOW_PROBE_HOVER_DUTY=<on>:<off>` alternates sweeping and parking so run drift cancels.
|
||||
pub(crate) struct ProbeHoverPlugin;
|
||||
|
||||
impl Plugin for ProbeHoverPlugin {
|
||||
@@ -571,26 +415,20 @@ struct ProbeHover {
|
||||
names: Vec<String>,
|
||||
at: f32,
|
||||
step: f32,
|
||||
/// `WOW_PROBE_HOVER_JITTER=<px>` — nudge each step off the frame's centre by up to this many
|
||||
/// pixels, cycling through a small square. Zero (the default) parks the cursor exactly on the
|
||||
/// centre, so a ONE-NAME sweep issues the same coordinates forever and the pointer never moves.
|
||||
/// `WOW_PROBE_HOVER_JITTER=<px>`: offset per step; at 0 a one-name sweep never moves.
|
||||
jitter: f32,
|
||||
/// `WOW_PROBE_HOVER_DUTY=<on>:<off>` — alternate `on` seconds of sweeping with `off` seconds
|
||||
/// parked off every frame, forever. See the plugin doc for why a leg that does not alternate
|
||||
/// cannot be read at this resolution.
|
||||
/// `WOW_PROBE_HOVER_DUTY=<on>:<off>`: seconds sweeping, then seconds parked.
|
||||
duty: Option<(f32, f32)>,
|
||||
i: usize,
|
||||
next: f32,
|
||||
announced: bool,
|
||||
/// Which half of the duty cycle we are in (meaningless when `duty` is `None`).
|
||||
parked: bool,
|
||||
}
|
||||
|
||||
/// Where the pointer goes during a duty cycle's parked half: the top-left corner, over nothing.
|
||||
/// The parked pointer: the top-left corner, over no frame.
|
||||
const HOVER_PARK_AT: (f32, f32) = (2.0, 2.0);
|
||||
|
||||
/// Move the cursor onto the next named frame's centre. Loops forever, so a run of any length is a
|
||||
/// steady sweep — the population the recorder splits on.
|
||||
/// Moves the pointer onto the next named frame's centre, looping forever.
|
||||
fn fire_probe_hover(
|
||||
mut probe: ResMut<ProbeHover>,
|
||||
mut synthetic: ResMut<crate::ui_script::SyntheticPointer>,
|
||||
@@ -607,7 +445,7 @@ fn fire_probe_hover(
|
||||
}
|
||||
let Some(mut script) = script else { return };
|
||||
synthetic.0 = true;
|
||||
// The duty cycle, if armed: which half of the current period is this, and did it just flip?
|
||||
// Which half of the duty period this is, and whether it just flipped.
|
||||
if let Some((on, off)) = probe.duty {
|
||||
let period = (on + off).max(1e-3);
|
||||
let parked = (now - probe.at).rem_euclid(period) >= on;
|
||||
@@ -637,24 +475,18 @@ fn fire_probe_hover(
|
||||
let (dx, dy) = jitter_offset(probe.jitter, probe.i);
|
||||
script.mouse_move(x + dx, y + dy);
|
||||
}
|
||||
// Named but unresolved is worth saying once per pass rather than silently sweeping air —
|
||||
// a sweep over frames that do not exist reads as "hovering is free".
|
||||
// A sweep over missing frames would read as free hovering, so say so.
|
||||
None => warn!("probe-hover: {name} has no resolved rect — nothing hovered this step"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A deterministic offset inside a `j`-pixel square, cycling with the step index.
|
||||
///
|
||||
/// It exists to separate the two costs a hover sweep otherwise fuses: **the pointer moved** (the
|
||||
/// hit test re-runs) and **the hovered frame changed** (the tooltip is torn down and rebuilt). A
|
||||
/// one-name sweep with a jitter moves the pointer every step while the hovered frame — and the
|
||||
/// tooltip on it — stay put, which is the first of the two on its own.
|
||||
/// A deterministic offset inside a `j`-pixel square: the pointer moves every step while the
|
||||
/// hovered frame stays put, isolating hit-test cost from tooltip rebuilds.
|
||||
fn jitter_offset(j: f32, i: usize) -> (f32, f32) {
|
||||
if j <= 0.0 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
// A 4-phase square walk: (+,+) → (-,+) → (-,-) → (+,-). Every step is a real move, and the
|
||||
// cursor never leaves a `j`-pixel box around the centre.
|
||||
// A 4-phase square walk: (+,+), (-,+), (-,-), (+,-).
|
||||
let (sx, sy) = match i % 4 {
|
||||
0 => (1.0, 1.0),
|
||||
1 => (-1.0, 1.0),
|
||||
@@ -697,8 +529,9 @@ impl Plugin for ProbeDragPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ProbeDragPlugin`]'s state machine: which pair, which step of the gesture, and when the next
|
||||
/// step is due.
|
||||
/// [`ProbeDragPlugin`]'s state: `WOW_PROBE_DRAG="A>B[;C>D…]"` drags frame A onto B through the
|
||||
/// real pointer path from `WOW_PROBE_DRAG_AT` (default 14 s), one step per `WOW_PROBE_DRAG_STEP`
|
||||
/// (default 0.1 s); `WOW_PROBE_DRAG_LUA` is a chunk whose returned string is logged after each.
|
||||
#[derive(Resource)]
|
||||
struct ProbeDrag {
|
||||
pairs: Vec<(String, String)>,
|
||||
@@ -706,16 +539,15 @@ struct ProbeDrag {
|
||||
step: f32,
|
||||
report: String,
|
||||
pair: usize,
|
||||
/// The step within the current gesture — see [`fire_probe_drag`]'s table.
|
||||
/// The step within the current gesture, per [`fire_probe_drag`].
|
||||
phase: usize,
|
||||
next: f32,
|
||||
from: (f32, f32),
|
||||
to: (f32, f32),
|
||||
}
|
||||
|
||||
/// One frame's centre in the VM's own units, read out of the live layout (`GetLeft`/`GetRight`/
|
||||
/// `GetTop`/`GetBottom` — the same space [`crate::ui_script::input`] feeds the cursor in).
|
||||
/// `None` when the frame does not exist or has no resolved rect.
|
||||
/// A frame's centre from its live `GetLeft`/`GetRight`/`GetTop`/`GetBottom`, in the space
|
||||
/// [`crate::ui_script::input`] feeds the cursor in.
|
||||
fn frame_centre(script: &benilla_ui::script::UiScript, name: &str) -> Option<(f32, f32)> {
|
||||
let read = |edge: &str| {
|
||||
script
|
||||
@@ -730,11 +562,8 @@ fn frame_centre(script: &benilla_ui::script::UiScript, name: &str) -> Option<(f3
|
||||
))
|
||||
}
|
||||
|
||||
/// Advance the scripted drag by at most one step per `probe.step` seconds.
|
||||
///
|
||||
/// The gesture is deliberately spread across real frames rather than run in one call: a press and
|
||||
/// a release inside a single frame is a *click*, and the whole point here is to exercise what the
|
||||
/// app does BETWEEN them.
|
||||
/// Advances the drag at most one step per `probe.step` seconds, each on its own frame: a press
|
||||
/// and release in one frame is a click.
|
||||
fn fire_probe_drag(
|
||||
mut probe: ResMut<ProbeDrag>,
|
||||
mut synthetic: ResMut<crate::ui_script::SyntheticPointer>,
|
||||
@@ -762,8 +591,6 @@ fn fire_probe_drag(
|
||||
|
||||
let (from_name, to_name) = probe.pairs[probe.pair].clone();
|
||||
match probe.phase {
|
||||
// Read both centres and park the cursor on the source — the hover the player makes
|
||||
// before they press.
|
||||
0 => {
|
||||
let (Some(from), Some(to)) = (
|
||||
frame_centre(&script, &from_name),
|
||||
@@ -785,13 +612,12 @@ fn fire_probe_drag(
|
||||
let (x, y) = probe.from;
|
||||
script.mouse_button(x, y, "LeftButton", true);
|
||||
}
|
||||
// Past the 4-px drag threshold — this is the move that fires `OnDragStart`.
|
||||
// Past the 4-px drag threshold: fires `OnDragStart`.
|
||||
2 => {
|
||||
let (x, y) = probe.from;
|
||||
script.mouse_move(x + 8.0, y + 8.0);
|
||||
}
|
||||
// Two waypoints along the path, so anything that only breaks while the drag is IN FLIGHT
|
||||
// (a repaint that re-seats the row under the cursor, say) gets frames to do it in.
|
||||
// Two waypoints, so the drag spends frames in flight.
|
||||
3 | 4 => {
|
||||
let t = if probe.phase == 3 { 0.4 } else { 0.8 };
|
||||
let (fx, fy) = probe.from;
|
||||
@@ -806,10 +632,8 @@ fn fire_probe_drag(
|
||||
let (x, y) = probe.to;
|
||||
script.mouse_button(x, y, "LeftButton", false);
|
||||
}
|
||||
// The self-check: hover the source where it now DRAWS and ask who the hit test says is
|
||||
// there. A frame whose hit rect and drawn rect have parted company is invisible to any
|
||||
// harness that presses the centre it just read — and it is exactly what "I can't grab
|
||||
// anything any more" looks like from a hand on the mouse.
|
||||
// Self-check: hover the source where it now draws and ask the hit test who is there, to
|
||||
// catch a hit rect that has parted from the drawn one.
|
||||
7 => {
|
||||
if let Some((x, y)) = frame_centre(&script, &from_name) {
|
||||
script.mouse_move(x, y);
|
||||
@@ -840,8 +664,7 @@ fn fire_probe_drag(
|
||||
}
|
||||
probe.pair += 1;
|
||||
probe.phase = 0;
|
||||
// Hand the pointer back between gestures: the app's own feed runs for a frame, which
|
||||
// is what a player's hand does too, and is where a stale hover would show up.
|
||||
// Hand the pointer back for a frame between gestures, where a stale hover shows.
|
||||
synthetic.0 = false;
|
||||
return;
|
||||
}
|
||||
@@ -853,8 +676,7 @@ fn fire_probe_drag(
|
||||
mod tests {
|
||||
use super::{probe_lua_due, ProbeLua};
|
||||
|
||||
/// A probe configured the way `WOW_PROBE_LUA=… WOW_PROBE_LUA_AT=10` configures one, with the
|
||||
/// per-entry bookkeeping set by hand.
|
||||
/// A probe as `WOW_PROBE_LUA_AT=10` configures one, with its entry bookkeeping set by hand.
|
||||
fn probe(entries: u32, fired: u32, ready_at: f32) -> ProbeLua {
|
||||
ProbeLua {
|
||||
chunk: "ProbeLog('x')".into(),
|
||||
@@ -867,8 +689,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The first entry is still `WOW_PROBE_LUA_AT` on the PROCESS clock — the meaning every
|
||||
/// recorded probe line in `decisions/` is tuned against.
|
||||
#[test]
|
||||
fn first_fire_waits_for_the_process_delay() {
|
||||
let p = probe(1, 0, 2.0);
|
||||
@@ -876,23 +696,18 @@ mod tests {
|
||||
assert!(probe_lua_due(&p, 10.0), "at WOW_PROBE_LUA_AT");
|
||||
}
|
||||
|
||||
/// Nothing fires before there is a world entry to fire into — the login and character
|
||||
/// screens sit here however long they take.
|
||||
#[test]
|
||||
fn nothing_fires_before_the_first_entry() {
|
||||
assert!(!probe_lua_due(&probe(0, 0, 0.0), 60.0));
|
||||
}
|
||||
|
||||
/// One chunk per entry, and no more: the frames after a fire are not a second one.
|
||||
#[test]
|
||||
fn an_entry_takes_exactly_one_chunk() {
|
||||
assert!(!probe_lua_due(&probe(1, 1, 2.0), 60.0));
|
||||
assert!(!probe_lua_due(&probe(2, 2, 40.0), 90.0));
|
||||
}
|
||||
|
||||
/// **The gap 2116 hit head-on**: the second entry re-arms, on its OWN clock. The process
|
||||
/// delay is long past by the time a relog lands, so reusing it would fire the chunk into the
|
||||
/// loading screen; the settle is measured from the moment that entry could take a chunk.
|
||||
/// A later entry re-arms on its own settle, measured from when it became ready.
|
||||
#[test]
|
||||
fn a_later_entry_re_arms_on_its_own_clock() {
|
||||
let p = probe(2, 1, 40.0);
|
||||
|
||||
@@ -1,38 +1,13 @@
|
||||
//! The **dress census** (`WOW_DRESS_CENSUS=<secs>[,<every>]`) — one line per streamed player near
|
||||
//! the body saying what the wire asked for, what we resolved, and what is actually hanging off the
|
||||
//! skeleton right now.
|
||||
//! `WOW_DRESS_CENSUS=<secs>[,<every>]`: one line per streamed player near the body giving what the
|
||||
//! wire asked for, what we resolved and what hangs off the skeleton.
|
||||
//!
|
||||
//! It exists because those are three different things and a screenshot conflates all three. A
|
||||
//! helm or cloak the player chose to hide, drawn anyway, is exactly a gap between the
|
||||
//! first and the third: the descriptor carried `PLAYER_FLAGS_HIDE_HELM`, the resolver ignored it,
|
||||
//! and a helm model hung off the head. Nothing in a picture separates that from "this character
|
||||
//! simply has a helm equipped", which is why the report needed a reader rather than an eye.
|
||||
//! - `flags`/`hide`: `PLAYER_FLAGS` and its `HIDE_HELM 0x400`/`HIDE_CLOAK 0x800` bits, a public
|
||||
//! field, so every player in range is readable.
|
||||
//! - `helm`/`cloak`: the resolved `ItemDisplayInfo` ids ([`Equipment`]); `0` is not dressed.
|
||||
//! - `spawned`: the attach slots standing under the unit ([`HeldAttached::spawned_slots`]).
|
||||
//! - `contradictions`: bodies whose flags hide a piece that is dressed anyway, tagged `!!`.
|
||||
//!
|
||||
//! ```text
|
||||
//! DRESS_CENSUS t=20.0 players=2 hiding-helm=1 hiding-cloak=1 contradictions=0 radius=120 body=(-8949.95,-132.49,83.57)
|
||||
//! DRESS 0xF150000000000038 d= 0.0 flags=0x00020c02 hide=helm+cloak helm=0 cloak=0 settled=1 spawned=[main,shL,shR] Probethree
|
||||
//! DRESS 0xF150000000000079 d= 12.4 flags=0x00020002 hide=- helm=32154 cloak=41205 settled=1 spawned=[main,off,helm,shL,shR] Naz
|
||||
//! ```
|
||||
//!
|
||||
//! - **`flags` / `hide`** are the wire's own: `PLAYER_FLAGS` and its `HIDE_HELM 0x400` /
|
||||
//! `HIDE_CLOAK 0x800` bits, which are PUBLIC — so every player in range is readable here, not
|
||||
//! only our own body, and "does a remote player's preference reach us" is a line rather than an
|
||||
//! argument.
|
||||
//! - **`helm` / `cloak`** are the resolved `ItemDisplayInfo` ids ([`Equipment`]) — `0` means the
|
||||
//! body is dressed without that piece, whether because nothing is equipped there or because the
|
||||
//! preference suppressed it.
|
||||
//! - **`spawned`** is the attach-slot list actually standing under the unit
|
||||
//! ([`HeldAttached::spawned_slots`]) — the visual truth, read off the ECS rather than inferred.
|
||||
//! - **`contradictions`** is the headline number and the whole point: a body whose flags say hide
|
||||
//! while a `helm` display or a spawned `helm` slot says otherwise is tagged `!!` and counted.
|
||||
//! B123 reproduces as `contradictions=1`; the fix reads `contradictions=0` with `hide=` still
|
||||
//! set, which is the distinction "the helm is gone" alone cannot make (it is also what a naked
|
||||
//! head looks like when the *preference* never arrived).
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=<geared body> WOW_NOSOUND=1 \
|
||||
//! WOW_DRESS_CENSUS="20,10" WOW_PROBE_EXIT_AT=45 cargo run -q -p benilla
|
||||
//! ```
|
||||
//! How to run it: `docs/CONTRIBUTING.md`, "Running it unattended".
|
||||
|
||||
use benilla_assets::coords::bevy_to_wow;
|
||||
use benilla_protocol::EntityKind;
|
||||
@@ -43,8 +18,7 @@ use crate::entities::{Equipment, HeldAttached, ATTACH_SLOT_NAMES};
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{Guid, NetEntity, ObjectStore, SelfPlayer};
|
||||
|
||||
/// How far from the body the census looks, in yards — the unit-visual census's radius, for the
|
||||
/// same reason: comfortably past what the server streams, so an empty census means an empty scene.
|
||||
/// Census radius in yards, past what the server streams; `WOW_DRESS_CENSUS_RADIUS` overrides.
|
||||
const DEFAULT_RADIUS: f32 = 120.0;
|
||||
|
||||
pub(crate) struct DressCensusPlugin;
|
||||
@@ -62,15 +36,14 @@ impl Plugin for DressCensusPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`DressCensusPlugin`] state: when the next census fires, and how often after that (`0` = once).
|
||||
/// [`DressCensusPlugin`] state; `every` of 0 fires once.
|
||||
#[derive(Resource)]
|
||||
struct DressCensus {
|
||||
next: f32,
|
||||
every: f32,
|
||||
}
|
||||
|
||||
/// What the census reads per entity: identity, kind, pose, the resolved worn set, and the attach
|
||||
/// roots actually standing.
|
||||
/// What the census reads per entity.
|
||||
type DressQuery = (
|
||||
Entity,
|
||||
&'static Guid,
|
||||
@@ -87,14 +60,10 @@ fn fire_dress_census(
|
||||
time: ProbeClock,
|
||||
names: Res<NameCache>,
|
||||
body: Query<(Entity, &Transform), With<SelfPlayer>>,
|
||||
// The self body's parts, one line each (`DRESS_PART`) — the draw-population inventory.
|
||||
// The self body's parts, one `DRESS_PART` line each.
|
||||
body_parts: crate::entities::BodyPartsDesc,
|
||||
entities: Query<DressQuery>,
|
||||
// The body's draw population (`parts=`/`mats=`): every mesh part hanging under the unit —
|
||||
// body batches, each attach model's batches, cards — and how many DISTINCT materials
|
||||
// they bind. The gap between the two is the ceiling on merging a body's batches by
|
||||
// material (1929's first proposal): what a merge could collapse, read off a dressed body
|
||||
// instead of guessed from a model's batch table.
|
||||
// `parts=`/`mats=`: mesh parts under the unit and the distinct materials they bind.
|
||||
children: Query<&Children>,
|
||||
parts: Query<&MeshMaterial3d<benilla_assets::materials::WowModelMaterial>>,
|
||||
) {
|
||||
@@ -116,7 +85,7 @@ fn fire_dress_census(
|
||||
.and_then(|v| v.parse::<f32>().ok())
|
||||
.unwrap_or(DEFAULT_RADIUS);
|
||||
|
||||
// `(is_contradiction, sort key, line)`. A contradiction sorts to the top: it is the finding.
|
||||
// `(is_contradiction, sort key, line)`; contradictions sort first.
|
||||
let mut rows: Vec<(bool, i64, String)> = Vec::new();
|
||||
let (mut hiding_helm, mut hiding_cloak, mut bad) = (0u32, 0u32, 0u32);
|
||||
for (unit, guid, net, t, store, equipment, attached) in &entities {
|
||||
@@ -137,9 +106,7 @@ fn fire_dress_census(
|
||||
.enumerate()
|
||||
.filter_map(|(i, e)| e.map(|_| ATTACH_SLOT_NAMES[i]))
|
||||
.collect();
|
||||
// The contradiction: the wire asked for a piece to be hidden and it is dressed anyway —
|
||||
// either resolved onto the body or standing as an attach model. This is B123's symptom,
|
||||
// stated as a predicate instead of a screenshot.
|
||||
// The wire hides a piece that is resolved onto the body or standing as an attach model.
|
||||
let helm_shown = eq.helm != 0 || spawned.contains(&"helm");
|
||||
let contradiction = (hide_helm && helm_shown) || (hide_cloak && eq.cloak != 0);
|
||||
bad += u32::from(contradiction);
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
//! `WOW_FX_CENSUS=1` — where this frame's particle draws are actually addressed, and whether the
|
||||
//! view they name is switched on.
|
||||
//!
|
||||
//! Built to answer "the login screen's braziers are simulating but nothing is on screen." A draw
|
||||
//! record names the **main-world camera entity** whose view it belongs to
|
||||
//! ([`benilla_world::particles::buffer::EffectDraw::cam`]), and `particles::sim` resolves that for a booth-layered
|
||||
//! emitter by finding the first booth camera whose `RenderLayers` intersect the emitter's. If two
|
||||
//! booths share a layer, the wrong camera can win — and if that camera is inactive, the emitter
|
||||
//! simulates forever and never draws. Nothing about that is visible in a frame time, a trace, or
|
||||
//! the emitter's own state: the particles are all *correct*, just addressed to a view nobody is
|
||||
//! rendering. The census makes it a line of output (decision 0775).
|
||||
//!
|
||||
//! Prints every 2 s: frames, live vertex count, how many DISTINCT vertex-buffer states were seen
|
||||
//! (the liveness check — a frozen sim repeats one state), and the per-camera draw histogram with
|
||||
//! each camera's booth token and active flag.
|
||||
//! `WOW_FX_CENSUS=1`: which camera each particle draw is addressed to, and whether that camera
|
||||
//! is active. A booth emitter takes the first booth camera sharing its `RenderLayers`, so two
|
||||
//! booths on one layer can route draws to an inactive view. Prints every 2 s: frames, vertex
|
||||
//! count, distinct vertex-buffer states (a frozen sim repeats one) and the per-camera histogram.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
@@ -22,7 +11,7 @@ use bevy::time::Real;
|
||||
use crate::portrait::BoothCam;
|
||||
use benilla_world::particles::buffer::EffectQuads;
|
||||
|
||||
/// Off unless `WOW_FX_CENSUS=1` — the plugin adds no system at all otherwise.
|
||||
/// Adds no system unless `WOW_FX_CENSUS=1`.
|
||||
pub(crate) fn plugin(app: &mut App) {
|
||||
if std::env::var("WOW_FX_CENSUS").as_deref() != Ok("1") {
|
||||
return;
|
||||
@@ -41,8 +30,7 @@ fn census(
|
||||
use std::hash::{Hash, Hasher};
|
||||
let Some(quads) = quads else { return };
|
||||
*frames += 1;
|
||||
// A cheap fingerprint of the head of the vertex buffer: enough to tell "this frame differs
|
||||
// from the last" without hashing megabytes.
|
||||
// Fingerprints only the head of the vertex buffer: enough to tell frames apart.
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
quads.verts.len().hash(&mut h);
|
||||
for v in quads.verts.iter().take(600) {
|
||||
@@ -56,10 +44,7 @@ fn census(
|
||||
*last = time.elapsed_secs();
|
||||
|
||||
let mut per_cam: HashMap<Entity, usize> = HashMap::new();
|
||||
// How many of this frame's draws take the SCENE-LIT pipeline arm. Only ~5% of the emitter
|
||||
// corpus clears the unlit file bit, so "the lit variant compiles" and "the lit variant ever
|
||||
// runs" are entirely different claims — and a scene with no lit emitter in it cannot tell
|
||||
// them apart. This counter is what makes the second one checkable.
|
||||
// Draws on the scene-lit pipeline arm: only ~5% of emitters clear the unlit bit.
|
||||
let mut lit_draws = 0usize;
|
||||
for d in &quads.draws {
|
||||
*per_cam.entry(d.cam).or_default() += 1;
|
||||
|
||||
@@ -1,43 +1,14 @@
|
||||
//! The **under-floor census** (`WOW_GROUND_CENSUS=<secs>[,<every>]`) — the instrument that closes
|
||||
//! B197's symptom: *"±half the NPCs in one building sit below the floor"*.
|
||||
//! `WOW_GROUND_CENSUS=<secs>[,<every>]`: per ground-clamped body ([`ground_derived`], including a
|
||||
//! player the server moves on a spline), whether it sits below the pose the server sent.
|
||||
//!
|
||||
//! Every reported site of that bug was a picture. A picture cannot say whether a unit is below the
|
||||
//! floor **because the server put it there** (then it is the server's ledger) or because *we* pulled
|
||||
//! it down off the pose the server sent (then it is ours), and it cannot say whether the floor the
|
||||
//! unit belongs on is even collidable from where it stands. Both questions are one line of numbers
|
||||
//! per unit:
|
||||
//! - `seat`: the server's Z ([`GroundClamped::seat_y`]), before the ground clamp.
|
||||
//! - `drop`: `seat − z`, how far the clamp pulled the unit down; `sunk` counts those past
|
||||
//! [`SUNK_YD`].
|
||||
//! - `above`: the lowest walkable surface strictly above the feet.
|
||||
//! - `terrain`: the MCNK height under it, what the clamp finds before a building's collider loads.
|
||||
//!
|
||||
//! ```text
|
||||
//! UGD 0x… unit "Stormwind Guard" z=5.02 seat=9.14 drop=+4.12 terrain=5.02 above=9.07 (+4.05) spline=0 swim=0
|
||||
//! ```
|
||||
//!
|
||||
//! - **`seat`** is the server's own Z for this unit — the pose the create block / a move packet /
|
||||
//! its spline last wrote ([`GroundClamped::seat_y`]), before the ground clamp had its say.
|
||||
//! - **`drop`** is `seat − z`: how far the clamp pulled the unit *below* the server's pose. This is
|
||||
//! the number that names the bug; on a healthy scene it is within a yard either way (the clamp's
|
||||
//! whole job is a small correction), and `sunk` counts the ones past [`SUNK_YD`].
|
||||
//! - **`above`** is the lowest walkable surface strictly above the unit's feet, found by walking a
|
||||
//! one-sided down-ray through the stack. A unit with a floor right there and a big `drop` is
|
||||
//! standing under a floor it could be standing on — the exact shape of the report.
|
||||
//! - **`terrain`** is the MCNK height under it, which is what the clamp finds when a building's own
|
||||
//! collider has not attached yet.
|
||||
//!
|
||||
//! Pair it with the checkout's probe identity (the `probe` skill) and a `.go` to the reported spot:
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe<n> WOW_NOSOUND=1 \
|
||||
//! WOW_PROBE_CHAT=".go xyz 6558.80 457.23 9.07 1" \
|
||||
//! WOW_GROUND_CENSUS="30,15" WOW_PROBE_EXIT_AT=75 cargo run -q -p benilla
|
||||
//! ```
|
||||
//!
|
||||
//! The repeat form is the load-race half: a census at 30 s and again at 45 s says whether a sunk
|
||||
//! unit *stays* sunk once the world has finished arriving, which is what separates a transient from
|
||||
//! the ratchet decision 1384 removed.
|
||||
//!
|
||||
//! It lists exactly the bodies the clamp is responsible for ([`ground_derived`]) — since B357 that
|
||||
//! includes a **player the server moves along a spline**, which the `player` tag names. That is the
|
||||
//! whole readout for a Playerbots server: a bot mid-path reads `player spline=1` with a small
|
||||
//! `drop`, and the bug it closes read the wire Z with no row here at all.
|
||||
//! The repeat form tells a transient sink during loading from one that stays. How to run it:
|
||||
//! `docs/CONTRIBUTING.md`, "Running it unattended".
|
||||
|
||||
use benilla_assets::coords::bevy_to_wow;
|
||||
use benilla_protocol::EntityKind;
|
||||
@@ -51,18 +22,13 @@ use crate::net::{
|
||||
SelfPlayer, Spline,
|
||||
};
|
||||
|
||||
/// A drop past this (yd) counts as **sunk** — the headline count. The clamp's honest corrections are
|
||||
/// the small float off a slightly-high wire Z and the little hill a straight spline cuts through;
|
||||
/// half a yard is well clear of both and well under a storey.
|
||||
/// A drop past this (yd) counts as sunk: clear of the clamp's small corrections, under a storey.
|
||||
const SUNK_YD: f32 = 0.5;
|
||||
|
||||
/// How far above a unit's feet the stack walk starts looking for the floor it should be on. A
|
||||
/// storey and a bit: high enough to clear any building floor a unit could be sunk beneath, low
|
||||
/// enough that it doesn't report the roof of an unrelated structure.
|
||||
/// Where the stack walk starts above the feet (yd): past one storey, below most roofs.
|
||||
const CEILING_YD: f32 = 12.0;
|
||||
|
||||
/// Feet-clearance for "strictly above" in the stack walk — below this the surface *is* the one the
|
||||
/// unit is standing on, not one above it.
|
||||
/// A surface within this of the feet (yd) is the one the unit stands on.
|
||||
const ABOVE_EPS: f32 = 0.05;
|
||||
|
||||
pub(crate) struct GroundCensusPlugin;
|
||||
@@ -88,8 +54,7 @@ impl Plugin for GroundCensusPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`GroundCensusPlugin`] state: when the next census fires, how often after that (`0` = once), and
|
||||
/// how far from the body to look.
|
||||
/// [`GroundCensusPlugin`] state; `every` of 0 fires once, `radius` from `WOW_GROUND_CENSUS_RADIUS`.
|
||||
#[derive(Resource)]
|
||||
struct GroundCensus {
|
||||
next: f32,
|
||||
@@ -97,8 +62,7 @@ struct GroundCensus {
|
||||
radius: f32,
|
||||
}
|
||||
|
||||
/// What the census reads per unit: identity, kind, pose, the clamp's memo (the seat), whether a
|
||||
/// path is driving it, and whether it is swimming (which exempts it from the clamp).
|
||||
/// What the census reads per unit; a swimmer is exempt from the clamp.
|
||||
type CensusQuery = (
|
||||
&'static Guid,
|
||||
&'static NetEntity,
|
||||
@@ -106,21 +70,17 @@ type CensusQuery = (
|
||||
Option<&'static GroundClamped>,
|
||||
Option<&'static Spline>,
|
||||
Has<CreatureSwimming>,
|
||||
// The subject test's other two inputs, so the listing is exactly the clamp's own population
|
||||
// rather than a second, hand-kept guess at it.
|
||||
// The rest of `ground_derived`'s inputs, so the listing is the clamp's own population.
|
||||
Has<Embodied>,
|
||||
Has<RemoteMotion>,
|
||||
);
|
||||
|
||||
/// One census line per [`ground_derived`] body within [`GroundCensus::radius`] of ours, worst drop
|
||||
/// first, under a summary line naming the count that matters.
|
||||
/// One line per [`ground_derived`] body within [`GroundCensus::radius`], worst drop first, under
|
||||
/// a summary line.
|
||||
fn fire_ground_census(
|
||||
mut probe: ResMut<GroundCensus>,
|
||||
time: ProbeClock,
|
||||
// The name each row belongs to. "Which NPC is sunk" is a name question — a guid sends the
|
||||
// reader back to the server's own tables to answer it — and the cache is already warm: it is
|
||||
// asked at first *sight* for every unit that streams in. A `None` is a name still in flight,
|
||||
// never a missing one.
|
||||
// Warm from first sight of every unit; a `None` is a name still in flight.
|
||||
names: Res<NameCache>,
|
||||
net_commands: Res<NetCommands>,
|
||||
world: benilla_world::collision::WorldCollision,
|
||||
@@ -172,16 +132,13 @@ fn fire_ground_census(
|
||||
"UGD {:#018x} {} {name:?} display={} pos=({:.2},{:.2},{:.2}) z={z:.2} seat={seat:.2} \
|
||||
drop={drop:+.2} terrain={} above={} spline={} swim={}",
|
||||
guid.0,
|
||||
// Which kind this row is: since B357 the listing is not all creatures, and a
|
||||
// player row is the one a Playerbots report is about.
|
||||
// The listing includes spline-driven players.
|
||||
if net.kind == EntityKind::Player {
|
||||
"player"
|
||||
} else {
|
||||
"unit"
|
||||
},
|
||||
// The display id, so a row hands straight to `WOW_CLAMP_TRACE=<display>` — the
|
||||
// per-frame `clmp` readout is filtered by display and nothing else, and a census
|
||||
// row that names only a guid cannot be followed up with it.
|
||||
// The key `WOW_CLAMP_TRACE=<display>` filters its per-frame readout by.
|
||||
net.display_id.unwrap_or(0),
|
||||
wow[0],
|
||||
wow[1],
|
||||
@@ -194,9 +151,7 @@ fn fire_ground_census(
|
||||
));
|
||||
}
|
||||
rows.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
// A swimmer is EXEMPT from the clamp by design (its wire Z is its swim depth), so its seat is
|
||||
// simply the last Z the clamp saw before it entered the water — a stale memo, not a drop. It
|
||||
// stays in the listing and out of the count.
|
||||
// A swimmer's wire Z is its swim depth and its seat is stale: listed, not counted.
|
||||
let sunk = rows
|
||||
.iter()
|
||||
.filter(|(d, swim, _)| *d > SUNK_YD && !swim)
|
||||
@@ -220,11 +175,8 @@ fn fire_ground_census(
|
||||
}
|
||||
}
|
||||
|
||||
/// The lowest walkable surface **strictly above** `feet` — the floor a sunk unit should be standing
|
||||
/// on. Walks the one-sided down-ray stack from [`CEILING_YD`] overhead: each hit above the feet
|
||||
/// becomes the new candidate and the next cast starts just under it, so a unit beneath three
|
||||
/// storeys reports the one immediately over its head rather than the roof. `None` when nothing is
|
||||
/// overhead — the healthy outdoor case.
|
||||
/// The lowest walkable surface strictly above `feet`: casts down from [`CEILING_YD`] overhead,
|
||||
/// restarting just under each hit, so the nearest floor overhead wins over the roof.
|
||||
fn lowest_surface_above(
|
||||
world: &benilla_world::collision::WorldCollision,
|
||||
feet: Vec3,
|
||||
@@ -237,10 +189,8 @@ fn lowest_surface_above(
|
||||
break;
|
||||
}
|
||||
let origin = Vec3::new(feet.x, from, feet.z);
|
||||
// A miss ENDS the walk with what it already has — it never discards it. `?` here read as
|
||||
// "no floor above this unit" for three of B197's four sunk NPCs, whose second cast (from
|
||||
// just under the floor it had already found, down to feet sitting on the terrain) missed
|
||||
// that terrain by a float hair. The instrument's own first finding was its own bug.
|
||||
// A miss ends the walk keeping what it found: a cast can miss the feet's own surface by
|
||||
// a float hair.
|
||||
let Some(hit) = world.ray_body(origin, Dir3::NEG_Y, reach) else {
|
||||
break;
|
||||
};
|
||||
|
||||
@@ -1,40 +1,11 @@
|
||||
//! The **motion-jitter meter** (`WOW_JITTER=<name-substr>[,<start_s>]`) — the instrument that turns
|
||||
//! "that NPC looks shaky up close" into a number, and separates the three things a screenshot
|
||||
//! conflates: the camera moving, the unit's root moving, and the *pose* moving.
|
||||
//! `WOW_JITTER=<name-substr>[,<start_s>]`: per frame, the camera, root and pose terms of a
|
||||
//! subject's rendered position as first and second differences, in mm and in pixels at the
|
||||
//! subject's distance. Δ² separates noise (≈2n) from smooth motion (≈A/3600 at 60 Hz), and `Δ/dt`
|
||||
//! beside `dt` separates uneven frame timing from a noisy pose.
|
||||
//!
|
||||
//! A rendered bone's screen position is `pose · root · (origin − camera)`. Each of those three
|
||||
//! terms is sampled here every frame and reported as its own **first and second differences**:
|
||||
//! The `mm` columns are milli-yards (yards × 1000), not millimetres; pixel columns are unaffected.
|
||||
//!
|
||||
//! - **Δ (first difference)** is motion. A slow idle has one; so does noise. On its own it cannot
|
||||
//! tell them apart, which is why "the model moved 0.4 mm this frame" is not a finding.
|
||||
//! - **Δ² (second difference)** is *curvature per frame*, and that is the discriminator. Smooth
|
||||
//! motion at 60 Hz has almost none — a 2 s idle of amplitude `A` carries `A·ω²·dt² ≈ A/3600`,
|
||||
//! i.e. tens of microns for a centimetre-scale sway — while uncorrelated per-frame noise of
|
||||
//! amplitude `n` shows up at `≈2n`. Two orders of magnitude apart, from the same series.
|
||||
//! - **Δ(v) = Δ²/dt²** separates *arithmetic* noise from **judder**: if the pose is smooth in time
|
||||
//! but the frame deltas are not, `Δ` is ragged while the velocity `Δ/dt` is clean. That is the
|
||||
//! whole difference between "the numbers are wrong" and "the numbers are right and arriving at
|
||||
//! uneven times", and it is invisible to any instrument that does not log `dt` beside the pose.
|
||||
//!
|
||||
//! **The `mm` columns are milli-YARDS** — yards × 1000, the map unit's own thousandth. On a true
|
||||
//! 0.9144 m yard the f32 ULP at Elwynn is 0.893 mm, not the 0.9766 these columns print (decision
|
||||
//! 1618). The pixel columns are unaffected: they are computed from yards throughout, so the unit
|
||||
//! cancels. Kept as-is rather than renamed because these columns are the comparison surface for two
|
||||
//! landed records (1609, 1617) and a rescale would silently break every figure in them.
|
||||
//!
|
||||
//! Everything is reported in **millimetres and in pixels at the subject's own distance**, because
|
||||
//! a world-space wobble is only a defect in proportion to how many pixels it covers: the same
|
||||
//! 1 mm is 0.03 px across a courtyard and 0.6 px pressed against the camera, which is exactly why
|
||||
//! this class of report always arrives as "…when I'm zoomed right in".
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe<n> WOW_NOSOUND=1 \
|
||||
//! WOW_PROBE_CHAT=".go xyz -9481.53 76.10 56.57 0" \
|
||||
//! WOW_PROBE_CAM="-82.55,0,0@18" WOW_JITTER="guard,22" WOW_PROBE_EXIT_AT=40 cargo run -q -p benilla
|
||||
//! ```
|
||||
//!
|
||||
//! One `JIT` line per frame, TSV, so a series is read by a script rather than by eye. The columns
|
||||
//! are named in the `JIT#` header the meter prints when it arms.
|
||||
//! One `JIT` TSV line per frame; the `JIT#` header names the columns when the meter arms.
|
||||
|
||||
use bevy::camera::Projection;
|
||||
use bevy::prelude::*;
|
||||
@@ -52,72 +23,49 @@ use crate::net::{Guid, NetEntity, SelfPlayer};
|
||||
/// `WOW_JITTER`'s parsed knobs plus the two-frame history the differences are taken over.
|
||||
#[derive(Resource)]
|
||||
struct JitterMeter {
|
||||
/// Lower-cased substring of the subject's name; empty = the nearest rigged non-self unit.
|
||||
/// Lower-cased name substring; empty is the nearest rigged non-self unit, `self` the avatar.
|
||||
want: String,
|
||||
/// Wall-clock second the meter arms (the world has to have streamed in first).
|
||||
/// Wall-clock second the meter arms.
|
||||
at: f32,
|
||||
/// Whose history [`Self::prev`] holds — a different entity restarts the series.
|
||||
/// Whose history [`Self::prev`] holds; a different entity restarts the series.
|
||||
subject: Option<Entity>,
|
||||
/// Per-bone model-space translations, last frame and the one before.
|
||||
prev: Vec<Vec3>,
|
||||
prev2: Vec<Vec3>,
|
||||
/// **What a vertex does** — each bone's row applied to a probe point [`FLESH_R`] out along
|
||||
/// each local axis, same two-frame window. A bone origin can be bitwise still while the bone
|
||||
/// *spins*, and a standing idle is very nearly a pure rotation animation: differencing origins
|
||||
/// measures the one channel such a clip does not use. This is the series the eye reads.
|
||||
/// Each bone's matrix applied to a point out along each local axis ([`flesh_radii`]): a
|
||||
/// standing idle is nearly pure rotation, which bone origins alone miss.
|
||||
prev_flesh: Vec<Vec3>,
|
||||
prev2_flesh: Vec<Vec3>,
|
||||
/// This subject's per-bone flesh radii ([`flesh_radii`]) — bind data, so computed once.
|
||||
/// This subject's per-bone flesh radii ([`flesh_radii`]), computed once.
|
||||
radii: Vec<f32>,
|
||||
/// Last frame's **animation** clock: `(node, seek_time, weight)` per playing animation.
|
||||
///
|
||||
/// The blind spot this closes. [`ProbeClock`] is `Time<Real>` — the WALL clock — so every `dt`
|
||||
/// this meter has ever printed, and the whole `Δ/dt` judder discriminator built on it, divides
|
||||
/// by a clock the pose does not advance on. A pose stepping unevenly against a perfectly clean
|
||||
/// wall delta is invisible to every other column here, and that is exactly the shape a
|
||||
/// whole-skeleton twitch takes. `seek_time` is the clock the pose is actually sampled at.
|
||||
/// Last frame's `(node, seek_time, weight)` per playing animation: `seek_time` is the clock
|
||||
/// the pose samples at, where `dt` is the wall clock.
|
||||
prev_anim: Vec<(usize, f32, f32)>,
|
||||
/// The same two-frame window on the unit's root and on the camera, world space.
|
||||
/// The unit's root and the camera, world space.
|
||||
prev_root: Vec3,
|
||||
prev2_root: Vec3,
|
||||
prev_cam: Vec3,
|
||||
prev2_cam: Vec3,
|
||||
/// The consumer **anchors'** world translations (held items, helm, shoulders, cape — the
|
||||
/// entity lane 0974 left in absolute space), same two-frame window.
|
||||
/// The anchors' world translations (held items, helm, shoulders, cape), in absolute space.
|
||||
prev_anc: Vec<Vec3>,
|
||||
prev2_anc: Vec<Vec3>,
|
||||
/// The **rider** frames the vertex stage actually places an attached model with (1609): the
|
||||
/// row-0 translation, in the host's rig frame. The A/B against `prev_anc` above — same
|
||||
/// attachment, same frame, one measured in absolute world space and one in the rig's.
|
||||
/// The rider frames the vertex stage places an attached model with: row-0 translation in the
|
||||
/// host's rig frame.
|
||||
prev_rid: Vec<Vec3>,
|
||||
prev2_rid: Vec<Vec3>,
|
||||
/// **The rows the vertex stage actually blends** — the palette, probed at each bone's bind
|
||||
/// position, same two-frame window.
|
||||
///
|
||||
/// The blind spot this closes, and it is the one that let a wrong "fixed" call stand. Every
|
||||
/// other bone column here reads `RigPose::model`, the pose the compose pass produced — and
|
||||
/// the GPU never reads it. It skins from the palette, which `rig_anim::compose::rig_worlds`
|
||||
/// composes a SECOND time from the rig's locals through the root's own frame, folding in the
|
||||
/// `flags & 0x7` arm and the billboard replacement. Anything that goes wrong between those
|
||||
/// two compositions is invisible to `flesh`, so a clean `flesh` column has never been able to
|
||||
/// clear the body — it only ever cleared the pose.
|
||||
/// The palette rows the GPU skins from, probed at each bone's bind position. The palette is
|
||||
/// composed again by `rig_anim::compose::rig_worlds` (the `flags & 0x7` arm, billboards), so
|
||||
/// a clean `flesh` column does not clear it.
|
||||
prev_pal: Vec<Vec3>,
|
||||
prev2_pal: Vec<Vec3>,
|
||||
/// **The end of the chain: where each probe lands ON SCREEN, in pixels.** Every other column
|
||||
/// stops somewhere upstream — model space, rig space, world space — and each of those can be
|
||||
/// spotless while the thing the eye reads is not, because the camera's ORIENTATION, the
|
||||
/// projection and the viewport all sit between them and the glass. This series is the shader's
|
||||
/// own arithmetic (`view_rot * (p_rig + (origin - cam))`, then clip) carried to viewport
|
||||
/// pixels, so it is the only column that can be held against "I can see it move".
|
||||
/// Each palette probe on screen in pixels, by the shader's own arithmetic
|
||||
/// (`view_rot * (p_rig + (origin - cam))`, then clip and viewport).
|
||||
prev_scr: Vec<Vec2>,
|
||||
prev2_scr: Vec<Vec2>,
|
||||
/// The camera's ROTATION, same two-frame window. `prev_cam` above is its translation only —
|
||||
/// and a camera that never moves an inch can still swing the whole scene a fraction of a pixel
|
||||
/// by rolling a single ULP, which reads as "everything shimmers" and is invisible to a
|
||||
/// translation probe.
|
||||
/// The camera's rotation: one ULP of roll shifts the whole scene without any translation.
|
||||
prev_camrot: Quat,
|
||||
prev2_camrot: Quat,
|
||||
/// Each bone's BIND position (from the rig's inverse bindposes) — bind data, computed once.
|
||||
/// Each bone's bind position, from the rig's inverse bindposes; computed once.
|
||||
binds: Vec<Vec3>,
|
||||
/// Frames of history held (`< 2` means Δ² is not defined yet).
|
||||
depth: u32,
|
||||
@@ -128,8 +76,7 @@ pub(crate) struct JitterMeterPlugin;
|
||||
impl Plugin for JitterMeterPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let raw = std::env::var("WOW_JITTER").unwrap_or_default();
|
||||
// `<substr>`, `<substr>,<start>`, or a bare `<start>` — the subject is optional because
|
||||
// "the nearest rigged thing that isn't me" is the whole selection at a 1.6 yd close-up.
|
||||
// `<substr>`, `<substr>,<start>`, or a bare `<start>`.
|
||||
let (want, at) = match raw.rsplit_once(',') {
|
||||
Some((n, t)) => (n.trim(), t.trim().parse::<f32>().unwrap_or(0.0)),
|
||||
None => match raw.trim().parse::<f32>() {
|
||||
@@ -164,28 +111,18 @@ impl Plugin for JitterMeterPlugin {
|
||||
binds: Vec::new(),
|
||||
depth: 0,
|
||||
})
|
||||
// `Last`, so every writer that can still move the subject this frame — the pose evaluator,
|
||||
// the rig finalize, transform propagation, the camera seat — has already run. Sampling in
|
||||
// `Update` would measure last frame's world against this frame's pose (the 1398 seam).
|
||||
// `Last`: after the pose, rig finalize, transform propagation and camera seat have run.
|
||||
.add_systems(Last, sample_jitter);
|
||||
}
|
||||
}
|
||||
|
||||
/// A leaf bone's assumed flesh radius, model-space yards — a fingertip or an eyelid, not a limb.
|
||||
/// A leaf bone's assumed flesh radius, model-space yards.
|
||||
const LEAF_R: f32 = 0.05;
|
||||
/// The cap on a derived radius: no bone drives geometry further out than this on a humanoid.
|
||||
/// The cap on a derived radius, model-space yards.
|
||||
const MAX_R: f32 = 0.45;
|
||||
|
||||
/// **Per-bone flesh radius** — how far off its own axis this bone's geometry actually sits, taken
|
||||
/// as the distance to its farthest child (a bone's length is the best proxy we have here for the
|
||||
/// flesh it carries), leaves falling back to [`LEAF_R`]. The child's own local translation IS that
|
||||
/// distance; a standing clip animates rotation, so this is the bind offset in all but name.
|
||||
///
|
||||
/// This is load-bearing, not a detail. A single radius for all 117 bones reads a 25 mrad twitch of
|
||||
/// an *eyelid* bone as ~5 mm of motion, when the eyelid's own vertices sit ~0.02 yd out and move a
|
||||
/// tenth of that. Sized this way, the probe answers "how far did the geometry this bone drives
|
||||
/// move", which is the question, instead of "how far did a point 0.2 yd off it move", which
|
||||
/// flatters every small bone in the skeleton into looking like the worst thing on the model.
|
||||
/// Per-bone flesh radius: the distance to its farthest child, clamped to [`LEAF_R`]..[`MAX_R`].
|
||||
/// Sized per bone so a small bone's rotation is not read at a large bone's lever arm.
|
||||
fn flesh_radii(parents: &[i16], locals: &[Transform]) -> Vec<f32> {
|
||||
let mut r = vec![0.0f32; parents.len()];
|
||||
for (child, &p) in parents.iter().enumerate() {
|
||||
@@ -199,8 +136,7 @@ fn flesh_radii(parents: &[i16], locals: &[Transform]) -> Vec<f32> {
|
||||
r.iter().map(|&v| v.clamp(LEAF_R, MAX_R)).collect()
|
||||
}
|
||||
|
||||
/// The probe points for one composed rig: three per bone, one along each local axis, each at that
|
||||
/// bone's own [`flesh_radii`] distance.
|
||||
/// Three probe points per bone, one along each local axis at its [`flesh_radii`] distance.
|
||||
fn flesh_probes(model: &[bevy::math::Affine3A], radii: &[f32]) -> Vec<Vec3> {
|
||||
let mut out = Vec::with_capacity(model.len() * 3);
|
||||
for (i, m) in model.iter().enumerate() {
|
||||
@@ -212,7 +148,7 @@ fn flesh_probes(model: &[bevy::math::Affine3A], radii: &[f32]) -> Vec<Vec3> {
|
||||
out
|
||||
}
|
||||
|
||||
/// What the meter reads per candidate subject: identity, name key, world pose, composed rig.
|
||||
/// What the meter reads per candidate subject.
|
||||
type SubjectQuery = (
|
||||
Entity,
|
||||
&'static Guid,
|
||||
@@ -223,12 +159,8 @@ type SubjectQuery = (
|
||||
Has<SelfPlayer>,
|
||||
);
|
||||
|
||||
/// **The palette probe** — [`flesh_probes`]' twin on the rows the vertex stage blends. A row is
|
||||
/// `rig_from_joint × inverse_bindpose`, so it maps BIND space, not bone-local space: probing it at
|
||||
/// the bone's bind position plus [`flesh_radii`]'s offset is the same question `flesh_probes` asks
|
||||
/// of `RigPose::model`, put to the numbers that actually reach the GPU. Probing it at the row's own
|
||||
/// translation column instead would read a rotation as up to the model's full height of lever arm,
|
||||
/// ~10x what any real vertex sees.
|
||||
/// [`flesh_probes`] on the palette rows. A row is `rig_from_joint × inverse_bindpose`, mapping bind
|
||||
/// space, so it is probed at the bone's bind position plus the flesh offset.
|
||||
fn palette_probes(rows: &[Mat4], binds: &[Vec3], radii: &[f32]) -> Vec<Vec3> {
|
||||
let mut out = Vec::with_capacity(rows.len() * 3);
|
||||
for (i, m) in rows.iter().enumerate() {
|
||||
@@ -241,7 +173,7 @@ fn palette_probes(rows: &[Mat4], binds: &[Vec3], radii: &[f32]) -> Vec<Vec3> {
|
||||
out
|
||||
}
|
||||
|
||||
/// One `JIT` line per frame for the subject: the three terms' Δ and Δ², in mm and in pixels.
|
||||
/// One `JIT` line per frame for the subject.
|
||||
fn sample_jitter(
|
||||
mut meter: ResMut<JitterMeter>,
|
||||
time: ProbeClock,
|
||||
@@ -257,9 +189,7 @@ fn sample_jitter(
|
||||
palettes: Res<benilla_world::rig_palette::RigPalettes>,
|
||||
ibps: Res<Assets<SkinnedMeshInverseBindposes>>,
|
||||
players: Query<&AnimationPlayer>,
|
||||
// The clock the POSE advances on — `ProbeClock` above is `Time<Real>`, the wall clock, and the
|
||||
// gap between the two is the whole defect (`benilla_world::frame_pace`). Logging both side by
|
||||
// side is the A/B: paced, `vdt` holds the cadence while `dt` still reports what really happened.
|
||||
// The clock the pose advances on (`benilla_world::frame_pace`), logged beside the wall `dt`.
|
||||
vtime: Res<Time<bevy::time::Virtual>>,
|
||||
) {
|
||||
let now = time.elapsed_secs();
|
||||
@@ -270,12 +200,9 @@ fn sample_jitter(
|
||||
return;
|
||||
};
|
||||
let cam_pos = cam_g.translation();
|
||||
// Nearest match to the camera — with no substring, nearest rigged unit outright.
|
||||
// The match nearest the camera.
|
||||
let want = meter.want.clone();
|
||||
// `WOW_JITTER="self"` aims the meter at the AVATAR — the director's own repro (an undressed
|
||||
// character, watched from a close third person), and the one subject every other spelling of
|
||||
// this filter excluded. Without it the meter could only ever answer for somebody else's body,
|
||||
// which is a different entity, a different equipment lane, and a camera that is seated ON it.
|
||||
// `WOW_JITTER="self"` meters the avatar; every other filter excludes it.
|
||||
let on_self = want == "self";
|
||||
let Some((entity, guid, net, tf, rig, skin, _)) = subjects
|
||||
.iter()
|
||||
@@ -304,7 +231,7 @@ fn sample_jitter(
|
||||
_ => return,
|
||||
};
|
||||
let px_per_yd = (window.physical_height() as f32 * 0.5) / (dist * (fovy * 0.5).tan());
|
||||
// The rig composes in MODEL space; the root's scale is what carries it onto the screen.
|
||||
// The rig composes in model space; the root's scale carries it into the world.
|
||||
let scale = tf.scale().max_element();
|
||||
|
||||
let bones: Vec<Vec3> = rig
|
||||
@@ -316,14 +243,10 @@ fn sample_jitter(
|
||||
meter.radii = flesh_radii(&rig.parents, &rig.locals);
|
||||
}
|
||||
let flesh = flesh_probes(&rig.model, &meter.radii);
|
||||
// The rows the vertex stage blends, probed the same way — the body's OWN ground truth. Empty
|
||||
// when the subject holds no palette slot (bind-pose fallback), which reads as all-zero columns
|
||||
// rather than as a clean result.
|
||||
// The palette rows, probed the same way. Empty without a palette slot (bind-pose fallback),
|
||||
// which prints as all-zero columns, not a clean result.
|
||||
let mut bone_pos: Vec<Vec3> = Vec::new();
|
||||
// The same rows' ROTATION, as the three transformed unit axes. Bone translations can be
|
||||
// smooth while the frames they carry are not — a probe offset from the bone rides the
|
||||
// rotation, and that is what the flesh/vertex actually follows. Differencing a unit axis
|
||||
// measures the rotational motion at unit radius, directly comparable to the file's.
|
||||
// The same rows' rotation, as the three transformed unit axes.
|
||||
let mut bone_axes: Vec<[Vec3; 3]> = Vec::new();
|
||||
let pal: Vec<Vec3> = match skin.and_then(|s| {
|
||||
Some((
|
||||
@@ -340,9 +263,7 @@ fn sample_jitter(
|
||||
.collect();
|
||||
}
|
||||
let probes = palette_probes(&rows, &meter.binds, &meter.radii);
|
||||
// The bones' OWN rig-relative positions (the row applied to the bind point, no probe
|
||||
// offset) — the raw lane's series, and the one shape an offline compose of the same
|
||||
// M2 can be differenced against point-for-point.
|
||||
// Rig-relative bone positions (row applied to the bind point): the raw lane's series.
|
||||
bone_pos = rows
|
||||
.iter()
|
||||
.zip(&meter.binds)
|
||||
@@ -362,10 +283,8 @@ fn sample_jitter(
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
// **Project them.** The mirror of `wow_model.wgsl`'s vertex stage, operation for operation:
|
||||
// camera-relative first (`p_rig + (origin - cam)`, never the absolute world coordinate), then
|
||||
// the view rotation, then clip, then viewport. What comes out is where the eye finds that bit
|
||||
// of flesh, in pixels, this frame.
|
||||
// Project them as `wow_model.wgsl`'s vertex stage does: camera-relative first
|
||||
// (`p_rig + (origin - cam)`), then view rotation, clip and viewport.
|
||||
let vp = Vec2::new(
|
||||
window.physical_width() as f32,
|
||||
window.physical_height() as f32,
|
||||
@@ -390,15 +309,13 @@ fn sample_jitter(
|
||||
None => Vec::new(),
|
||||
};
|
||||
let camrot = cam_g.rotation();
|
||||
// The anchors are ordinary scene-graph entities in ABSOLUTE world space — the seam 0974
|
||||
// named and did not close. Their translations are read exactly as the renderer reads them.
|
||||
// Anchors are scene-graph entities in absolute world space, read as the renderer does.
|
||||
let ancs: Vec<Vec3> = rig
|
||||
.anchors
|
||||
.iter()
|
||||
.map(|&(_, e)| globals.get(e).map_or(Vec3::ZERO, |g| g.translation()))
|
||||
.collect();
|
||||
// The rider frames of every attached model hanging off THIS unit, in host-rig space. Their
|
||||
// origins are reported too — a rider is only exact while its origin holds still.
|
||||
// The rider frames of every model attached to this unit, in host-rig space, with origins.
|
||||
let rids: Vec<(Vec3, Vec3)> = riders
|
||||
.iter()
|
||||
.filter(|r| r.host == entity)
|
||||
@@ -406,7 +323,7 @@ fn sample_jitter(
|
||||
.collect();
|
||||
let dt = time.delta_secs().max(1.0e-6);
|
||||
|
||||
// A new subject (or a bone count that changed under us — a re-skin) restarts the series.
|
||||
// A new subject or a changed bone count (a re-skin) restarts the series.
|
||||
if meter.subject != Some(entity)
|
||||
|| meter.prev.len() != bones.len()
|
||||
|| meter.prev_flesh.len() != flesh.len()
|
||||
@@ -430,8 +347,7 @@ fn sample_jitter(
|
||||
net.display_id,
|
||||
bones.len(),
|
||||
);
|
||||
// Name the anchors once: bone id, and how much GEOMETRY hangs under each — the number
|
||||
// that says whether a jittering anchor is a held weapon or a bookkeeping node.
|
||||
// Name the anchors once: bone id and how many meshes hang under each.
|
||||
let mut roster = Vec::new();
|
||||
for (i, &(bone, e)) in rig.anchors.iter().enumerate() {
|
||||
let mut n = 0usize;
|
||||
@@ -508,7 +424,7 @@ fn sample_jitter(
|
||||
return;
|
||||
}
|
||||
|
||||
// Δ and Δ² per bone, in model space; the largest bone is the one the eye is reading.
|
||||
// Δ and Δ² per bone, in model space, at the worst bone.
|
||||
let (mut d2, mut worst) = (0.0f32, 0usize);
|
||||
for (i, &p) in bones.iter().enumerate() {
|
||||
let c = (p - 2.0 * meter.prev[i] + meter.prev2[i]).length();
|
||||
@@ -518,10 +434,9 @@ fn sample_jitter(
|
||||
}
|
||||
}
|
||||
let bd1 = (bones[worst] - meter.prev[worst]).length();
|
||||
// The same two differences on the FLESH probes — rotation included. This is the number that
|
||||
// has to be small for the model to read as still; `bd*` above is only its translation half.
|
||||
// The same on the flesh probes, rotation included.
|
||||
let (mut fd2, mut fworst) = (0.0f32, 0usize);
|
||||
// Per-BONE, max over its three probes — so the spread below counts bones, not probe points.
|
||||
// Per bone, max over its three probes, so the spread below counts bones.
|
||||
let mut per_bone = vec![0.0f32; flesh.len() / 3];
|
||||
for (i, &p) in flesh.iter().enumerate() {
|
||||
let c = (p - 2.0 * meter.prev_flesh[i] + meter.prev2_flesh[i]).length();
|
||||
@@ -533,17 +448,12 @@ fn sample_jitter(
|
||||
}
|
||||
}
|
||||
let fd1 = (flesh[fworst] - meter.prev_flesh[fworst]).length();
|
||||
// **Is it the model, or is it one bone?** The worst bone alone cannot tell those apart, and
|
||||
// that is the whole question a report of "the WHOLE model shakes" asks. `fd2med` is the
|
||||
// median bone's curvature and `fnbig` the count above a quarter-pixel: one twitching bone
|
||||
// leaves the median at zero and the count at 1, while a model-wide shimmer lifts both.
|
||||
// One bone or the whole model: `fd2med` is the median bone's Δ², `fnbig` the count past a
|
||||
// quarter pixel; a single twitching bone leaves the median at zero.
|
||||
let mut sorted = per_bone.clone();
|
||||
sorted.sort_by(f32::total_cmp);
|
||||
let fd2med = sorted.get(sorted.len() / 2).copied().unwrap_or(0.0);
|
||||
// The matching Δ median. `fd1`/`fd2` above are read at whichever bone is worst THIS frame, so
|
||||
// they are not one series and cannot be differenced against each other; these two are. Δ going
|
||||
// to zero on alternate frames is a hold-then-jump stair-step; Δ steady while Δ² alternates is
|
||||
// not, and the pair is what tells those apart.
|
||||
// The matching Δ median; unlike the worst-bone columns, the medians form one series.
|
||||
let mut d1s: Vec<f32> = (0..per_bone.len())
|
||||
.map(|b| {
|
||||
(0..3)
|
||||
@@ -555,8 +465,7 @@ fn sample_jitter(
|
||||
let fd1med = d1s.get(d1s.len() / 2).copied().unwrap_or(0.0);
|
||||
let big_px = 0.25 / (scale * px_per_yd).max(1.0e-6);
|
||||
let fnbig = per_bone.iter().filter(|&&c| c > big_px).count();
|
||||
// The same pair on the PALETTE probes. No `scale` here, unlike the flesh columns: these rows
|
||||
// are composed through the root's own frame, so the unit's scale is already in them.
|
||||
// The same on the palette probes, without `scale`: the rows already carry the root's frame.
|
||||
let (mut pd2, mut pworst) = (0.0f32, 0usize);
|
||||
let mut pal_bone = vec![0.0f32; pal.len() / 3];
|
||||
for (i, &p) in pal.iter().enumerate() {
|
||||
@@ -575,9 +484,8 @@ fn sample_jitter(
|
||||
.iter()
|
||||
.filter(|&&c| c > 0.25 / px_per_yd.max(1.0e-6))
|
||||
.count();
|
||||
// **In pixels, at the glass.** Per bone, the max over its three probes; then the MEDIAN bone
|
||||
// (the "whole model" number the report actually asks about), the worst bone, and the count
|
||||
// past a quarter pixel.
|
||||
// On screen, in pixels: per bone the max of its three probes, then the median bone, the worst
|
||||
// and the count past a quarter pixel.
|
||||
let mut scr_bone = vec![(0.0f32, 0.0f32); scr.len() / 3];
|
||||
for (i, &q) in scr.iter().enumerate() {
|
||||
let c = (q - 2.0 * meter.prev_scr[i] + meter.prev2_scr[i]).length();
|
||||
@@ -593,20 +501,15 @@ fn sample_jitter(
|
||||
let sd2med = s2.get(s2.len() / 2).copied().unwrap_or(0.0);
|
||||
let sd2max = s2.last().copied().unwrap_or(0.0);
|
||||
let snbig = s2.iter().filter(|&&c| c > 0.25).count();
|
||||
// Camera ROTATION, in microradians: Δ and Δ² of the angle between consecutive orientations.
|
||||
// Camera rotation in microradians: Δ and Δ² of the angle between consecutive orientations.
|
||||
let ang = |a: Quat, b: Quat| a.angle_between(b) * 1.0e6;
|
||||
let crd1 = ang(camrot, meter.prev_camrot);
|
||||
let crd2 = (ang(camrot, meter.prev_camrot) - ang(meter.prev_camrot, meter.prev2_camrot)).abs();
|
||||
// The flesh probe's **velocity**, mm/s — `Δ/dt`, not `Δ`. This is the judder discriminator,
|
||||
// and it is the one number the series cannot reconstruct from the others: if the pose is
|
||||
// smooth in time but sampled at uneven `dt`, then `Δ ≈ v·dt` is ragged exactly as `dt` is
|
||||
// while `v` itself is clean, so `Δ²` reads large (`≈ v·Δ(dt)`) purely from the timing. A pose
|
||||
// that is genuinely noisy has no such alibi: its velocity is ragged too. Differencing THIS
|
||||
// column is what tells the two apart, and differencing `Δ` alone never can.
|
||||
// The flesh velocity `Δ/dt`: clean when a smooth pose is sampled at uneven `dt`, ragged when
|
||||
// the pose itself is noisy.
|
||||
let fvel = fd1 * scale / dt;
|
||||
// Same two differences on the anchors, and the WORST AXIS of the worst one: the f32 grid an
|
||||
// absolute coordinate lands on is per-axis, so at ~9.5 k yards on one axis and ~60 on another
|
||||
// the staircase is one-dimensional — which is the fingerprint, not an aside.
|
||||
// The same on the anchors, plus the worst one's per-axis step: f32 precision of an absolute
|
||||
// coordinate is per axis, so a large coordinate steps on one axis only.
|
||||
let (mut ad2, mut aworst) = (0.0f32, 0usize);
|
||||
for (i, &p) in ancs.iter().enumerate() {
|
||||
let c = (p - 2.0 * meter.prev_anc[i] + meter.prev2_anc[i]).length();
|
||||
@@ -620,7 +523,7 @@ fn sample_jitter(
|
||||
} else {
|
||||
(ancs[aworst] - meter.prev_anc[aworst]).length()
|
||||
};
|
||||
// The same two differences on the RIDER rows — the number the fix has to move.
|
||||
// The same on the rider rows.
|
||||
let (mut rd2, mut rworst) = (0.0f32, 0usize);
|
||||
for (i, &(_, t)) in rids.iter().enumerate() {
|
||||
let c = (t - 2.0 * meter.prev_rid[i] + meter.prev2_rid[i]).length();
|
||||
@@ -639,8 +542,7 @@ fn sample_jitter(
|
||||
} else {
|
||||
(ancs[aworst] - meter.prev_anc[aworst]).abs()
|
||||
};
|
||||
// The animation clock, per playing node: how far the POSE's own time advanced this frame, and
|
||||
// whether the set of playing animations changed under us (a replay/stop_all snaps the pose).
|
||||
// The animation clock per playing node; a changed node set (replay, `stop_all`) snaps the pose.
|
||||
let anim: Vec<(usize, f32, f32)> = players
|
||||
.get(entity)
|
||||
.map(|p| {
|
||||
@@ -652,8 +554,7 @@ fn sample_jitter(
|
||||
v
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// Δseek on the node we also had last frame; `restart` marks a node set that changed (or a seek
|
||||
// that went backwards by more than a loop's worth of frame) — the pose-snap fingerprint.
|
||||
// The largest Δseek across nodes; NaN when the node set changed.
|
||||
let same = anim.len() == meter.prev_anim.len()
|
||||
&& anim.iter().zip(&meter.prev_anim).all(|(a, b)| a.0 == b.0);
|
||||
let dseek = if same {
|
||||
@@ -668,23 +569,12 @@ fn sample_jitter(
|
||||
let cam_d2 = (cam_pos - 2.0 * meter.prev_cam + meter.prev2_cam).length();
|
||||
let root_d1 = (root - meter.prev_root).length();
|
||||
let root_d2 = (root - 2.0 * meter.prev_root + meter.prev2_root).length();
|
||||
// Δ² over dt² is the velocity change the frame actually applied — the term that stays put
|
||||
// when a smooth pose is merely sampled at uneven times, and blows up when it is not smooth.
|
||||
// Δ²/dt²: the velocity change the frame applied.
|
||||
let dvel = d2 * scale / (dt * dt);
|
||||
let mm = |v: f32| v * scale * 1000.0;
|
||||
// **`WOW_JITTER_RAW=<bone>` — one bone's series, unaggregated.** Every other column here is a
|
||||
// median or a worst-of over ~350 probe points, and both of those hide the shape of a series:
|
||||
// a median cannot show a staircase (the *set* stays busy while each member holds), and a
|
||||
// worst-of is not one series at all — it re-picks its subject every frame, so differencing it
|
||||
// is meaningless. Twice now a reading taken off those columns pointed at the wrong mechanism.
|
||||
// This prints the chosen bone's palette-probe position (rig-relative yards, full f32) and its
|
||||
// projected screen position (pixels), so the series can be differenced honestly downstream.
|
||||
// **The two ABSOLUTE numbers the vertex stage still sums** (`wow_model.wgsl`:
|
||||
// `p_cam = frame_from_local·v + (frame_origin - view.world_position)`). Everything else on the
|
||||
// path is rig-sized and exact; these two are world coordinates, so at Elwynn's ~9.5 k yards
|
||||
// ONE of their axes has an ULP of 2^-10 yd = 0.9766 mm while the other two sit at ~7.6e-6.
|
||||
// That asymmetry is the whole fingerprint: the staircase is one-dimensional, so it is
|
||||
// invisible looking ALONG the big axis and full-amplitude looking across it.
|
||||
// `ORIGIN`: the two absolute terms the vertex stage sums (`wow_model.wgsl`:
|
||||
// `p_cam = frame_from_local·v + (frame_origin - view.world_position)`); at ~9.5 k yards one
|
||||
// axis has an f32 ULP of 2^-10 yd.
|
||||
if let Some(o) = rig_origin {
|
||||
println!(
|
||||
"ORIGIN\t{now:.6}\t{:.9}\t{:.9}\t{:.9}\t{:.9}\t{:.9}\t{:.9}",
|
||||
@@ -692,9 +582,7 @@ fn sample_jitter(
|
||||
);
|
||||
}
|
||||
if raw_bone() == Some(usize::MAX) {
|
||||
// `WOW_JITTER_RAW=all` — the whole skeleton, one line per bone per frame. The point is
|
||||
// localisation: differenced against an offline compose of the same M2 it says WHICH bones
|
||||
// carry motion the file does not, and a subtree answer names the pass that added it.
|
||||
// `WOW_JITTER_RAW=all`: one line per bone per frame, position and axes.
|
||||
for (b, p) in bone_pos.iter().enumerate() {
|
||||
let a = bone_axes.get(b).copied().unwrap_or_default();
|
||||
println!(
|
||||
@@ -716,6 +604,7 @@ fn sample_jitter(
|
||||
);
|
||||
}
|
||||
} else if let Some(b) = raw_bone() {
|
||||
// `WOW_JITTER_RAW=<bone>`: one bone's unaggregated series, rig position and screen pixel.
|
||||
if let (Some(p), Some(q)) = (bone_pos.get(b), scr.get(3 * b)) {
|
||||
println!(
|
||||
"RAW\t{now:.6}\t{b}\t{:.9}\t{:.9}\t{:.9}\t{:.6}\t{:.6}\t{:.6}",
|
||||
@@ -799,8 +688,7 @@ fn sample_jitter(
|
||||
meter.prev_anim = anim;
|
||||
}
|
||||
|
||||
/// `WOW_JITTER_RAW`'s bone — read once. Absent (or unparseable) leaves the raw lane silent, which
|
||||
/// is the default: it is a debugging firehose, one line per frame on top of the `JIT` line.
|
||||
/// `WOW_JITTER_RAW`'s bone (`all` is every bone), read once; unset leaves the raw lane silent.
|
||||
fn raw_bone() -> Option<usize> {
|
||||
static B: std::sync::OnceLock<Option<usize>> = std::sync::OnceLock::new();
|
||||
*B.get_or_init(|| {
|
||||
|
||||
@@ -1,46 +1,16 @@
|
||||
//! The **transport census** (`WOW_LIFT_CENSUS=<secs>[,<every>]`) — the instrument that turns
|
||||
//! *"the Thunder Bluff elevators aren't there"* into a line of numbers.
|
||||
//! `WOW_LIFT_CENSUS=<secs>[,<every>]`: per type-11 elevator and type-15 boat, whether it
|
||||
//! streamed, armed, built a model and where it is in its cycle. A transport spawns
|
||||
//! [`Visibility::Hidden`] and its first ticked pose unhides it, so one that never arms is present,
|
||||
//! solid and invisible.
|
||||
//!
|
||||
//! An absent lift is a screenshot, and a screenshot cannot separate the four ways a type-11
|
||||
//! elevator (or a type-15 boat) can fail to appear — each of which calls for a different fix:
|
||||
//! - `state`: `lift`/`taxi` (armed), `seed` (a type-11 waiting for its keyframe catalog), `bare`
|
||||
//! (anchored, no drive), `parked` (no anchor).
|
||||
//! - `vis`/`inh`: the root's [`Visibility`] and the propagated [`InheritedVisibility`]; `hidden=`
|
||||
//! counts transports the tick never showed or judged off the live [`CurrentMap`] (`map=`).
|
||||
//! - `meshes`: render descendants of the whole subtree.
|
||||
//!
|
||||
//! - the server never streamed it (**no entity** — nothing on the wire, our count is 0);
|
||||
//! - it streamed but never **armed** (`state=seed`/`bare`): a transport spawns
|
||||
//! [`Visibility::Hidden`] and is unhidden by its first ticked pose (decision 0438), so an arm
|
||||
//! that never completes leaves a car that is present, solid, and permanently invisible —
|
||||
//! B168's "an invisible wall in its place";
|
||||
//! - it armed and ticks, but the model never built (`meshes=0`) — an asset gap;
|
||||
//! - it armed, ticks and draws, and the car is simply somewhere else in its cycle
|
||||
//! (`cycle=`/`pos=` say where, against the authored keyframes).
|
||||
//!
|
||||
//! ```text
|
||||
//! LIFT 0xF11000000000504B entry=4170 disp=360 type=11 state=lift period=30033 cycle=17421
|
||||
//! moving=0 vis=Inherited inh=1 meshes=4 attached=1 pos=(-1286.24,189.72,130.08) d=9.4
|
||||
//! ```
|
||||
//!
|
||||
//! - **`state`** is the arm verdict read off the components themselves: `lift`/`taxi` (armed),
|
||||
//! `seed` (a type-11 waiting for its keyframe catalog), `bare` (an anchored transport with no
|
||||
//! drive — the boat arm waiting on a template, or a type-11 whose create carried no entry),
|
||||
//! `parked` (no anchor: a pathless type-11 the arm deliberately released, or a type the wire
|
||||
//! never flagged).
|
||||
//! - **`vis`/`inh`** are the root's own [`Visibility`] and the propagated
|
||||
//! [`InheritedVisibility`]. `vis=Hidden` on a transport is the finding: either the tick never ran
|
||||
//! for it, or it ran and judged the car off-map. The headline `hidden=` count is that number, and
|
||||
//! `map=` beside it is the live [`CurrentMap`] the tick judged against — the pair that named
|
||||
//! decision 1654 (`hidden=11 … map=1`, every lift on Kalimdor, all of them armed and ticking).
|
||||
//! - **`meshes`** counts render descendants (the whole subtree, not just direct children — a GO
|
||||
//! model hangs its submeshes under an anim host).
|
||||
//!
|
||||
//! Pair it with the checkout's probe identity (the `probe` skill) and a `.go` to the reported spot:
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe<n> WOW_NOSOUND=1 \
|
||||
//! WOW_PROBE_CHAT=".go xyz -1286.2 189.7 132.0 1" \
|
||||
//! WOW_LIFT_CENSUS="30,15" WOW_PROBE_EXIT_AT=75 cargo run -q -p benilla
|
||||
//! ```
|
||||
//!
|
||||
//! The repeat form is the load-race half: a census at 30 s and again at 45 s says whether a
|
||||
//! `state=seed` is a transient (the catalog had not opened yet) or the verdict.
|
||||
//! The repeat form tells a transient `state=seed` from a stuck one. How to run it:
|
||||
//! `docs/CONTRIBUTING.md`, "Running it unattended".
|
||||
|
||||
use benilla_assets::coords::bevy_to_wow;
|
||||
use benilla_protocol::EntityKind;
|
||||
@@ -53,8 +23,7 @@ use crate::net::{Guid, NetEntity, ObjectStore, SelfPlayer};
|
||||
use crate::transport::{ElevatorSeed, Transport, TransportAnchor};
|
||||
use benilla_world::world_map::CurrentMap;
|
||||
|
||||
/// How deep the render-descendant walk goes. A GO's submeshes sit at most a couple of levels
|
||||
/// under the net root (root → anim host → submesh); eight is slack, not a limit anything reaches.
|
||||
/// Depth of the render-descendant walk; a GO's submeshes sit two levels down (root, anim host).
|
||||
const WALK_DEPTH: u32 = 8;
|
||||
|
||||
pub(crate) struct LiftCensusPlugin;
|
||||
@@ -72,15 +41,14 @@ impl Plugin for LiftCensusPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`LiftCensusPlugin`] state: when the next census fires, and how often after that (`0` = once).
|
||||
/// [`LiftCensusPlugin`] state; `every` of 0 fires once.
|
||||
#[derive(Resource)]
|
||||
struct LiftCensus {
|
||||
next: f32,
|
||||
every: f32,
|
||||
}
|
||||
|
||||
/// What the census reads per entity: identity, kind/display, pose, the three transport
|
||||
/// components (each one a distinct arm stage), the visibility pair, and the visual gate.
|
||||
/// What the census reads per entity; each transport component is a distinct arm stage.
|
||||
type CensusQuery = (
|
||||
&'static Guid,
|
||||
&'static NetEntity,
|
||||
@@ -95,11 +63,8 @@ type CensusQuery = (
|
||||
Has<VisualAttached>,
|
||||
);
|
||||
|
||||
/// One line per streamed transport GameObject — hidden ones first, then by distance — under a
|
||||
/// summary line naming the counts that matter. No radius: vmangos sends a map's **whole** transport
|
||||
/// set at world entry and keeps it resident (`Map::SendInitTransports`, `Map.cpp:1719`; every
|
||||
/// type-11 joins `m_transports` at `Map::Add`) — decision 0678 measured the same thing from a
|
||||
/// ship's lanterns at 4853 yd. So the population is a handful and all of it is in reach.
|
||||
/// One line per streamed transport, hidden first, then by distance. No radius: vmangos sends a
|
||||
/// map's whole transport set at world entry (`Map::SendInitTransports`, `Map.cpp:1718`).
|
||||
fn fire_lift_census(
|
||||
mut probe: ResMut<LiftCensus>,
|
||||
time: ProbeClock,
|
||||
@@ -125,9 +90,8 @@ fn fire_lift_census(
|
||||
for (guid, net, t, store, transport, anchor, seed, vis, inherited, kids, attached) in &entities
|
||||
{
|
||||
let go_type = store.0.gameobject_type_id();
|
||||
// Every ticking GO type (the pair the reference's per-frame tick `0x630970` fires) plus
|
||||
// anything already wearing a transport component — so a type field we misread still shows
|
||||
// up rather than vanishing from the instrument that exists to find it.
|
||||
// The GO types the reference's per-frame tick `0x630970` fires, plus anything carrying a
|
||||
// transport component, so a misread type field still lists.
|
||||
if !(net.kind == EntityKind::GameObject && matches!(go_type, 11 | 15)
|
||||
|| transport.is_some()
|
||||
|| anchor.is_some()
|
||||
@@ -199,13 +163,12 @@ fn fire_lift_census(
|
||||
}
|
||||
}
|
||||
|
||||
/// A borrowed child slice, or an empty one — the `Option<&Children>` unwrap the walk starts from.
|
||||
/// The children to start the walk from, or none.
|
||||
fn entity_children(kids: Option<&Children>) -> Vec<Entity> {
|
||||
kids.map(|c| c.iter().collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Count the render descendants of a subtree — the whole subtree, because a GameObject's
|
||||
/// submeshes hang under an anim host rather than directly off the net root.
|
||||
/// Counts render descendants of the whole subtree; a GO's submeshes hang under an anim host.
|
||||
fn render_descendants(
|
||||
roots: Vec<Entity>,
|
||||
children: &Query<&Children>,
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
//! The live frame-time sample ([`LiveFpsPlugin`]) and everything printed from the same sample
|
||||
//! window: the `FPS_PROBE` line itself, the visible-submesh census
|
||||
//! (`VIS_CENSUS`/`VIS_ESCAPED`/`VIS_DUMP`), the asset-churn ratchet (`MAT_CHURN`) and the
|
||||
//! residency meter (`ASSET_DUMP`). One file because one system, `drive_live_fps`, prints all four
|
||||
//! from the same frame at the same instant — they are one measurement with four line families,
|
||||
//! not four instruments.
|
||||
//!
|
||||
//! **What is counted lives in [`benilla_world::world_census`]; what is printed lives here.** The census
|
||||
//! is the engine's own published account of the frame it drew (decision 1164) — this probe adds
|
||||
//! the timing window around it and owns the line shapes, which are a greppable contract of ours
|
||||
//! and no business of the renderer's.
|
||||
//! The live frame-time sample ([`LiveFpsPlugin`]) and the lines printed from the same window:
|
||||
//! `FPS_PROBE`, `VIS_CENSUS`/`VIS_ESCAPED`/`VIS_DUMP`, `MAT_CHURN` and `ASSET_DUMP`. The counting
|
||||
//! is [`benilla_world::world_census`]'s; the timing window and the line shapes are here.
|
||||
|
||||
use bevy::ecs::system::SystemParam;
|
||||
use bevy::prelude::*;
|
||||
@@ -16,22 +8,13 @@ use bevy::prelude::*;
|
||||
use crate::capture::PROBE_WARMUP_FRAMES;
|
||||
use benilla_world::world_census::WorldCensus;
|
||||
|
||||
/// The LIVE FPS probe (`WOW_LIVE_FPS=<frames>`, delay via `WOW_LIVE_FPS_AT` seconds, default 25;
|
||||
/// `WOW_LIVE_FPS_MOVE=1` holds W through warmup + sampling, so the probe measures RUNNING through
|
||||
/// the scene — streaming, spawns, re-classification — not a parked camera; the 0366 hunt's
|
||||
/// "running around SW" gap):
|
||||
/// the [`crate::capture::CapturePlugin`] probe's numbers on a NORMAL connected run — streamed units, net
|
||||
/// apply, quest markers, everything the server-less harness deliberately excludes. Built for the
|
||||
/// 0362 residual: the serverless stormwind probe pinned 60 while the director's live session read
|
||||
/// 20, so the gap IS the live world — this instrument measures it. Waits for in-world + the delay
|
||||
/// (park the character first with [`super::ProbeChatPlugin`]), uncaps vsync, warms
|
||||
/// [`PROBE_WARMUP_FRAMES`], samples, prints the same machine-greppable `FPS_PROBE` line
|
||||
/// (scenario=`live`), and exits.
|
||||
/// `WOW_LIVE_FPS=<frames>` (default 300): the [`crate::capture::CapturePlugin`] probe's numbers on
|
||||
/// a connected run. Waits for the world and `WOW_LIVE_FPS_AT` seconds (default 25), uncaps vsync,
|
||||
/// warms [`PROBE_WARMUP_FRAMES`], samples, prints `FPS_PROBE scenario=live` and exits.
|
||||
/// `WOW_LIVE_FPS_MOVE=1` holds W throughout, measuring a run through the scene.
|
||||
pub(crate) struct LiveFpsPlugin;
|
||||
|
||||
/// How long past `WOW_LIVE_FPS_AT` the probe waits for a world before declaring the run dead.
|
||||
/// Entry normally lands well inside `at` itself; 60 s of grace covers a cold-cache load without
|
||||
/// ever letting a stranded run ride out a harness timeout (see the `Waiting` arm).
|
||||
/// Seconds past `WOW_LIVE_FPS_AT` without a world before the run exits as dead; covers a cold load.
|
||||
const BOOT_DEADLINE_SECS: f32 = 60.0;
|
||||
|
||||
impl Plugin for LiveFpsPlugin {
|
||||
@@ -67,8 +50,7 @@ impl Plugin for LiveFpsPlugin {
|
||||
})
|
||||
.add_systems(Update, drive_live_fps);
|
||||
WorldCensus::churn_counters(app);
|
||||
// The engine counts its own materials; the UI pass is ours, so we fold it into the same
|
||||
// tally rather than keeping a second one that could disagree about the window.
|
||||
// The UI pass's material joins the engine's churn tally.
|
||||
WorldCensus::count_churn::<crate::ui_pass::UiQuadMaterial>(app, "uiquad");
|
||||
}
|
||||
}
|
||||
@@ -81,7 +63,6 @@ enum LiveFpsPhase {
|
||||
Done,
|
||||
}
|
||||
|
||||
/// [`LiveFpsPlugin`] state.
|
||||
/// The still-frame input counters (`benilla_world::dev_state::STILL_INPUTS_CHANGED`), now.
|
||||
fn still_inputs_now() -> [u32; 7] {
|
||||
let c = &benilla_world::dev_state::STILL_INPUTS_CHANGED;
|
||||
@@ -92,84 +73,56 @@ fn still_inputs_now() -> [u32; 7] {
|
||||
struct LiveFps {
|
||||
frames: usize,
|
||||
at: f32,
|
||||
/// Hold W while measuring (`WOW_LIVE_FPS_MOVE=1`) — the moving-workload probe.
|
||||
/// Hold W while measuring (`WOW_LIVE_FPS_MOVE=1`).
|
||||
run: bool,
|
||||
phase: LiveFpsPhase,
|
||||
samples: Vec<f32>,
|
||||
/// Process CPU per frame, ms, index-parallel with `samples` — the TAIL half of `cpu_ms`:
|
||||
/// a mean under budget hides the one frame in twenty that is over it, and whether that
|
||||
/// frame was CPU work or a wait is the first question about a stutter.
|
||||
/// Process CPU per frame, ms, index-parallel with `samples`: says whether a slow frame was
|
||||
/// work or a wait.
|
||||
cpu_samples: Vec<f32>,
|
||||
cpu_prev: Option<f64>,
|
||||
/// Per frame, beside `cpu_samples`: entities added net of removed, mesh assets added,
|
||||
/// model materials added — the three spike signatures a crowd produces (a spell kit
|
||||
/// spawning, a body dressing, a first-seen material realizing). Printed for the worst
|
||||
/// frames only.
|
||||
/// Per frame: net entities added, meshes added, model materials added, sound kit decodes.
|
||||
/// Printed for the worst frames only.
|
||||
churn_samples: Vec<(i32, u32, u32, u32)>,
|
||||
entities_prev: usize,
|
||||
decodes_prev: u32,
|
||||
/// Live particles and the GPU meter's freshest reading per frame — the two "same work,
|
||||
/// more of it" signatures the churn triple cannot carry.
|
||||
/// Live particles and the GPU meter's freshest reading per frame.
|
||||
load_samples: Vec<(u32, f32)>,
|
||||
/// The PACED clock's delta per frame (`Time<Virtual>`, `frame_pace`): under vsync with
|
||||
/// pipelined rendering the raw delta is mis-split into long/short pairs that sum to exact
|
||||
/// refresh multiples, so `samples`' tail counts presentation jitter as frames. The pacer
|
||||
/// snaps a cadenced delta to whole refreshes; a paced delta of two periods is a frame the
|
||||
/// display actually skipped.
|
||||
/// The paced delta per frame (`Time<Virtual>`, `frame_pace`). Under vsync with pipelined
|
||||
/// rendering the raw delta splits into long/short pairs; a paced delta of two refresh periods
|
||||
/// is a frame the display skipped.
|
||||
paced_samples: Vec<f32>,
|
||||
/// Per-thread CPU at the window's first frame (`perf::thread_cpu_table`), keyed by pthread
|
||||
/// identity — subtracted at the window's end for `thr=[name:ms/frame,…]`.
|
||||
/// Per-thread CPU at the window's first frame (`perf::thread_cpu_table`), keyed by pthread,
|
||||
/// the baseline for `thr=[name:ms/frame,…]`.
|
||||
threads_at_start: Option<Vec<(usize, String, f64, f64)>>,
|
||||
/// The page-fault counters at the window's first frame (`perf::process_faults`).
|
||||
faults_at_start: Option<(u64, u64)>,
|
||||
/// `dev_state::STILL_INPUTS_CHANGED` at the window's first frame — `noisy=` is the delta.
|
||||
/// `dev_state::STILL_INPUTS_CHANGED` at the window's first frame; `noisy=` is the delta.
|
||||
still_at_start: [u32; 7],
|
||||
/// Process CPU seconds at the first sampled frame ([`crate::perf::process_cpu_secs`]) — the
|
||||
/// baseline for the window's `cpu_ms`/`cpu_pct`.
|
||||
/// Process CPU seconds at the first sampled frame ([`crate::perf::process_cpu_secs`]), the
|
||||
/// baseline for `cpu_ms`/`cpu_pct`.
|
||||
cpu_at_start: Option<f64>,
|
||||
/// Machine-wide CPU ticks at the first sampled frame ([`crate::perf::system_cpu_ticks`]) — the
|
||||
/// baseline for `sys_busy_pct`, which says whether anyone ELSE was competing for the cores
|
||||
/// while this leg ran. `cpu_ms` alone cannot: see that function's header for the 25.93-vs-18.8
|
||||
/// leg that motivated it (1157).
|
||||
/// Machine-wide CPU ticks at the first sampled frame ([`crate::perf::system_cpu_ticks`]), the
|
||||
/// baseline for `sys_busy_pct`: whether other processes competed for the cores.
|
||||
sys_at_start: Option<(u64, u64)>,
|
||||
/// The window's occlusion state, maintained from `WindowOccluded` transitions. macOS
|
||||
/// throttles a FULLY covered window to ~1 fps (any covering window, not just the lock
|
||||
/// screen — the director's correction to 0729/0730's lock-screen reading): a probe launched
|
||||
/// detached spawns unfocused and can land completely behind other windows, and its leg then
|
||||
/// measures the throttle, not the client.
|
||||
/// Occlusion, from `WindowOccluded`: macOS throttles a fully covered window to ~1 fps.
|
||||
occluded_now: bool,
|
||||
/// Sampled frames taken while occluded — `occluded_frames=` on the probe line, so a
|
||||
/// throttled leg names itself instead of being inferred from a ~1 s hitch signature.
|
||||
/// Sampled frames taken while occluded (`occluded_frames=`).
|
||||
occluded_frames: usize,
|
||||
}
|
||||
|
||||
/// **Where** the sample was taken — the 0705 prove-the-run law: a probe number is evidence only
|
||||
/// once the body is known to be at the pin, and `WOW_PROBE_CHAT`'s `.go` can silently fail (a bad
|
||||
/// map id, a refused command) leaving the run measuring the login spot.
|
||||
///
|
||||
/// The *scene-state* half of the pin — which room the camera claims, how many exterior windows,
|
||||
/// what the cull did — is the engine's, and arrives with the rest of the census. Without it a
|
||||
/// `drawn=` reading taken indoors cannot be read at all: a big number means either "we claimed a
|
||||
/// room and the cull let everything through" or "we never claimed a room, so nothing was gated" —
|
||||
/// opposite bugs with identical numbers, and the difference cost a measurement (0780).
|
||||
/// The display stamp's monitor roster + the `WOW_GPU_MS=1` meter + its sample sink, bundled
|
||||
/// (the 16-SystemParam ceiling, the house's SpawnTables shape).
|
||||
/// The rest of [`drive_live_fps`]'s params, bundled under the 16-SystemParam ceiling.
|
||||
type ScreenParams<'w, 's> = (
|
||||
Query<'w, 's, &'static bevy::window::Monitor>,
|
||||
Option<Res<'w, crate::perf::GpuMsShared>>,
|
||||
Local<'s, Vec<f32>>,
|
||||
Option<Res<'w, crate::perf::WgpuCensusShared>>,
|
||||
// Every camera, for `views=`: the number of ACTIVE views the render app walked this frame —
|
||||
// each is its own pass set (a portrait booth awake, a body pane open), and a lane that
|
||||
// scales with views (cluster assignment, the view bind groups, every per-pass tracker) is
|
||||
// not a scene cost until this number is known.
|
||||
// Every camera, for `views=`: active views, each its own pass set.
|
||||
Query<'w, 's, &'static Camera>,
|
||||
// The UI's live texture-identity runs (`ui_batches=`): one `Mesh2d` draw each in the 2D pass.
|
||||
Query<'w, 's, (), With<crate::ui_pass::UiQuadBatch>>,
|
||||
// Every view-visible `Mesh3d` by lane (`vis_terrain/model/liquid/other=`): the 3D passes'
|
||||
// draw population. `drawn=` counts model submeshes only; the terrain is one entity per
|
||||
// ADT chunk and a city horizon is over a thousand of them — a per-draw cost is not
|
||||
// attributable until this split is known.
|
||||
// Every view-visible `Mesh3d` by lane (`vis_terrain/model/liquid/other=`); `drawn=` counts
|
||||
// model submeshes only, and terrain is one entity per ADT chunk.
|
||||
Query<
|
||||
'w,
|
||||
's,
|
||||
@@ -186,8 +139,8 @@ type ScreenParams<'w, 's> = (
|
||||
MessageReader<'w, 's, AssetEvent<benilla_assets::materials::WowModelMaterial>>,
|
||||
Res<'w, Time<bevy::time::Virtual>>,
|
||||
ResMut<'w, crate::perf::MainThreadSplit>,
|
||||
// The additive blend-state check (`perf::blend_check`): draws whose bound blend state
|
||||
// contradicted their material this frame — `blend_mismatch=` on the line.
|
||||
// `blend_mismatch=` (`perf::blend_check`): draws whose bound blend state contradicts their
|
||||
// material.
|
||||
Option<Res<'w, crate::perf::BlendMismatchShared>>,
|
||||
);
|
||||
|
||||
@@ -195,40 +148,31 @@ type ScreenParams<'w, 's> = (
|
||||
struct SamplePin<'w, 's> {
|
||||
map: Option<Res<'w, benilla_world::world_map::CurrentMap>>,
|
||||
body: Option<Res<'w, crate::player::Player>>,
|
||||
/// The eye the leg was measured through — pose-stamped like `sys_busy_pct` time-stamps load,
|
||||
/// and for the same reason: two legs are only comparable if this matches. 1475's bring-up
|
||||
/// lost an hour to a probe whose camera sat first-person pitched 26° down — a per-character
|
||||
/// saved camera file, invisible in every count — and the census read as a regression hunt.
|
||||
/// The camera the leg was measured through: two legs compare only when it matches.
|
||||
cam: Query<'w, 's, &'static GlobalTransform, With<benilla_world::view::WorldCamera>>,
|
||||
}
|
||||
|
||||
/// Wait for in-world + the delay, uncap, warm, sample, print, exit — the live twin of the
|
||||
/// harness probe's `Phase::ProbeWarmup`/`Probing` arms.
|
||||
/// Waits for the world and the delay, uncaps, warms, samples, prints and exits: the live form of
|
||||
/// the harness probe's `Phase::ProbeWarmup`/`Probing` arms.
|
||||
fn drive_live_fps(
|
||||
mut probe: ResMut<LiveFps>,
|
||||
time: Res<Time<bevy::time::Real>>,
|
||||
self_player: Query<(), With<crate::net::SelfPlayer>>,
|
||||
mut windows: Query<&mut Window, With<bevy::window::PrimaryWindow>>,
|
||||
// What the engine drew this frame and what it is holding — submeshes, the exterior-scene
|
||||
// gate, emitters, resident assets, the churn window. One param, one instant.
|
||||
// What the engine drew this frame and what it holds.
|
||||
mut census: WorldCensus,
|
||||
streamed: Query<(), With<crate::net::NetEntity>>,
|
||||
// The animation-LOD gate's effect, machine-readable per probe (decision 0448): how many
|
||||
// streamed rigs sat parked at sample end.
|
||||
// Rigs the animation-LOD gate parked, at sample end.
|
||||
parked: Query<(), With<benilla_world::rig_anim::AnimParked>>,
|
||||
entities: Query<()>,
|
||||
pin: SamplePin,
|
||||
// The owned skin-palette occupancy (decision 0720) — `rigs=live/peak bones=live/peak` on the
|
||||
// probe line proves the palette lane is actually populated (an all-zero table renders
|
||||
// origin-collapsed rigs, which no other probe number would catch).
|
||||
// Skin-palette occupancy (`rigs=live/peak`); an empty table renders origin-collapsed rigs.
|
||||
palettes: Option<Res<benilla_world::rig_palette::RigPalettes>>,
|
||||
mut keys: ResMut<ButtonInput<KeyCode>>,
|
||||
mut key_events: MessageWriter<bevy::input::keyboard::KeyboardInput>,
|
||||
mut exit: MessageWriter<AppExit>,
|
||||
mut occlusions: MessageReader<bevy::window::WindowOccluded>,
|
||||
// Bundled (the 16-SystemParam ceiling): the monitor roster for the display stamp, and the
|
||||
// `WOW_GPU_MS=1` meter — the render app's whole-frame GPU clock, sampled per probe frame so
|
||||
// the leg line carries gpu percentiles beside the cpu ones (absent when the meter is off).
|
||||
// Includes the `WOW_GPU_MS=1` meter, the render app's whole-frame GPU clock.
|
||||
mut screen: ScreenParams,
|
||||
) {
|
||||
let (monitors, gpu, gpu_samples, wgpu_census, cameras, ui_batches, vis_meshes) = (
|
||||
@@ -247,8 +191,7 @@ fn drive_live_fps(
|
||||
.11
|
||||
.as_ref()
|
||||
.map_or(0, |m| m.0.load(std::sync::atomic::Ordering::Relaxed));
|
||||
// Read every frame (a reader that only reads inside the window would report the whole
|
||||
// backlog on its first sampled frame).
|
||||
// Read every frame, or the first sampled frame would report the whole backlog.
|
||||
let mesh_added = mesh_events
|
||||
.read()
|
||||
.filter(|e| matches!(e, AssetEvent::Added { .. }))
|
||||
@@ -257,22 +200,16 @@ fn drive_live_fps(
|
||||
.read()
|
||||
.filter(|e| matches!(e, AssetEvent::Added { .. }))
|
||||
.count() as u32;
|
||||
// Drain every frame so the state is current whichever phase we're in — the window can be
|
||||
// occluded before sampling ever starts (a detached launch spawns behind whatever is open).
|
||||
// Drained every frame: the window can be occluded before sampling starts.
|
||||
for o in occlusions.read() {
|
||||
probe.occluded_now = o.occluded;
|
||||
}
|
||||
match probe.phase {
|
||||
LiveFpsPhase::Done => {}
|
||||
LiveFpsPhase::Waiting => {
|
||||
// (The un-occludable window that keeps the SETTLE phase from streaming the world at
|
||||
// ~1 fps is [`ProbeFocusPlugin`]'s now — every probe needs it, not just this one.)
|
||||
if time.elapsed_secs() < probe.at || self_player.is_empty() {
|
||||
// The boot deadline: entering the world takes seconds, and `at` already grants a
|
||||
// settle window on top — a run still worldless this far past it is stranded on a
|
||||
// glue screen (dead server, refused login the marker arms didn't catch) and every
|
||||
// second more is the 1371 sitting's dead wall-clock again. `FATAL` is the marker
|
||||
// a leg runner keys on.
|
||||
// Still worldless past the deadline: stranded on a glue screen. A leg runner
|
||||
// keys on `FATAL`.
|
||||
if time.elapsed_secs() > probe.at + BOOT_DEADLINE_SECS {
|
||||
error!(
|
||||
"live-fps: FATAL — still not in world {:.0}s past the probe delay; a measurement run with no world is dead. exiting",
|
||||
@@ -291,10 +228,8 @@ fn drive_live_fps(
|
||||
if probe.run { ", holding W" } else { "" }
|
||||
);
|
||||
if probe.run {
|
||||
// A synthetic held key: `ButtonInput` persists a press until its release, and the
|
||||
// winit feed only releases keys it saw go down, so this holds across frames. The
|
||||
// raw KeyboardInput message rides along for the binding dispatch's press edge
|
||||
// (0997 — MOVEFORWARD latches off the event, holds off the state).
|
||||
// Held until released: winit only releases keys it saw go down. The raw message
|
||||
// gives the binding dispatch its press edge (MOVEFORWARD latches off the event).
|
||||
keys.press(KeyCode::KeyW);
|
||||
key_events.write(bevy::input::keyboard::KeyboardInput {
|
||||
key_code: KeyCode::KeyW,
|
||||
@@ -324,8 +259,7 @@ fn drive_live_fps(
|
||||
probe.still_at_start = still_inputs_now();
|
||||
main_split.restart();
|
||||
probe.sys_at_start = crate::perf::system_cpu_ticks();
|
||||
// The churn census restarts with the window — warmup noise (streaming, shader
|
||||
// warms) would otherwise read as steady-state ratchets.
|
||||
// Restart churn with the window so warmup does not read as a ratchet.
|
||||
census.restart_churn();
|
||||
probe.occluded_frames = 0;
|
||||
}
|
||||
@@ -378,19 +312,13 @@ fn drive_live_fps(
|
||||
.single()
|
||||
.map(|w| (w.physical_width(), w.physical_height()))
|
||||
.unwrap_or((0, 0));
|
||||
// The present mode actually measured under — an uncap that silently rails (0362) is
|
||||
// only diagnosable if the line says what was asked for.
|
||||
// The present mode measured under.
|
||||
let present = windows
|
||||
.single()
|
||||
.map(|w| format!(" present={:?}", w.present_mode))
|
||||
.unwrap_or_default();
|
||||
// Which display the window sits on, and that display's refresh rate — the regime a
|
||||
// railed leg is railed AT. Two legs of one sitting have read 126.5 vs exactly 60.0 fps
|
||||
// under the same uncapped present mode (1388 round 3, 1395's pin), and nothing on the
|
||||
// line said why: this machine drives a 60 Hz external beside a 120 Hz built-in, and
|
||||
// present=AutoNoVsync resolves to Metal displaySync=false either way — the rail, when
|
||||
// one appears, is the WindowServer's, keyed to the display. Stamp it so a regime split
|
||||
// reads off the line instead of costing a discarded round.
|
||||
// The display under the window's centre and its refresh rate: on macOS an uncapped
|
||||
// leg can still rail at the WindowServer's per-display rate.
|
||||
let display = {
|
||||
let center = windows.single().ok().and_then(|w| match w.position {
|
||||
bevy::window::WindowPosition::At(p) => {
|
||||
@@ -423,15 +351,14 @@ fn drive_live_fps(
|
||||
None => " display=unpositioned".to_string(),
|
||||
}
|
||||
};
|
||||
// The GPU meter's percentiles over the same window (WOW_GPU_MS=1; empty otherwise).
|
||||
// GPU percentiles over the same window (`WOW_GPU_MS=1`; empty otherwise).
|
||||
let gpu_line = if gpu_samples.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let mut g = std::mem::take(&mut **gpu_samples);
|
||||
g.sort_by(f32::total_cmp);
|
||||
let gat = |q: f32| g[(((g.len() - 1) as f32) * q).round() as usize];
|
||||
// …and the wgpu resource census beside it (`perf::gpu::WgpuCensus`): the
|
||||
// residency term of the pass-encode CPU, sampled at the window's end.
|
||||
// The wgpu resource census (`perf::gpu::WgpuCensus`) at the window's end.
|
||||
let census = wgpu_census
|
||||
.as_ref()
|
||||
.map(|c| {
|
||||
@@ -461,16 +388,13 @@ fn drive_live_fps(
|
||||
g[g.len() - 1]
|
||||
)
|
||||
};
|
||||
// CPU cost per frame across every thread — the load-robust half of the measurement
|
||||
// (`perf::process_cpu_secs`), and directly comparable with a reporter's CPU %.
|
||||
// Process CPU per frame across every thread (`perf::process_cpu_secs`).
|
||||
let v_len = v.len();
|
||||
let cpu = match (probe.cpu_at_start, crate::perf::process_cpu_secs()) {
|
||||
(Some(t0), Some(t1)) => {
|
||||
let per_frame_ms = (t1 - t0) * 1000.0 / v.len() as f64;
|
||||
// The tail: CPU percentiles over the same frames, how many frames ran over
|
||||
// the 60 Hz budget, and the worst six as `index:frame_ms/cpu_ms` — a frame
|
||||
// whose cpu is near its length was WORK; one far under it was a WAIT (GPU,
|
||||
// present, a lock).
|
||||
// CPU percentiles, frames over the 60 Hz budget, and the worst six as
|
||||
// `index:frame_ms/cpu_ms`: cpu near frame length was work, far under a wait.
|
||||
let mut c: Vec<f32> = probe.cpu_samples.clone();
|
||||
c.sort_by(f32::total_cmp);
|
||||
let cat = |q: f32| c[(((c.len() - 1) as f32) * q).round() as usize];
|
||||
@@ -500,8 +424,7 @@ fn drive_live_fps(
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
// Per thread, ms per frame over the window — the split that says which
|
||||
// thread a tax landed on; threads sharing a name (a pool) are summed.
|
||||
// Per thread, ms per frame; threads sharing a name (a pool) are summed.
|
||||
let threads = match (&probe.threads_at_start, crate::perf::thread_cpu_table()) {
|
||||
(Some(t0), Some(t1)) => {
|
||||
let start: std::collections::HashMap<usize, (f64, f64)> =
|
||||
@@ -538,9 +461,8 @@ fn drive_live_fps(
|
||||
.zip(probe.still_at_start)
|
||||
.map(|(n, s)| n.wrapping_sub(s))
|
||||
.collect();
|
||||
// Page faults per frame beside the split: `sys` time with no
|
||||
// syscall under it is the kernel zero-filling pages the allocator
|
||||
// gave back and asked for again.
|
||||
// Page faults per frame: `sys` time without syscalls is the kernel
|
||||
// zero-filling pages.
|
||||
let faults =
|
||||
match (probe.faults_at_start, crate::perf::process_faults()) {
|
||||
(Some((mi0, ma0)), Some((mi1, ma1))) => format!(
|
||||
@@ -571,11 +493,8 @@ fn drive_live_fps(
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
// How busy the WHOLE machine was across the same window — every core, every process.
|
||||
// `cpu_ms` is load-ROBUST, not load-immune (1157: the same pin leg read 25.93 with two
|
||||
// other slots compiling and 18.80-20.12 quiet), so a leg that does not say this cannot
|
||||
// be compared with one taken at a different time. A stamp, never a gate: legs at
|
||||
// similar `sys_busy_pct` are comparable, legs far apart are not.
|
||||
// Machine-wide busy share over the window: `cpu_ms` rises under outside load, so legs
|
||||
// compare only at similar `sys_busy_pct`. A stamp, not a gate.
|
||||
let sys = match (probe.sys_at_start, crate::perf::system_cpu_ticks()) {
|
||||
(Some((b0, t0)), Some((b1, t1))) if t1 > t0 => {
|
||||
format!(
|
||||
@@ -585,9 +504,8 @@ fn drive_live_fps(
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
// The pin the number belongs to, in the `.go xyz` order, so a probe line can be
|
||||
// matched against the report's coordinates without a second instrument — followed by
|
||||
// the exterior-scene gate's state, which is what makes `drawn=` legible indoors.
|
||||
// Where the sample was taken, in `.go xyz` order: a failed `.go` leaves the body at
|
||||
// login. Then the exterior-scene gate's state, which `drawn=` indoors needs.
|
||||
let at_pin = match (pin.map.as_ref(), pin.body.as_ref().filter(|b| b.active)) {
|
||||
(Some(m), Some(b)) => {
|
||||
let [x, y, z] = benilla_assets::coords::bevy_to_wow(b.pos);
|
||||
@@ -610,16 +528,13 @@ fn drive_live_fps(
|
||||
(Some(room), Some(w)) => format!(" room={room} windows={w}"),
|
||||
_ => String::new(),
|
||||
};
|
||||
// Beside `room=`, because the two answer the same question from opposite ends:
|
||||
// `room=none` says the camera claims no interior, `sky=` says whether some building's
|
||||
// PVS is painting its own backdrop over the world anyway.
|
||||
// Whether a building's PVS paints its own backdrop, the counterpart of `room=`.
|
||||
let sky = seen
|
||||
.sky
|
||||
.as_deref()
|
||||
.map(|s| format!(" sky={s}"))
|
||||
.unwrap_or_default();
|
||||
// The effect stream's other half. `emitters=`/`particles=` above count quad clouds
|
||||
// only, so a screen full of ribbon trails reads as an empty scene without this.
|
||||
// `emitters=`/`particles=` count quad clouds only; ribbons are counted here.
|
||||
let ribbons = seen
|
||||
.ribbons
|
||||
.map(|(n, d)| format!(" ribbons={n} ribbons_drawn={d}"))
|
||||
@@ -680,8 +595,7 @@ fn drive_live_fps(
|
||||
ui_batches.iter().count(),
|
||||
vis = {
|
||||
let mut n = [0usize; 4];
|
||||
// Of the model parts, how many draw in the transparent pass (M2 `Blend`
|
||||
// batches) — the sorted phase whose draws never batch across materials.
|
||||
// Model parts in the transparent pass (M2 `Blend`), which never batch.
|
||||
let mut blended = 0usize;
|
||||
for (vv, terrain, model, liquid) in vis_meshes.iter() {
|
||||
if !vv.get() {
|
||||
@@ -730,8 +644,7 @@ fn drive_live_fps(
|
||||
probe.occluded_frames,
|
||||
);
|
||||
print_vis_census(&seen);
|
||||
// The window's Modified-event totals per asset type — a type at ~1×/frame here is a
|
||||
// per-frame re-upload ratchet; absent means quiet.
|
||||
// Modified-event totals per asset type; ~1×/frame is a per-frame re-upload.
|
||||
if !seen.churn.is_empty() {
|
||||
let churn = seen
|
||||
.churn
|
||||
@@ -760,20 +673,9 @@ fn drive_live_fps(
|
||||
}
|
||||
}
|
||||
|
||||
/// **What is on the screen, and who is accountable for it** — the `VIS_CENSUS` line beside
|
||||
/// `FPS_PROBE`, plus a per-model breakdown under `WOW_VIS_DUMP=1`.
|
||||
///
|
||||
/// `drawn=` alone cannot answer "why can I still see that from in here?". It is one number over
|
||||
/// every model submesh, and the interesting split is not visible-vs-not: it is which *subsystem* the
|
||||
/// visible thing belongs to and whether anything is gating it at all. A tree that draws through a
|
||||
/// wall is a completely different defect depending on whether it is tagged as exterior scene
|
||||
/// (tagged but admitted — the cull or the bound is wrong) or is not (nothing is gating it — the
|
||||
/// wrong lane spawned it). Naming which took a screenshot, an asset dig and a wrong guess; this
|
||||
/// line answers it in one run.
|
||||
///
|
||||
/// `WOW_VIS_DUMP=1` then names the models: one `VIS_DUMP` line per distinct visible label, ungated
|
||||
/// first, most-drawn first — which is the "so WHICH trees are they?" question. The counting is
|
||||
/// [`benilla_world::world_census`]'s; the line shapes below are ours.
|
||||
/// `VIS_CENSUS`: visible submeshes per kind and whether the exterior-scene gate covers them, so a
|
||||
/// model drawing through a wall reads as tagged-but-admitted or ungated. `WOW_VIS_DUMP=1` adds one
|
||||
/// `VIS_DUMP` line per visible label, ungated first.
|
||||
fn print_vis_census(seen: &benilla_world::world_census::CensusReport) {
|
||||
let line = seen
|
||||
.kinds
|
||||
@@ -798,8 +700,7 @@ fn print_vis_census(seen: &benilla_world::world_census::CensusReport) {
|
||||
"VIS_CENSUS visible-submeshes {line} | resident {resident} | why {why} | tagged={} hidden={} exempt={} no_aabb={}",
|
||||
seen.tagged, seen.hidden, seen.exempt, seen.no_aabb
|
||||
);
|
||||
// The escapees always print: a tagged, bounded object the cull left un-hidden is a defect by
|
||||
// construction, and burying it behind a flag is how it stays unnoticed.
|
||||
// Always printed: a tagged, bounded object the cull left visible is a defect.
|
||||
for (label, card, n) in &seen.escaped {
|
||||
let c = if *card { " BILLBOARD-CARD" } else { "" };
|
||||
println!("VIS_ESCAPED {n}{c} {label}");
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
//! The LIVE-run probe instruments — every plugin here rides a NORMAL connected session (unlike
|
||||
//! the parent's server-less [`super::CapturePlugin`] harness): scripted chat sends
|
||||
//! ([`ProbeChatPlugin`]), synthetic key taps ([`ProbeKeyPlugin`]), a Lua chunk in the live UI VM
|
||||
//! ([`ProbeLuaPlugin`]), the bounded-lifetime self-exit ([`ProbeExitPlugin`]), and the live
|
||||
//! frame-time sampler ([`LiveFpsPlugin`]). The live screenshot and its validity gates live in the
|
||||
//! sibling [`super::live_shot`]. Each is env-gated and registered by `main`; compose them for
|
||||
//! unattended "park, act, observe" probes.
|
||||
//! The live-run probe instruments, riding a connected session (unlike the server-less
|
||||
//! [`super::CapturePlugin`] harness). Each is env-gated and registered by `main`; the live
|
||||
//! screenshot is [`super::live_shot`].
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// The **actuation** probes — the three channels that make a live session do something
|
||||
/// headlessly: chat/slash lines, synthetic key presses, and a Lua chunk in the UI VM.
|
||||
/// The particle-draw census (`WOW_FX_CENSUS=1`).
|
||||
mod fx_draw_census;
|
||||
pub(crate) use fx_draw_census::plugin as fx_draw_census_plugin;
|
||||
mod act;
|
||||
@@ -17,106 +12,67 @@ pub(crate) use act::{
|
||||
ProbeChatPlugin, ProbeDragPlugin, ProbeHoverPlugin, ProbeKeyPlugin, ProbeLuaPlugin,
|
||||
};
|
||||
|
||||
/// The probe **run** itself — its bounded lifetime, and the window it lives in (level,
|
||||
/// parking, size). Nothing here measures anything; it is the shell every other probe rides.
|
||||
/// The probe run's shell: its bounded lifetime and its window's level, parking and size.
|
||||
mod run;
|
||||
pub(crate) use run::{ProbeExitPlugin, ProbeFocusPlugin, ProbeResizePlugin};
|
||||
|
||||
/// The live frame-time sample — `FPS_PROBE` and the companion lines emitted from the same
|
||||
/// sample window (`VIS_CENSUS`/`VIS_ESCAPED`/`VIS_DUMP`, `MAT_CHURN`, `ASSET_DUMP`).
|
||||
/// The live frame-time sample, `FPS_PROBE`, and the lines printed from the same window.
|
||||
mod live_fps;
|
||||
pub(crate) use live_fps::LiveFpsPlugin;
|
||||
|
||||
/// The particle census — per-emitter state plus the draw-distance accounting behind B39.
|
||||
/// The particle census: per-emitter state and draw-distance accounting.
|
||||
mod particle_census;
|
||||
pub(crate) use particle_census::ParticleCensusPlugin;
|
||||
|
||||
/// The under-floor census — per-unit "where the server put it vs where we drew it", the instrument
|
||||
/// that turns a screenshot of an NPC below a floor into a number (B197, decision 1384).
|
||||
/// The under-floor census: per unit, where the server put it against where we drew it.
|
||||
mod ground_census;
|
||||
pub(crate) use ground_census::GroundCensusPlugin;
|
||||
|
||||
/// The transport census — per type-11/15 GameObject, the arm stage, the visibility pair and the
|
||||
/// render-descendant count: the reader that separates a lift the server never sent from one that
|
||||
/// never armed and so never lifted its spawn-hide (B168).
|
||||
/// The transport census: per type-11/15 GameObject, its arm stage, visibility and mesh count.
|
||||
mod lift_census;
|
||||
pub(crate) use lift_census::LiftCensusPlugin;
|
||||
mod trail_census;
|
||||
pub(crate) use trail_census::TrailCensusPlugin;
|
||||
|
||||
/// The unit-visual census — per-entity "what visual did this display actually get", which is what
|
||||
/// separates a debug cube (a gap of ours) from a model that legitimately draws nothing (B13,
|
||||
/// decision 1403).
|
||||
/// The unit-visual census: per entity, which visual its display got, telling a debug cube from a
|
||||
/// model that draws nothing.
|
||||
mod visual_census;
|
||||
pub(crate) use visual_census::UnitVisualsPlugin;
|
||||
|
||||
/// The motion-jitter meter — per frame, the camera / root / pose terms of a subject's rendered
|
||||
/// position as first *and second* differences, in mm and in pixels at its own distance: the
|
||||
/// reader that separates "the pose is noisy" from "the pose is smooth and the frames are not".
|
||||
/// The motion-jitter meter: per frame, the camera, root and pose terms of a subject's rendered
|
||||
/// position as first and second differences.
|
||||
mod jitter;
|
||||
pub(crate) use jitter::JitterMeterPlugin;
|
||||
|
||||
/// The dress census — per-player "what did the wire ask for, what did we resolve, what is actually
|
||||
/// hanging off the skeleton": the three things a screenshot of a geared character conflates, and
|
||||
/// the reader that turns "my show-helm preference is ignored" into a counted contradiction (B123,
|
||||
/// decision 1472).
|
||||
/// The dress census: per player, what the wire asked for, what we resolved and what hangs off the
|
||||
/// skeleton.
|
||||
mod dress_census;
|
||||
pub(crate) use dress_census::DressCensusPlugin;
|
||||
|
||||
/// The reveal audit — per-frame, from a snap: every term that decides whether the world about
|
||||
/// to be shown is actually drawable (decision 1498).
|
||||
/// The reveal audit: per frame from a snap, every term deciding whether the world is drawable.
|
||||
mod reveal;
|
||||
pub(crate) use reveal::RevealAuditPlugin;
|
||||
|
||||
/// The two exclusive-`World` reflection dumps: the bevy_ui node inventory and the archetype
|
||||
/// census — "what is resident right now, and what is it made of".
|
||||
/// The two exclusive-`World` dumps: the bevy_ui node inventory and the archetype census.
|
||||
mod world_census;
|
||||
pub(crate) use world_census::{EntityCensusPlugin, NodeProbePlugin};
|
||||
|
||||
/// The schedule census — per schedule, every system with its executor-relevant flags, both
|
||||
/// worlds: the structural inventory under the 1435 orchestration rows (decision 1437).
|
||||
/// The schedule census: per schedule in both worlds, every system with its executor flags.
|
||||
mod sched_census;
|
||||
pub(crate) use sched_census::SchedCensusPlugin;
|
||||
|
||||
/// The frame-stall injector — blocks the main loop on a schedule, reproducing on demand the one
|
||||
/// thing a probe window can never do to itself: go to the background and come back with one
|
||||
/// enormous frame delta. Doubles as the plain frame-delta/occlusion monitor at `WOW_STALL=0`.
|
||||
/// The frame-stall injector: blocks the main loop on a schedule to produce one huge frame delta;
|
||||
/// at `WOW_STALL=0` a plain frame-delta and occlusion monitor.
|
||||
mod stall;
|
||||
pub(crate) use stall::StallPlugin;
|
||||
|
||||
/// The clock **every probe schedule reads** — real time, never the virtual clock (decision 0789).
|
||||
///
|
||||
/// A probe knob is a wall-clock instruction: "send this at 20 s", "sample 300 frames from 25 s",
|
||||
/// "resize at 12 s", "exit at 480 s". `Time<Virtual>` cannot honour one — it clamps every frame delta
|
||||
/// to `max_delta` (250 ms, `bevy_time`'s default), so on any leg that hitches (a streaming burst, an
|
||||
/// occluded window, a loaded machine) it falls behind real time and drags the whole schedule with it.
|
||||
/// The knob then means something the operator never asked for, *silently*.
|
||||
///
|
||||
/// **Third time this clock has cost us a measurement.** 0615 moved the relayed-move replay off it
|
||||
/// (and cites the UI script clock as the same lesson before that); then B131's first causal leg was
|
||||
/// destroyed by it here — the probe-chat hops drifted 40 s → 75 s apart, so windows labelled
|
||||
/// "parked, ticks off" in fact contained a teleport and live ticks, and an eight-minute leg had to be
|
||||
/// thrown away (decision 0785's discarded run). A named alias is what makes the next probe get it
|
||||
/// right without knowing the story: type this, and the mistake is unavailable.
|
||||
///
|
||||
/// The one deliberate virtual clock in the harness is the fixture **age** in
|
||||
/// [`super::drive_capture`] — an age on the clock the effect animates on (and which the capture
|
||||
/// freezes at save time), which is not a schedule at all.
|
||||
/// The clock every probe schedule reads: real time. `Time<Virtual>` clamps each frame delta to
|
||||
/// `max_delta` (250 ms), so on a hitching leg it falls behind and drifts every `<secs>` knob.
|
||||
pub(crate) type ProbeClock<'w> = Res<'w, Time<bevy::time::Real>>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// **The invariant, checked instead of remembered** (decision 0789).
|
||||
///
|
||||
/// Naming the right clock [`ProbeClock`] makes it easy to reach for; it does not make the wrong
|
||||
/// one unavailable, and `Res<Time>` is the shorter, prelude-blessed, obvious spelling. That
|
||||
/// asymmetry is precisely why this same clock has now cost three lanes a correctness bug
|
||||
/// (0615's replay clock, the UI script clock, and B131's discarded probe leg) — each time it was
|
||||
/// fixed where it was found and left available everywhere else. So the fix is not another
|
||||
/// convention: it is this test, in the suite the gates already run.
|
||||
///
|
||||
/// Adding a virtual clock to the probe harness now means adding yourself to [`ALLOWED`] with a
|
||||
/// reason, which is a much better conversation than discovering the drift in a thrown-away leg.
|
||||
/// No probe system under `capture/` reads the virtual clock, except the [`ALLOWED`] entries.
|
||||
#[test]
|
||||
fn probe_schedules_read_the_wall_clock() {
|
||||
/// `(file, system, why it is genuinely an age or a delta on the animating clock)`.
|
||||
@@ -153,9 +109,7 @@ mod tests {
|
||||
),
|
||||
];
|
||||
|
||||
// The needles are assembled at runtime so the checker does not flag **its own source** —
|
||||
// which is exactly what it did on its first run, and is the cheapest possible proof that it
|
||||
// has teeth.
|
||||
// Needles are assembled at runtime so the checker does not flag its own source.
|
||||
let bare = format!(": Res<{}>,", "Time");
|
||||
let bare_last = format!(": Res<{}>", "Time");
|
||||
let explicit = format!("Res<{}<{}>>", "Time", "Virtual");
|
||||
@@ -163,11 +117,7 @@ mod tests {
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut offenders = Vec::new();
|
||||
let mut allowed_seen = 0usize;
|
||||
// The probe harness: everything under `capture/`. It used to need a hand-added exception
|
||||
// for the one probe that lived beside the player controller; decision 1174 moved every
|
||||
// instrument in here, so the directory walk IS the scope again — and the move immediately
|
||||
// earned its keep by catching `probe_cam`, which had been scheduling on the virtual clock
|
||||
// outside this checker's reach ever since 0653.
|
||||
// The probe harness is everything under `capture/`.
|
||||
let mut stack = vec![src.join("capture")];
|
||||
let mut files: Vec<std::path::PathBuf> = Vec::new();
|
||||
while let Some(dir) = stack.pop() {
|
||||
@@ -189,8 +139,7 @@ mod tests {
|
||||
let text = std::fs::read_to_string(&path).expect("source is readable");
|
||||
for (n, line) in text.lines().enumerate() {
|
||||
let t = line.trim();
|
||||
// A system param binding the virtual clock. `Time<Real>` is the whole point, and
|
||||
// `ResMut<Time<Virtual>>` is the capture's own clock *control*, not a read of it.
|
||||
// A param binding the virtual clock; `ResMut<Time<Virtual>>` is clock control.
|
||||
let virtual_clock =
|
||||
(t.ends_with(&bare) || t.ends_with(&bare_last)) || t.contains(&explicit);
|
||||
if !virtual_clock {
|
||||
|
||||
@@ -1,30 +1,14 @@
|
||||
//! The particle census ([`ParticleCensusPlugin`]) — one `PARTICLE_CENSUS_EMITTER` line per live
|
||||
//! emitter plus the `PARTICLE_CENSUS` summary, including the draw-distance accounting that is
|
||||
//! B39's numeric form.
|
||||
//! The particle census ([`ParticleCensusPlugin`]): a `PARTICLE_CENSUS_EMITTER` line per live
|
||||
//! emitter and a `PARTICLE_CENSUS` summary with draw-distance accounting.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::ProbeClock;
|
||||
|
||||
/// The particle census (`WOW_PARTICLE_CENSUS=<secs>`): once, `t` seconds in, print one line per
|
||||
/// live emitter (blend, file flags, sampled rate keys, texture, live count) plus a machine-
|
||||
/// readable total — the like-for-like number to put beside a reference-trace quad count (the
|
||||
/// login whirlpool investigation: the real client draws 793 particle quads across 23 draws in
|
||||
/// one `UI_MainMenu` frame). Works at any state — the glue screens included, unlike the
|
||||
/// in-world-gated FPS probe.
|
||||
///
|
||||
/// It also measures **draw distance** (decision 0678): each emitter's planar depth along
|
||||
/// camera-forward — the coordinate the far-clip wall uses — and the draw-set gate's verdict, with
|
||||
/// `drawn_beyond_wall` on the summary line. That is the numeric form of "effects render at
|
||||
/// unlimited distance" (bug B39): emitters still ticking and drawing past the wall that has already
|
||||
/// discarded the terrain beneath them. **It must read 0**; a non-zero value is the bug, live.
|
||||
///
|
||||
/// **`WOW_PARTICLE_CENSUS=+<secs>` fires that long after the world is first SHOWN** (the loading
|
||||
/// screen dropping) instead of after app start. Everything that rides the appear ramp — decision
|
||||
/// 0827/0833's `alpha` column above all — lives in a 2-second window whose start moves by *seconds*
|
||||
/// between a warm and a cold load, and a wall-clock timer either lands in it or does not: three
|
||||
/// runs in a row missed it while the question was "does a weapon glow ramp with its wearer?". A
|
||||
/// probe should not be a dice roll, and the ramp's own trigger is the thing to time from.
|
||||
/// `WOW_PARTICLE_CENSUS=<secs>`: once, prints one line per live emitter and a total, at any state
|
||||
/// including glue screens (the reference draws 793 quads in 23 draws on one `UI_MainMenu` frame).
|
||||
/// `drawn_beyond_wall` counts emitters drawn past the far-clip wall and must read 0.
|
||||
/// `+<secs>` times from the loading screen dropping, so the appear ramp is caught on any load.
|
||||
pub(crate) struct ParticleCensusPlugin;
|
||||
|
||||
impl Plugin for ParticleCensusPlugin {
|
||||
@@ -44,12 +28,11 @@ impl Plugin for ParticleCensusPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ParticleCensusPlugin`] state: the fire time and the once-latch.
|
||||
/// [`ParticleCensusPlugin`] state.
|
||||
#[derive(Resource)]
|
||||
struct ParticleCensus {
|
||||
at: f32,
|
||||
/// `at` is measured from the world being shown, not from app start — and is rewritten into an
|
||||
/// absolute time the first frame the loading screen stops covering.
|
||||
/// `at` counts from the world being shown; made absolute when the loading screen drops.
|
||||
after_shown: bool,
|
||||
fired: bool,
|
||||
}
|
||||
@@ -66,8 +49,7 @@ fn fire_particle_census(
|
||||
Option<&bevy::camera::visibility::RenderLayers>,
|
||||
)>,
|
||||
) {
|
||||
// Latch the shown-relative deadline the first frame the screen drops (and only then: while it
|
||||
// still covers there is no ramp to be relative to).
|
||||
// Latch the shown-relative deadline the first frame the screen drops.
|
||||
if probe.after_shown {
|
||||
if screen.covering() {
|
||||
return;
|
||||
@@ -81,26 +63,10 @@ fn fire_particle_census(
|
||||
probe.fired = true;
|
||||
let mut total = 0usize;
|
||||
let mut n = 0usize;
|
||||
// The B39 columns (decision 0678). Per emitter: its planar depth along camera-forward (the
|
||||
// coordinate the far-clip wall is measured in) and the draw-set gate's live verdict.
|
||||
//
|
||||
// **`drawn_beyond_wall` is the number that names the bug.** It counts emitters the gate is
|
||||
// still ticking and drawing at a depth where the detailed world — the terrain under them
|
||||
// included — has already been discarded by the wall. Before 0678 it was routinely non-zero,
|
||||
// because `doodad_fade_alpha` admits any owner over `NEVER_FADE_RADIUS` at *every* distance
|
||||
// and nothing else bounded depth; that is precisely "all effects render at unlimited
|
||||
// distance", out past the point where the terrain itself stops drawing.
|
||||
// It must now be **0**: past the wall the gate hides the emitter and freezes its pool.
|
||||
//
|
||||
// `beyond_wall` (verdict ignored) stays as the denominator — emitters *exist* out there and
|
||||
// should, they are simply frozen. A fix that despawned them would be the wrong fix.
|
||||
//
|
||||
// **Booth-layered emitters are excluded from the distance accounting** — the same layer filter
|
||||
// `simulate_particles` uses to pick a booth's camera. The portrait/glue scenes are parked
|
||||
// thousands of yards from the world and drawn by their OWN camera, so the world camera's wall
|
||||
// says nothing about them; counting them read as 28 phantom "effects past the wall" (all of
|
||||
// them Karazahn braziers and night-elf glows at ~7080 yd) on a build where the world was
|
||||
// already clean. Measuring the right subject is the instrument's job, not the reader's.
|
||||
// Per emitter: planar depth along camera-forward (the far-clip wall's coordinate) and the
|
||||
// draw-set gate's verdict. Past the wall the gate hides the emitter and freezes its pool, so
|
||||
// `beyond_wall` counts frozen emitters and `drawn_beyond_wall` must be 0. Booth-layered
|
||||
// emitters are drawn by their own camera, far from the world, and are left out.
|
||||
let cam_tf = cam.iter().next();
|
||||
let mut beyond_wall = 0usize;
|
||||
let mut drawn_beyond_wall = 0usize;
|
||||
@@ -110,8 +76,7 @@ fn fire_particle_census(
|
||||
for (e, fade, layers) in &emitters {
|
||||
let world_layer =
|
||||
layers.is_none_or(|l| l.intersects(&bevy::camera::visibility::RenderLayers::default()));
|
||||
// Depth to the OWNER's fade sphere where there is one (the gate's own subject), else the
|
||||
// emitter's live anchor — so the number always names what the gate actually tests.
|
||||
// Depth to the owner's fade sphere if any, else the anchor: what the gate tests.
|
||||
let depth = cam_tf.map(|t| {
|
||||
let center = fade.map_or_else(|| e.anchor_world(), |f| f.center);
|
||||
let radius = fade.map_or(0.0, |f| f.radius);
|
||||
@@ -138,10 +103,7 @@ fn fire_particle_census(
|
||||
let lane = if world_layer { "world" } else { "booth" };
|
||||
let c = fade.map_or_else(|| e.anchor_world(), |f| f.center);
|
||||
format!(
|
||||
// `has_fade`, not `gated`: this column prints whether the emitter carries an
|
||||
// `EmitterFade`, and calling it `gated` made it read as the draw-set verdict —
|
||||
// which is `drawn` (its inverse), right beside it. A tile-emitter reading was
|
||||
// taken backwards off the old label before it was renamed (decision 2046).
|
||||
// `has_fade` is whether it carries an `EmitterFade`; the verdict is `drawn`.
|
||||
" depth={d:.1} drawn={drawn} lane={lane} has_fade={} at=({:.0},{:.0},{:.0})",
|
||||
fade.is_some(),
|
||||
c.x,
|
||||
@@ -151,8 +113,7 @@ fn fire_particle_census(
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let d = e.def();
|
||||
// The rate summary: the constant (the common shape), else each slot's key count — the
|
||||
// full per-sequence choreography lives in `benilla-extract m2anim`, not a census line.
|
||||
// The constant rate, else each slot's key count (`benilla-extract m2anim` has the rest).
|
||||
let rate_keys: Vec<String> = match d.timing.constant_rate() {
|
||||
Some(r) => vec![format!("{r:.1}")],
|
||||
None => d
|
||||
@@ -163,9 +124,7 @@ fn fire_particle_census(
|
||||
.map(|(s, (_, r, _))| format!("s{s}:{}k", r.map_or(0, <[(f32, f32)]>::len)))
|
||||
.collect(),
|
||||
};
|
||||
// The orientation fingerprint (world plane normal + thickness/radius) is the numeric
|
||||
// "which way does this cloud face" — the flat-vs-standing question a screenshot can
|
||||
// only suggest (the InstancePortal swirl-plane investigation).
|
||||
// Which way the cloud faces: world plane normal, thickness and radius.
|
||||
let plane = e
|
||||
.cloud_fingerprint()
|
||||
.map(|(c, nrm, thick, radius)| {
|
||||
@@ -183,9 +142,7 @@ fn fire_particle_census(
|
||||
d.params.sample(None, 0.0, 0.0).lifespan,
|
||||
d.texture.as_deref().unwrap_or("-"),
|
||||
e.live(),
|
||||
// The frame's composed MODEL alpha (decision 0827/0833) — the number that answers
|
||||
// "this cloud is drawing, why can't I see it / why is it full strength?". An effect on
|
||||
// a unit that has not appeared yet reads ~0; one with no model above it reads 1.
|
||||
// The composed model alpha: ~0 on a unit not yet appeared, 1 with no model above.
|
||||
e.render_alpha(),
|
||||
);
|
||||
total += e.live();
|
||||
@@ -196,9 +153,7 @@ fn fire_particle_census(
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// The camera pose goes on the line so a census is self-describing: every distance number here
|
||||
// is measured from it, and a probe whose `.go` silently failed otherwise reports crisp numbers
|
||||
// about the wrong place.
|
||||
// Every distance is from this camera; it also shows a failed `.go`.
|
||||
let where_ = cam_tf
|
||||
.map(|t| {
|
||||
let p = t.translation();
|
||||
|
||||
@@ -1,38 +1,11 @@
|
||||
//! The **reveal audit** (`WOW_REVEAL=<frames>`) — one line per frame from a snap, naming every
|
||||
//! term that decides whether the world the player is about to be shown is actually there.
|
||||
//! `WOW_REVEAL=<frames>`: one `REVEAL` line per frame after a snap (a same-map teleport or a
|
||||
//! worldport), naming every term that decides whether the destination is drawn. Residency columns
|
||||
//! (`res`, `focus`, `scene`, `place`, `coll`, `merge`, `gx`) are the terms behind
|
||||
//! [`WorldLoadProgress::presentable`], which the cover waits on; draw columns (`drawn`, `hid`,
|
||||
//! `sel`, `room`, `win`, `pvslag`) are what the frame put on screen.
|
||||
//!
|
||||
//! "Teleported and the city wasn't drawn for a frame or more" is a *temporal* claim about the
|
||||
//! frames either side of a reveal, and neither a screenshot nor the loading screen's own 3-second
|
||||
//! wait line can hold one: the screenshot is one frame with no state attached, and the wait line
|
||||
//! only speaks for loads slow enough to be stuck. This prints the whole window — the cover, the
|
||||
//! residency terms behind [`WorldLoadProgress::presentable`], the settle hold, and the retained
|
||||
//! pass's collected-vs-published region counts — so "which term let the reveal through" is read
|
||||
//! off a column instead of guessed.
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_USER=probe1 WOW_PASS=pprobe1 WOW_CHAR=Probeone \
|
||||
//! WOW_PROBE_CHAT=".go xyz -9250.45 160.86 67.90 0;.tele stormwind" \
|
||||
//! WOW_PROBE_CHAT_AT=20 WOW_PROBE_CHAT_EVERY=25 WOW_REVEAL=120 \
|
||||
//! WOW_PROBE_EXIT_AT=70 cargo run -q -p benilla | grep REVEAL
|
||||
//! ```
|
||||
//!
|
||||
//! The arming edge is the snap itself (a same-map teleport or a worldport), so the window covers
|
||||
//! the frames a cover would have to be up for — and, when none is, the frames the player is
|
||||
//! looking at the destination through nothing at all.
|
||||
//!
|
||||
//! **It runs in `Last`, and that is load-bearing.** Half of what decides whether a frame has a
|
||||
//! world in it is settled in `PostUpdate` — bevy's visibility check, the exterior-scene gate, the
|
||||
//! retained pass's own scene walk — and a line printed in `Update` reports those columns from the
|
||||
//! *previous* frame. Against a one-or-two-frame artefact an off-by-one instrument is worse than
|
||||
//! none: it attributes the hole to the frame beside it. In `Last` every column below is this
|
||||
//! frame's, residency and draw alike.
|
||||
//!
|
||||
//! The columns split into two halves on purpose. **Residency** (`res`, `focus`, `scene`, `place`,
|
||||
//! `coll`, `merge`, `gx`) is *what the world has* — the terms behind
|
||||
//! [`WorldLoadProgress::presentable`], the ones the cover is raised and cleared on. **Draw**
|
||||
//! (`drawn`, `hid`, `sel`, `room`, `win`, `pvseye`) is *what the frame put on screen*. A reveal
|
||||
//! defect where every residency term reads ready and the draw half has collapsed is not a
|
||||
//! streaming bug at all — it is the visibility authority answering about somewhere else.
|
||||
//! Runs in `Last`: visibility, the exterior-scene gate and the retained pass settle in
|
||||
//! `PostUpdate`, so an `Update` line would report them a frame late.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -47,9 +20,7 @@ struct RevealAudit {
|
||||
frames: u32,
|
||||
/// Frames printed since the current arm; `None` = not armed.
|
||||
n: Option<u32>,
|
||||
/// [`ProbeClock`] seconds at the arming snap — the `t=` column. The wall clock, like every
|
||||
/// other probe schedule (decision 0789): a reveal window is measured in real milliseconds,
|
||||
/// and the virtual clock clamps exactly the hitching frames this instrument exists to see.
|
||||
/// [`ProbeClock`] seconds at the arming snap, the base of the `t=` column.
|
||||
since: f32,
|
||||
}
|
||||
|
||||
@@ -66,8 +37,7 @@ impl Plugin for RevealAuditPlugin {
|
||||
n: None,
|
||||
since: 0.0,
|
||||
})
|
||||
// In `Last`: after the streamer published residency, after the screen decided what to do
|
||||
// with it, and after `PostUpdate` settled what actually draws (see the module docs).
|
||||
// `Last`: after residency, the loading screen and `PostUpdate`'s visibility.
|
||||
.add_systems(Last, drive_reveal_audit);
|
||||
}
|
||||
}
|
||||
@@ -105,8 +75,7 @@ fn drive_reveal_audit(
|
||||
.as_deref()
|
||||
.map_or((0, 0, 0), benilla_world::static_gx::StaticGx::draw_census);
|
||||
let seen = census.take();
|
||||
// How far the visibility authority's pose is from the eye this frame is actually drawn from
|
||||
// — ~0 in steady play, the whole jump on a snap frame it has not caught up with.
|
||||
// Distance from the visibility authority's eye to the drawn camera: ~0 in steady play.
|
||||
let pvs_lag = match (seen.pvs_eye, cam.iter().next()) {
|
||||
(Some(eye), Some(now_eye)) => eye.distance(now_eye.translation()),
|
||||
_ => f32::NAN,
|
||||
|
||||
@@ -1,36 +1,15 @@
|
||||
//! The probe **run's own shell** — everything about the process and its window rather than
|
||||
//! about what is being measured: the un-occludable, parked window ([`ProbeFocusPlugin`]), the
|
||||
//! bounded lifetime ([`ProbeExitPlugin`]), and the mid-run resize ([`ProbeResizePlugin`]).
|
||||
//! Every scripted probe rides these, whatever instrument it is running.
|
||||
//! The probe run's shell: the un-occludable, parked window ([`ProbeFocusPlugin`]), the bounded
|
||||
//! lifetime ([`ProbeExitPlugin`]) and the mid-run resize ([`ProbeResizePlugin`]).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::ProbeClock;
|
||||
|
||||
/// Keep a probe run's window **un-occludable, and out of the director's way** — the one defence
|
||||
/// against macOS's ~1 fps throttle for a fully covered window (decisions 0713/0777, docs/METHOD.md's
|
||||
/// `caffeinate` note), at the smallest footprint that still buys it.
|
||||
///
|
||||
/// The on-top half used to live inside the FPS probe alone, which reads as "a frame-rate concern".
|
||||
/// It is not: **every** scripted probe schedule is wall-clock ([`ProbeClock`]), so a throttled run
|
||||
/// doesn't just measure slowly, it *executes the wrong script* — one session's mounted-jump run
|
||||
/// fired `W@16` and `Space@19` in the SAME frame at ~1 fps, i.e. it jumped from a standstill
|
||||
/// instead of mid-run, and the leg had to be re-read to notice (decision 0906). Any probe env arms
|
||||
/// it, so a key/chat/Lua probe defends itself exactly like the FPS one.
|
||||
///
|
||||
/// **The parking half is the other side of that bill** (decision 1148). Asserting `AlwaysOnTop`
|
||||
/// every frame silently defeats [`benilla_world::bgwin`]'s whole design — it overrides the
|
||||
/// `AlwaysOnBottom` birth cage *and* the `Normal` handed back at release — so an instrumented run
|
||||
/// sits on top for its entire life however politely it was launched. At the full-size default that
|
||||
/// is a screen-filling window over whatever the director is doing, and a session that fires six
|
||||
/// probes fires six of them (which is how it got reported). The answer is not to drop the
|
||||
/// assertion — the throttle is real — but to shrink what is being asserted:
|
||||
/// [`benilla_world::bgwin::no_pixel_run`] sizes such a run at 640×360 and this parks it in the top-right
|
||||
/// corner. A run that photographs pixels is excluded from both and keeps the full window.
|
||||
///
|
||||
/// Write-gated on both counts: re-marking `Window` every frame would re-apply its whole state
|
||||
/// through winit, and the park is one-shot, latched once a monitor is actually readable (the
|
||||
/// monitor entities do not exist on frame 1).
|
||||
/// Keeps a probe window un-occludable: macOS throttles a fully covered window to ~1 fps, which
|
||||
/// on the wall clock ([`ProbeClock`]) collapses scheduled steps into one frame. A no-pixel run
|
||||
/// ([`benilla_world::bgwin::no_pixel_run`], 640×360) is also parked once, per [`Park`]; a run that
|
||||
/// photographs pixels keeps the full window. Pinning overrides [`benilla_world::bgwin`]'s levels.
|
||||
/// Writes only on change: marking `Window` changed re-applies its whole state through winit.
|
||||
pub(crate) struct ProbeFocusPlugin;
|
||||
|
||||
impl Plugin for ProbeFocusPlugin {
|
||||
@@ -39,34 +18,25 @@ impl Plugin for ProbeFocusPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a no-pixel probe window goes, and whether it is pinned on top — `WOW_PROBE_PARK`.
|
||||
///
|
||||
/// This is a **dial, not a preference**, and it exists because the thing it trades against is
|
||||
/// recorded as unproven. `AlwaysOnTop` costs the director screen for every probe a session fires;
|
||||
/// what it buys is immunity from the macOS occlusion throttle, whose evidence is decision 0713's
|
||||
/// *"one suspect survives, unproven"* (stall magnitudes of 1026–1053 ms sitting on
|
||||
/// `CAMetalLayer.nextDrawable`'s 1 s timeout) promoted to fact by 0777 and built on by 0906. The
|
||||
/// probe line already stamps `occluded_frames=`, so each setting is measurable against the others
|
||||
/// on the same pin — which is the only way to retire the on-top assertion honestly.
|
||||
/// `WOW_PROBE_PARK`: where a no-pixel probe window goes and whether it is pinned on top; compare
|
||||
/// settings by the probe line's `occluded_frames=`.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Park {
|
||||
/// Top-right corner, `AlwaysOnTop`. Today's behaviour.
|
||||
/// Top-right corner, `AlwaysOnTop`; the default.
|
||||
Corner,
|
||||
/// Mostly past the right edge — a [`PARK_EDGE_SLIVER`]-wide strip left on screen, at the
|
||||
/// NORMAL level. macOS marks a window `NSWindowOcclusionStateVisible` if *any* part of it is
|
||||
/// visible, so a sliver should be enough to dodge the throttle at ~1% of the screen cost.
|
||||
/// A [`PARK_EDGE_SLIVER`]-wide strip on screen at the normal level: macOS counts a window
|
||||
/// visible if any part of it is.
|
||||
Edge,
|
||||
/// Wholly past the right edge, NORMAL level. The best case if AppKit allows it — AppKit
|
||||
/// constrains ordinary titled windows back onto a screen (`constrainFrameRect:toScreen:`), so
|
||||
/// this may simply not stick; that is the assumption the dial exists to test.
|
||||
/// Wholly past the right edge, normal level; AppKit may constrain it back
|
||||
/// (`constrainFrameRect:toScreen:`).
|
||||
Off,
|
||||
}
|
||||
|
||||
/// How much of an [`Park::Edge`] window stays on screen (logical px).
|
||||
const PARK_EDGE_SLIVER: f32 = 32.0;
|
||||
|
||||
/// Gap (logical px) a [`Park::Corner`] window keeps from the screen's top and right edges — clear
|
||||
/// of the menu bar, which a window at `y = 0` would sit under.
|
||||
/// Gap (logical px) a [`Park::Corner`] window keeps from the top and right edges, clear of the
|
||||
/// menu bar.
|
||||
const PROBE_WINDOW_MARGIN: f32 = 36.0;
|
||||
|
||||
fn park_mode() -> Park {
|
||||
@@ -87,9 +57,7 @@ fn keep_probe_window_on_top(
|
||||
};
|
||||
let no_pixel = benilla_world::bgwin::no_pixel_run();
|
||||
let mode = park_mode();
|
||||
// Only `Corner` needs the pin: the other two get out of the way by *position*, which is the
|
||||
// point of measuring them — an edge/offscreen window that never reports `occluded_frames` has
|
||||
// retired the assertion on evidence instead of preference.
|
||||
// A pixel run is always pinned; a no-pixel run only in `Corner`, the others dodge by position.
|
||||
let want_top = !no_pixel || mode == Park::Corner;
|
||||
let level = if want_top {
|
||||
bevy::window::WindowLevel::AlwaysOnTop
|
||||
@@ -102,10 +70,9 @@ fn keep_probe_window_on_top(
|
||||
if *parked || !no_pixel {
|
||||
return;
|
||||
}
|
||||
// `WindowPosition::At` is in PHYSICAL pixels while the window's own resolution is logical, so
|
||||
// the width has to be scaled before it is subtracted.
|
||||
// `WindowPosition::At` is physical pixels, the resolution logical: scale the width.
|
||||
let Some(m) = monitors.iter().next() else {
|
||||
return; // no monitor entity yet — try again next frame
|
||||
return; // no monitor entity yet (frame 1)
|
||||
};
|
||||
let scale = m.scale_factor as f32;
|
||||
let width = (w.resolution.width() * scale) as i32;
|
||||
@@ -130,9 +97,7 @@ fn keep_probe_window_on_top(
|
||||
);
|
||||
}
|
||||
|
||||
/// The probe self-termination as its own plugin, registered whenever `WOW_PROBE_EXIT_AT` is set
|
||||
/// — it used to ride inside [`super::ProbeLuaPlugin`], so a chat/key-only probe's exit knob silently
|
||||
/// did nothing (the 0441 flourish probe hung past its window on exactly that).
|
||||
/// The probe self-termination, registered whenever `WOW_PROBE_EXIT_AT` is set.
|
||||
pub(crate) struct ProbeExitPlugin;
|
||||
|
||||
impl Plugin for ProbeExitPlugin {
|
||||
@@ -147,10 +112,7 @@ impl Plugin for ProbeExitPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe run's clean self-termination (`WOW_PROBE_EXIT_AT=<secs>`, off when unset): exit the
|
||||
/// app after N wall seconds, so a scripted live probe (`WOW_PROBE_LUA`/`WOW_PROBE_CHAT`) is one
|
||||
/// foreground command with a bounded lifetime — no external kill, no orphaned window (0437's
|
||||
/// probe rounds prompted it; generic to every future live probe).
|
||||
/// `WOW_PROBE_EXIT_AT=<secs>`: exits the app after that many wall seconds; off when unset.
|
||||
#[derive(Resource)]
|
||||
struct ProbeExit {
|
||||
at: Option<f32>,
|
||||
@@ -167,10 +129,8 @@ fn fire_probe_exit(
|
||||
info!("probe-exit: {at}s elapsed — exiting");
|
||||
probe.fired = true;
|
||||
exit.write(AppExit::Success);
|
||||
// The hard backstop rides its own OS thread: the polite AppExit stops the Update
|
||||
// schedule, so an in-schedule backstop can never fire — exactly the hang it existed
|
||||
// for (a winit/net-thread teardown hang leaves a zombie client holding the account;
|
||||
// the 0451 probe reproduced it). A probe run has nothing to lose.
|
||||
// The hard backstop runs on its own thread, since `AppExit` stops `Update`: a teardown
|
||||
// hang would otherwise leave a client holding the account.
|
||||
std::thread::spawn(|| {
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
warn!("probe-exit: still alive 5s after AppExit — hard exit");
|
||||
@@ -179,10 +139,8 @@ fn fire_probe_exit(
|
||||
}
|
||||
}
|
||||
|
||||
/// The window-resize probe (`WOW_PROBE_RESIZE="<secs>:<W>x<H>"`, logical units): resize the
|
||||
/// primary window mid-run — the headless stand-in for a mac fullscreen toggle or a window drag,
|
||||
/// so resize-reactive layout (the glue screens' rescale rebuild) is verifiable in one scripted
|
||||
/// run: open, resize at `t`, shoot after (`WOW_LOGIN_SHOT_OUT` fires at 8 s).
|
||||
/// `WOW_PROBE_RESIZE="<secs>:<W>x<H>"`: resizes the primary window to that logical size mid-run,
|
||||
/// standing in for a fullscreen toggle or a window drag.
|
||||
pub(crate) struct ProbeResizePlugin;
|
||||
|
||||
impl Plugin for ProbeResizePlugin {
|
||||
@@ -206,7 +164,7 @@ impl Plugin for ProbeResizePlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ProbeResizePlugin`] state: the fire time, the target logical size, and the once-latch.
|
||||
/// [`ProbeResizePlugin`] state; `size` is logical.
|
||||
#[derive(Resource)]
|
||||
struct ProbeResize {
|
||||
at: f32,
|
||||
|
||||
@@ -1,33 +1,19 @@
|
||||
//! The schedule census (`WOW_SCHED_CENSUS=1`): the structural inventory behind the 1435 band
|
||||
//! map's orchestration rows — per schedule, every system with its executor-relevant flags
|
||||
//! (non-`Send`, exclusive, has-deferred), for BOTH worlds. Born with decision 1437: the parked
|
||||
//! frame pays ~5.4 ms/f of pure scheduling (schedule selves + executor + empty command applies
|
||||
//! across ~600 executed systems), and neither consolidation nor executor choices can be argued
|
||||
//! about until the population has names and counts.
|
||||
//!
|
||||
//! Why runtime one-shots instead of a pre-run `Schedule::initialize` walk: bevy removes a
|
||||
//! schedule from the `Schedules` resource WHILE it runs (`World::schedule_scope`), so no single
|
||||
//! vantage sees everything — and pre-run initialization is the wrong tool twice over
|
||||
//! (`Extract` params panic without the runtime-only `MainWorld`, and `Local<impl FromWorld>`
|
||||
//! state may read resources Startup hasn't inserted yet). Two vantages per world cover each
|
||||
//! other: `Update` and `PostUpdate` in the main world, `ExtractSchedule` and `Render` in the
|
||||
//! render app. Only the `Main`/`RenderStartup` runners stay invisible (each is the fixed
|
||||
//! one-system runner bevy ships).
|
||||
//! `WOW_SCHED_CENSUS=1`: per schedule in both worlds, every system with its executor flags
|
||||
//! (non-`Send`, exclusive, has-deferred). Bevy removes a running schedule from `Schedules`
|
||||
//! (`World::schedule_scope`), so two vantages per world cover each other: `Update`/`PostUpdate`
|
||||
//! in main, `ExtractSchedule`/`Render` in the render app. Only the `Main`/`RenderStartup` runners
|
||||
//! stay unseen.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::{ExtractSchedule, Render, RenderApp};
|
||||
|
||||
/// Frames to wait before dumping — deep enough that every schedule has run at least once (the
|
||||
/// census reads the live graph, not what a first frame happens to have reached).
|
||||
/// The frame to dump at: late enough that every schedule has run once.
|
||||
const CENSUS_FRAME: u32 = 10;
|
||||
|
||||
/// Frames to wait before the census run exits — the render app runs a frame behind the main
|
||||
/// world (pipelined), so give its vantages room past [`CENSUS_FRAME`] before the app closes.
|
||||
/// The frame the run exits at; the pipelined render app runs a frame behind [`CENSUS_FRAME`].
|
||||
const EXIT_FRAME: u32 = 40;
|
||||
|
||||
/// The census instrument. Purely structural — it does not need the world, a server, or a
|
||||
/// character: the schedule graph is fixed once the plugins have built, so a login-screen run
|
||||
/// answers for every state.
|
||||
/// The census; the graph is fixed once plugins build, so a login-screen run answers for all states.
|
||||
pub(crate) struct SchedCensusPlugin;
|
||||
|
||||
impl Plugin for SchedCensusPlugin {
|
||||
@@ -36,8 +22,7 @@ impl Plugin for SchedCensusPlugin {
|
||||
.add_systems(Update, census_vantage("main", "Update"))
|
||||
.add_systems(PostUpdate, census_vantage("main", "PostUpdate"))
|
||||
.add_systems(Last, census_exit);
|
||||
// The render app still lives in the main `App` here (pipelining detaches it at cleanup,
|
||||
// after every plugin has built). Its two vantages mirror the main world's pair.
|
||||
// The render app is still a sub-app here; pipelining detaches it at cleanup.
|
||||
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
|
||||
render_app
|
||||
.init_resource::<DumpedSchedules>()
|
||||
@@ -47,13 +32,11 @@ impl Plugin for SchedCensusPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which schedules this world's earlier vantage already printed — the later vantage adds only
|
||||
/// what the earlier one could not see (itself, chiefly).
|
||||
/// Schedules this world has printed; the later vantage adds only what the earlier could not see.
|
||||
#[derive(Resource, Default)]
|
||||
struct DumpedSchedules(std::collections::HashSet<String>);
|
||||
|
||||
/// One vantage: an exclusive one-shot that, at [`CENSUS_FRAME`], prints every schedule visible
|
||||
/// in `Schedules` that no earlier vantage in this world has printed.
|
||||
/// One vantage: at [`CENSUS_FRAME`], prints every visible schedule this world has not printed.
|
||||
fn census_vantage(
|
||||
world_tag: &'static str,
|
||||
vantage: &'static str,
|
||||
@@ -80,9 +63,8 @@ fn census_vantage(
|
||||
}
|
||||
}
|
||||
|
||||
/// Print one schedule: the tally line, then one line per system. `systems()` errors only on an
|
||||
/// uninitialized graph — impossible by [`CENSUS_FRAME`] for a schedule that runs, and a
|
||||
/// registered-but-never-run schedule is exactly worth flagging as such.
|
||||
/// Prints one schedule's tally, then one line per system. By [`CENSUS_FRAME`] only a schedule that
|
||||
/// never ran is uninitialized, and it is flagged.
|
||||
fn dump_schedule(
|
||||
world_tag: &str,
|
||||
vantage: &str,
|
||||
@@ -129,7 +111,7 @@ fn dump_schedule(
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the run once both worlds have had time to print — the census is its own whole run.
|
||||
/// Exits once both worlds have printed.
|
||||
fn census_exit(mut frame: Local<u32>, mut exit: MessageWriter<AppExit>) {
|
||||
*frame += 1;
|
||||
if *frame == EXIT_FRAME {
|
||||
|
||||
@@ -1,63 +1,16 @@
|
||||
//! The **frame-stall injector** (`WOW_STALL="<ms>[,<every_s>[,<after_s>[,<frames>]]]"`) — the
|
||||
//! instrument that makes "I tabbed away and came back to X" reproducible.
|
||||
//! `WOW_STALL="<ms>[,<every_s>[,<after_s>[,<frames>]]]"`: blocks the main loop for `<ms>` on each
|
||||
//! of `<frames>` consecutive frames, every `<every_s>` seconds from `<after_s>`, reproducing a
|
||||
//! backgrounded window, which a kept-visible probe window ([`super::ProbeFocusPlugin`]) never is.
|
||||
//!
|
||||
//! A probe window cannot be tabbed away from: every scripted live probe asserts `AlwaysOnTop`
|
||||
//! ([`super::ProbeFocusPlugin`], decision 0906) precisely so the OS cannot throttle it. So the one
|
||||
//! class of bug that only appears *because* the window went to the background — the client running
|
||||
//! for seconds at ~1 fps and then resuming — had no reproduction at all. This is that reproduction:
|
||||
//! it blocks the main loop for `<ms>` on each of `<frames>` consecutive frames, every `<every_s>`
|
||||
//! seconds, starting at `<after_s>`. Nothing else.
|
||||
//! Losing focus alone costs nothing (bevy's default `WinitSettings::game()` keeps an unfocused
|
||||
//! window at 60 Hz); a fully covered macOS window blocks ~1 s in `CAMetalLayer.nextDrawable` every
|
||||
//! frame. A ten-second tab-away is therefore `WOW_STALL="1000,20,60,10"`, and `WOW_STALL="0"` is
|
||||
//! the monitor alone. Distinct from `WOW_STALL_INJECT` (`crate::perf::stall`), one watchdog sleep.
|
||||
//!
|
||||
//! **What backgrounding actually does, and why this shape is the faithful one.** Two independent
|
||||
//! mechanisms, and only the second one bites:
|
||||
//!
|
||||
//! - *winit* — we never set [`bevy::winit::WinitSettings`], so we get its `Default`, which is
|
||||
//! `WinitSettings::game()`: `focused_mode: Continuous`, `unfocused_mode:
|
||||
//! reactive_low_power(1/60 s)` (`bevy_winit-0.18.1/src/winit_config.rs:19-22`). An unfocused
|
||||
//! window therefore still targets **60 Hz**; losing focus on its own costs nothing.
|
||||
//! - *macOS* — a window that is fully **covered** is throttled at the drawable: every
|
||||
//! `CAMetalLayer.nextDrawable` blocks about a second, so the run continues at ~1 fps for as long
|
||||
//! as it is covered (decisions 0713/0777; the director's correction in 0906 — "any covering
|
||||
//! window, not just the lock screen"; 1355 measured the signature as a `p99 ≈ 1020 ms`
|
||||
//! metronome). A ten-second glance at a terminal is therefore ~10 consecutive one-second frames,
|
||||
//! which is why `<frames>` exists: a *single* long frame is a hitch, and a hitch is not what
|
||||
//! backgrounding does.
|
||||
//!
|
||||
//! So the knob that reproduces a ten-second tab-away is `WOW_STALL="1000,20,60,10"`.
|
||||
//!
|
||||
//! **Not to be confused with `WOW_STALL_INJECT`** (`crate::perf::stall`, decisions 0713/1637),
|
||||
//! whose name is one suffix away. That one fires **a single** sleep at a single instant, and it
|
||||
//! exists to prove the stuck-main-thread *watchdog* fires — it is a test affordance for an
|
||||
//! instrument, not a reproduction of a gameplay state. This one repeats, blocks `<frames>` in a
|
||||
//! row, and carries the frame-delta/occlusion monitor, which is the whole difference: 1663 was
|
||||
//! reproducible only with **consecutive** long frames (one alone loses the deck for a frame and
|
||||
//! recovers; two drop the rider through it). The two should probably become one knob — flagged,
|
||||
//! not done here.
|
||||
//!
|
||||
//! **`WOW_STALL="0"` injects nothing** and leaves only the monitor running — the "what is this
|
||||
//! run's frame pacing actually doing" reader, which is how the premise above gets checked on a real
|
||||
//! run instead of assumed. Every run prints, per window:
|
||||
//!
|
||||
//! ```text
|
||||
//! STALL_WATCH t=35.0 frames=299 p50=16.7 p99=17.9 max=33.4 focused=1 occluded=0
|
||||
//! STALL t=35.0 sleeping 1000 ms (1/10)
|
||||
//! STALL_HIT t=36.0 injected=1000 wall_dt=1016.4 real_dt=16.7 virt_dt=16.7 clamped=0
|
||||
//! ```
|
||||
//!
|
||||
//! **Three clocks on that line, and they are three different clocks — that is the point.**
|
||||
//!
|
||||
//! - `wall_dt` is [`Instant`] to [`Instant`] across the main-schedule pass: the true frame time,
|
||||
//! and the clock `transport::Transport::cycle_ms` runs on (`anchor.at.elapsed()`).
|
||||
//! - `real_dt` is `Time<Real>`'s delta, which under pipelined rendering is **not** the wall clock:
|
||||
//! `time_system` stamps it from an `Instant` the *render world* sends over a `bounded(2)` channel
|
||||
//! (`bevy_time-0.18.1/src/lib.rs:152-171`, `bevy_render-0.18.1/src/renderer/mod.rs:113`), so it
|
||||
//! arrives a frame or two late — bevy's own source carries a `TODO: Figure out how to handle
|
||||
//! this when using pipelined rendering` right above it.
|
||||
//! - `virt_dt` is what every consumer of `Res<Time>` integrates: `real_dt` after the frame pacer,
|
||||
//! clamped to `Time<Virtual>`'s stock 250 ms `max_delta`. `clamped=1` marks the frames where the
|
||||
//! clamp bit.
|
||||
//!
|
||||
//! A `focused=`/`occluded=` transition prints its own line the moment it lands.
|
||||
//! `STALL_HIT` prints three clocks: `wall_dt`, [`Instant`] across the main-schedule pass (the
|
||||
//! clock `transport::Transport::cycle_ms` runs on); `real_dt`, `Time<Real>`, which under pipelined
|
||||
//! rendering is stamped from an `Instant` the render world sends and lags a frame or two;
|
||||
//! `virt_dt`, after the frame pacer and the 250 ms `max_delta` clamp (`clamped=1`).
|
||||
|
||||
use core::time::Duration;
|
||||
use std::time::Instant;
|
||||
@@ -68,8 +21,7 @@ use bevy::window::WindowOccluded;
|
||||
|
||||
use super::ProbeClock;
|
||||
|
||||
/// Default seconds between stall bursts, and default seconds of run before the first one — long
|
||||
/// enough that login, world entry and the first streaming burst are never what is being measured.
|
||||
/// Default seconds between bursts and before the first, clear of login and world entry.
|
||||
const DEFAULT_EVERY_SECS: f32 = 20.0;
|
||||
const DEFAULT_AFTER_SECS: f32 = 30.0;
|
||||
/// How often the monitor prints its `STALL_WATCH` summary, seconds.
|
||||
@@ -115,22 +67,20 @@ impl Plugin for StallPlugin {
|
||||
focused: None,
|
||||
occluded: false,
|
||||
})
|
||||
// `Last`, so the block lands at the frame boundary: the sleep is over by the time the
|
||||
// next frame's `First` stamps the clocks, and it therefore shows up as that frame's
|
||||
// delta — which is the shape an unscheduled/throttled frame has.
|
||||
// `Last`: the sleep ends before the next `First` stamps the clocks, so it is that
|
||||
// frame's delta, as a throttled frame's is.
|
||||
.add_systems(Last, drive_stall);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`StallPlugin`] state: the knob, the schedule, and the current window's samples.
|
||||
/// [`StallPlugin`] state.
|
||||
#[derive(Resource)]
|
||||
struct Stall {
|
||||
/// Milliseconds to block for, `0` = monitor only.
|
||||
/// Milliseconds to block for; `0` is monitor only.
|
||||
ms: u64,
|
||||
/// Seconds between bursts.
|
||||
every: f32,
|
||||
/// Consecutive frames blocked per burst — a backgrounded window is ~1 fps for as long as it
|
||||
/// is covered, so one long frame is a hitch and `burst` long frames are a tab-away.
|
||||
/// Consecutive frames blocked per burst: one long frame is a hitch, several a tab-away.
|
||||
burst: u32,
|
||||
/// Wall-clock second of the next burst.
|
||||
next_stall: f32,
|
||||
@@ -138,15 +88,13 @@ struct Stall {
|
||||
left: u32,
|
||||
/// Wall-clock second of the next `STALL_WATCH` summary.
|
||||
next_watch: f32,
|
||||
/// Set on the frame that slept; read (and reported) on the frame that paid for it.
|
||||
/// Set on the frame that slept; reported on the frame that paid for it.
|
||||
pending: Option<u64>,
|
||||
/// End of the previous main-schedule pass — the true wall-clock frame delta, which under
|
||||
/// pipelined rendering `Time<Real>` is not (see the module header).
|
||||
/// End of the previous main-schedule pass, for the wall-clock frame delta.
|
||||
last: Option<Instant>,
|
||||
/// This window's true frame deltas, milliseconds.
|
||||
samples: Vec<f64>,
|
||||
/// Last seen window focus, `None` until the first read — so the first frame prints the state
|
||||
/// rather than a transition into it.
|
||||
/// Last seen window focus; `None` until the first read, which prints the state.
|
||||
focused: Option<bool>,
|
||||
/// Last seen occlusion, from `WindowOccluded`.
|
||||
occluded: bool,
|
||||
@@ -159,7 +107,7 @@ impl Stall {
|
||||
}
|
||||
}
|
||||
|
||||
/// Report the frame that just ran, then decide whether to block the next one.
|
||||
/// Reports the frame that just ran, then decides whether to block the next one.
|
||||
fn drive_stall(
|
||||
real: ProbeClock,
|
||||
virt: Res<Time<Virtual>>,
|
||||
@@ -175,8 +123,7 @@ fn drive_stall(
|
||||
stall.last = Some(at);
|
||||
stall.samples.push(wall_ms);
|
||||
|
||||
// Window state, printed on transition — the premise check: a run that was occluded (or never
|
||||
// focused) was measuring the OS, not us.
|
||||
// Window state, printed on transition: an occluded run measures the OS throttle.
|
||||
for o in occlusions.read() {
|
||||
if o.occluded != stall.occluded {
|
||||
stall.occluded = o.occluded;
|
||||
|
||||
@@ -1,47 +1,16 @@
|
||||
//! The **ribbon-trail census** (`WOW_TRAIL_CENSUS=<secs>[,<every>]`) — the instrument that turns
|
||||
//! *"the weapon particles trail behind while the lift goes up"* into a line of numbers.
|
||||
//! `WOW_TRAIL_CENSUS=<secs>[,<every>]`: per ribbon trail, its extent in the world and in its
|
||||
//! transport's frame. A trail's length is `host speed × edgeLifetime`; on a moving deck the edges
|
||||
//! ride the deck's pose, so a still rider on a vertical lift reads `world_dy ≈ 0`, where edges
|
||||
//! frozen in the world would read `deck speed × edgeLifetime`.
|
||||
//!
|
||||
//! A streak in a screenshot cannot say whether it is **right**. A weapon trail is *supposed* to
|
||||
//! smear — that is the whole effect, and its length is `host speed × edgeLifetime` by design. The
|
||||
//! bug the director reported is not "there is a streak", it is that the streak is drawn against
|
||||
//! the **world** while the host is standing still on a moving **deck**. Those two look identical
|
||||
//! in a still frame and are one subtraction apart in numbers:
|
||||
//! - `world_dy`: the strip's vertical extent in world space.
|
||||
//! - `deck_ext`: the extent in the transport's frame. [`benilla_world::ride_frame::ride_matrix`]
|
||||
//! is a translation and a yaw, so vertically it equals the world reading; it differs only on
|
||||
//! a turning transport.
|
||||
//!
|
||||
//! - **`world_dy`** — the strip's vertical extent in world space, and **the observable**. A rider
|
||||
//! standing still on a moving deck must draw a streak that is *short in world space*: with the
|
||||
//! ride frame the edges are stored on the deck and re-projected through its live pose, so the
|
||||
//! whole strip travels rigidly with the car and never stretches. Without it the edges are frozen
|
||||
//! in the world while the car climbs out from under them, and the strip grows to
|
||||
//! `deck speed × edgeLifetime`. So on a **vertical** lift the reading is simply: `world_dy ≈ 0`
|
||||
//! is right, `world_dy ≈ deck speed × edgeLifetime` is the bug (decision 1591's unbuilt gap,
|
||||
//! 1661's fix).
|
||||
//! - **`deck_ext`** — the strip's extent measured *inside the transport's frame*. **It cannot
|
||||
//! differ from the world reading in Y**: `A` is `translate · Rz` (see
|
||||
//! [`benilla_world::ride_frame::ride_matrix`]), and a yaw plus a translation leaves a vertical
|
||||
//! extent invariant. It earns its column only on a **turning** transport — a boat swinging
|
||||
//! through a bend, where the in-plane extent does differ — and stands as a self-check on a lift,
|
||||
//! where it must equal the world one. (This column was first written claiming `world_dy` large
|
||||
//! with `deck_dy` small was the finding; that pair is unreachable by construction, and saying so
|
||||
//! here is cheaper than the next reader re-deriving it.)
|
||||
//!
|
||||
//! ```text
|
||||
//! TRAIL_CENSUS t=85.0 trails=1 riding=1 stamped=1 deck=0xf12000104a00477a deckY=101.96 worst=0.00
|
||||
//! TRAIL 168v1 bone=24 edges=25 life=0.50s vis=- | world_dy= 0.00 world_ext= 0.25 deck_ext= 0.25
|
||||
//! ```
|
||||
//!
|
||||
//! Off a transport `deck=` reads `-` and the deck columns are the world ones, which is the control:
|
||||
//! this census must not change on the ground, where a streak *should* smear behind a moving host.
|
||||
//!
|
||||
//! Pair it with the checkout's probe identity (the `probe` skill) and a `.go` onto the Thunder Bluff lift:
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe<n> WOW_NOSOUND=1 \
|
||||
//! WOW_PROBE_CHAT=".go xyz -1286.2 189.7 132.0 1" \
|
||||
//! WOW_TRAIL_CENSUS="35,5" WOW_PROBE_EXIT_AT=75 cargo run -q -p benilla
|
||||
//! ```
|
||||
//!
|
||||
//! The repeat form is the point: one census says what the strip looked like at an instant, a
|
||||
//! series across the lift's travel says whether the deck-relative spread *grows with the ride*.
|
||||
//! Off a transport `deck=` reads `-` and the deck columns equal the world ones. A series across
|
||||
//! the lift's travel shows whether the spread grows. How to run it: `docs/CONTRIBUTING.md`,
|
||||
//! "Running it unattended".
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -64,15 +33,14 @@ impl Plugin for TrailCensusPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`TrailCensusPlugin`] state: when the next census fires, and how often after that (`0` = once).
|
||||
/// [`TrailCensusPlugin`] state; `every` of 0 fires once.
|
||||
#[derive(Resource)]
|
||||
struct TrailCensus {
|
||||
next: f32,
|
||||
every: f32,
|
||||
}
|
||||
|
||||
/// The axis-aligned extent of a point cloud — `(dx, dy, dz)`. Returns zeros for fewer than two
|
||||
/// points, which is a trail that has committed nothing yet rather than a trail that is perfect.
|
||||
/// The axis-aligned extent of a point cloud; zero below two points (nothing committed yet).
|
||||
fn extent(points: impl Iterator<Item = Vec3>) -> Vec3 {
|
||||
let (mut lo, mut hi) = (Vec3::splat(f32::MAX), Vec3::splat(f32::MIN));
|
||||
let mut n = 0u32;
|
||||
@@ -87,25 +55,17 @@ fn extent(points: impl Iterator<Item = Vec3>) -> Vec3 {
|
||||
hi - lo
|
||||
}
|
||||
|
||||
/// One line per live ribbon trail, worst deck-relative spread first — the order that puts the
|
||||
/// finding at the top when there are dozens of trails in a scene.
|
||||
/// One line per live ribbon trail, worst deck-relative vertical spread first.
|
||||
fn fire_trail_census(
|
||||
mut probe: ResMut<TrailCensus>,
|
||||
time: ProbeClock,
|
||||
// `ViewVisibility` is OPTIONAL and a trail never has one: a trail entity is spawned with a
|
||||
// `Transform` and nothing else, deliberately (`ribbons.rs`'s `fade` note — an emitter entity
|
||||
// carrying a `Visibility` would enlist in a second writer's query, decision 0025). Asking for
|
||||
// it as a required component matched ZERO trails in every scene, on the ground and on a lift
|
||||
// alike, and printed `trails=0` as if the client had spawned none.
|
||||
// Optional: a trail entity carries only a `Transform` (`ribbons.rs`'s `fade` note), so a
|
||||
// required `ViewVisibility` would match no trail.
|
||||
trails: Query<(Entity, &RibbonTrail, Option<&ViewVisibility>)>,
|
||||
// The deck's live pose. Disjoint from the trails above by construction — a transport is never
|
||||
// a ribbon — and read through the same `ride_matrix` the sim folds with, so the instrument
|
||||
// cannot drift from the thing it measures.
|
||||
// The deck's live pose, read through the same `ride_matrix` the sim uses.
|
||||
decks: Query<&GlobalTransform, Without<RibbonTrail>>,
|
||||
guids: Query<&crate::net::Guid>,
|
||||
// The STAMP itself, counted separately from the trails: "nothing is riding" and "something is
|
||||
// riding but its trail isn't folded" are different bugs with different fixes, and a census
|
||||
// that cannot tell them apart sends you to the wrong half of the system.
|
||||
// Ride-frame stamps, counted apart from the trails: a rider whose trail is not folded shows.
|
||||
stamped: Query<Entity, With<benilla_world::ride_frame::RideFrame>>,
|
||||
) {
|
||||
let now = time.elapsed_secs();
|
||||
@@ -124,8 +84,7 @@ fn fire_trail_census(
|
||||
for (entity, trail, vis) in &trails {
|
||||
let world: Vec<Vec3> = trail.strip_world().collect();
|
||||
let w_ext = extent(world.iter().copied());
|
||||
// Into the deck's frame. Off a transport this is the identity, so the two columns agree
|
||||
// and the line reads as its own control.
|
||||
// Into the deck's frame; off a transport the two columns agree.
|
||||
let deck = trail
|
||||
.deck()
|
||||
.and_then(|d| decks.get(d).ok().map(|gt| (d, gt)));
|
||||
|
||||
@@ -1,54 +1,17 @@
|
||||
//! The **unit-visual census** (`WOW_UNIT_VISUALS=<secs>[,<every>]`) — the instrument that closes
|
||||
//! B13's symptom: *"the invisible trigger NPC displays as a black block"*.
|
||||
//! `WOW_UNIT_VISUALS=<secs>[,<every>]`: per streamed entity, which visual its display got. A debug
|
||||
//! cube means no model we could load; an invisible trigger creature has a model that draws nothing
|
||||
//! in the reference, and should draw nothing here.
|
||||
//!
|
||||
//! Every site of that bug arrived as a screenshot of a black slab, and a screenshot cannot say
|
||||
//! which of the two things a slab is. Both look identical and they call for opposite fixes:
|
||||
//! - `cube`: the [`FallbackCube`] marker; `cubes=` on the summary counts them.
|
||||
//! - `meshes`: direct render children, the body's own batches; `cube=0 meshes=0` is a trigger.
|
||||
//! - `held`: attach slots on the skeleton ([`HeldAttached::spawned_slots`]), which `meshes`
|
||||
//! misses since an attached model is a grandchild.
|
||||
//! - `pick`: parts the mouseover ray-tests across [`crate::target::hover::pick_model_roots`], or
|
||||
//! fallback-box children; `pick=0` is selectable only by nameplate.
|
||||
//! - `PENDING`: a visual not built yet, as opposed to one that built nothing.
|
||||
//!
|
||||
//! - the entity's display named **no model we could load** — a gap of ours, and the cube is the
|
||||
//! debug signal that says so (it renders black rather than its authored red only because an
|
||||
//! unlit `StandardMaterial` catches no light in our scene);
|
||||
//! - the entity's display named a model which **draws nothing** — how an invisible trigger
|
||||
//! creature hides in the real client, and nothing should be drawn at all (decision 1403).
|
||||
//!
|
||||
//! One line per streamed entity says which, without an eye:
|
||||
//!
|
||||
//! ```text
|
||||
//! UVIS 0xF130003A7200013B Unit display=13069 d=6.4 cube=1 meshes=0 pick=1 held=[] Zandalarian Event Generator
|
||||
//! ```
|
||||
//!
|
||||
//! - **`cube`** is the [`FallbackCube`] marker — literally the arm that spawned it, not a guess
|
||||
//! from the picture. The headline `cubes=` count is the number that names the bug.
|
||||
//! - **`meshes`** counts the entity's spawned render children — the BODY's own batches. `cube=0
|
||||
//! meshes=0` is the *correct* reading for a trigger creature: attached, and drawing nothing.
|
||||
//! - **`held`** is the attach-slot list actually hanging off its skeleton
|
||||
//! ([`HeldAttached::spawned_slots`], the dress census's own reader). It is here because
|
||||
//! `meshes` counts *direct* children and an attached model is a grandchild, so a unit whose
|
||||
//! whole visible self is a held weapon — the Naxxramas `Unholy Axe`, an `InvisibleStalker` body
|
||||
//! with an axe in its hand — reads `meshes=0` whether the axe is there or not. That blind spot
|
||||
//! is what let decision 1656's defect sit under a green census; `held=[main]` is the line that
|
||||
//! says the weapon arrived.
|
||||
//! - **`pick`** counts the parts the **mouseover** would actually ray-test — every skinned part
|
||||
//! across the unit's chained model roots ([`crate::target::hover::pick_model_roots`]: the body,
|
||||
//! everything it wears, its mount), plus its fallback-box children where it has no skinned parts
|
||||
//! at all. `pick=0` means the unit cannot be hovered or clicked *at all* and only its name plate
|
||||
//! can select it — which is the second half of what the Naxxramas weapon mobs reported and what
|
||||
//! neither `meshes` nor `held` could say: `held=[main]` proves the axe is *attached*, never that
|
||||
//! it is *clickable* (decision 1658).
|
||||
//! - **`pending`** marks an entity whose visual has not been built yet (a model still streaming) —
|
||||
//! never to be confused with one that built nothing, which is the distinction the census exists
|
||||
//! to keep.
|
||||
//!
|
||||
//! Pair it with the checkout's probe identity (the `probe` skill) and a `.go` to the reported spot:
|
||||
//!
|
||||
//! ```text
|
||||
//! WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe<n> WOW_NOSOUND=1 \
|
||||
//! WOW_PROBE_CHAT=".go xyz -11847.0 1280.9 3.2 0" \
|
||||
//! WOW_UNIT_VISUALS="45,15" WOW_PROBE_EXIT_AT=90 cargo run -q -p benilla
|
||||
//! ```
|
||||
//!
|
||||
//! The repeat form is the load-race half: a census at 45 s and again at 60 s says whether a `cube`
|
||||
//! is a *verdict* or just an entity whose model had not landed yet — the same transient-vs-ratchet
|
||||
//! separation `WOW_GROUND_CENSUS` makes for the under-floor report.
|
||||
//! The repeat form tells a transient cube from a stuck one. How to run it: `docs/CONTRIBUTING.md`,
|
||||
//! "Running it unattended".
|
||||
|
||||
use benilla_assets::coords::bevy_to_wow;
|
||||
use benilla_protocol::EntityKind;
|
||||
@@ -60,9 +23,7 @@ use crate::names::NameCache;
|
||||
use crate::net::{Guid, NetEntity, SelfPlayer};
|
||||
use crate::target::hover::pick_model_roots;
|
||||
|
||||
/// How far from the body the census looks, in yards. Comfortably past the server's own creature
|
||||
/// visibility radius for a spot the operator has just `.go`ne to, so "nothing found" means the
|
||||
/// scene is empty rather than the window being tight.
|
||||
/// Census radius in yards, past the server's visibility radius; `WOW_UNIT_VISUALS_RADIUS` sets it.
|
||||
const DEFAULT_RADIUS: f32 = 120.0;
|
||||
|
||||
pub(crate) struct UnitVisualsPlugin;
|
||||
@@ -88,8 +49,7 @@ impl Plugin for UnitVisualsPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`UnitVisualsPlugin`] state: when the next census fires, how often after that (`0` = once), and
|
||||
/// how far from the body to look.
|
||||
/// [`UnitVisualsPlugin`] state; `every` of 0 fires once.
|
||||
#[derive(Resource)]
|
||||
struct UnitVisuals {
|
||||
next: f32,
|
||||
@@ -97,8 +57,7 @@ struct UnitVisuals {
|
||||
radius: f32,
|
||||
}
|
||||
|
||||
/// What the census reads per entity: identity, kind + display, pose, and whether its visual has
|
||||
/// been built yet.
|
||||
/// What the census reads per entity.
|
||||
type VisualQuery = (
|
||||
Entity,
|
||||
&'static Guid,
|
||||
@@ -106,20 +65,15 @@ type VisualQuery = (
|
||||
&'static Transform,
|
||||
Option<&'static Children>,
|
||||
Has<VisualAttached>,
|
||||
// The attach slots standing under this unit — the visual truth for everything that rides a
|
||||
// BONE rather than the entity, which `Children` above cannot see.
|
||||
// The attach slots on this unit's bones, which `Children` does not reach.
|
||||
Option<&'static HeldAttached>,
|
||||
// …and its mount, the third chained-model root the pick offers (decision 0441).
|
||||
// Its mount, the third chained-model root the pick offers.
|
||||
Option<&'static crate::entities::mount::MountChild>,
|
||||
// The descriptor, read for exactly one thing here: which half of the corpse fork a
|
||||
// `TYPEID_CORPSE` row is on (decision 1706). A corpse has no name-cache entry, so without this
|
||||
// every corpse row reads `Corpse … ?` and cannot say whether `meshes=0` means "the bone-pile
|
||||
// model is missing" or "the dressed body is missing" — opposite findings.
|
||||
// For a `TYPEID_CORPSE`, which has no name: bone pile or dressed body.
|
||||
Option<&'static crate::net::ObjectStore>,
|
||||
);
|
||||
|
||||
/// One line per streamed entity within [`UnitVisuals::radius`] of the body — cubes first, then
|
||||
/// everything else — under a summary line naming the count that matters.
|
||||
/// One line per streamed entity within [`UnitVisuals::radius`], cubes first, under a summary line.
|
||||
fn fire_unit_visuals(
|
||||
mut probe: ResMut<UnitVisuals>,
|
||||
time: ProbeClock,
|
||||
@@ -128,8 +82,8 @@ fn fire_unit_visuals(
|
||||
entities: Query<VisualQuery>,
|
||||
cubes: Query<(), With<FallbackCube>>,
|
||||
meshes: Query<(), With<Mesh3d>>,
|
||||
// The pick's own populations, read exactly as `update_hover` reads them: the skinned parts of
|
||||
// each chained model root (pass 1/2) and, for a unit with none, its fallback-box children.
|
||||
// The pick's populations as `update_hover` reads them: skinned parts per chained model root,
|
||||
// else fallback-box children.
|
||||
child_sets: Query<&Children>,
|
||||
rig_parts: Query<(), With<benilla_world::rig_palette::RigPart>>,
|
||||
box_parts: Query<(), With<benilla_world::interact::CreaturePickPart>>,
|
||||
@@ -149,8 +103,7 @@ fn fire_unit_visuals(
|
||||
};
|
||||
let radius2 = probe.radius * probe.radius;
|
||||
|
||||
// `(is_cube, sort key, line)`. Cubes sort to the top: they are the finding, and a busy city
|
||||
// census is long.
|
||||
// `(is_cube, sort key, line)`; cubes sort first.
|
||||
let mut rows: Vec<(bool, i64, String)> = Vec::new();
|
||||
let (mut cube_n, mut blank_n, mut pending_n) = (0u32, 0u32, 0u32);
|
||||
let mut cube_displays: Vec<u32> = Vec::new();
|
||||
@@ -166,7 +119,7 @@ fn fire_unit_visuals(
|
||||
cube |= cubes.contains(kid);
|
||||
mesh_n += u32::from(meshes.contains(kid));
|
||||
}
|
||||
// A cube IS a mesh child; count it as the cube it is so `meshes` reads as real geometry.
|
||||
// The cube is a mesh child; exclude it so `meshes` counts real geometry.
|
||||
mesh_n = mesh_n.saturating_sub(u32::from(cube));
|
||||
let display = net.display_id.unwrap_or(0);
|
||||
if !attached {
|
||||
@@ -179,10 +132,8 @@ fn fire_unit_visuals(
|
||||
} else if mesh_n == 0 {
|
||||
blank_n += 1;
|
||||
}
|
||||
// What the mouseover would test. The enumeration is the picker's own
|
||||
// ([`pick_model_roots`]) so the census can never report a set the pick doesn't use; the
|
||||
// fallback-box leg mirrors `update_hover`'s AABB path, which owns exactly the units the
|
||||
// skinned path does not.
|
||||
// What the mouseover tests, via the picker's own [`pick_model_roots`]; the fallback-box
|
||||
// leg mirrors `update_hover`'s AABB path for units with no skinned parts.
|
||||
let worn = held.map_or(&[][..], |h| h.spawned_slots().as_slice());
|
||||
let mut pick_n: u32 = pick_model_roots(entity, worn, mount.map(|m| m.0))
|
||||
.filter_map(|root| child_sets.get(root).ok())
|
||||
@@ -218,7 +169,7 @@ fn fire_unit_visuals(
|
||||
u8::from(cube),
|
||||
held.join(","),
|
||||
if attached { "attached" } else { "PENDING" },
|
||||
// The name, or — for a corpse, which never has one — which fork it took.
|
||||
// The name, or for a corpse which fork it took.
|
||||
match (net.kind, store) {
|
||||
(EntityKind::Corpse, Some(s)) if s.0.corpse_is_bones() => "<bones>",
|
||||
(EntityKind::Corpse, Some(_)) => "<corpse body>",
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
//! The two exclusive-`World` reflection dumps — the pair of one-shots that answer "what is
|
||||
//! resident right now, and what is it made of" by walking the live `World` itself: the bevy_ui
|
||||
//! node inventory ([`NodeProbePlugin`]) and the archetype census ([`EntityCensusPlugin`]).
|
||||
//! They share the shape (fire once at `t`, take `&mut World`, print with the ubiquitous
|
||||
//! plumbing components filtered out) as well as the question.
|
||||
//! Two one-shot dumps of the live `World`: the bevy_ui node inventory ([`NodeProbePlugin`]) and
|
||||
//! the archetype census ([`EntityCensusPlugin`]), each printed without the plumbing components.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// The bevy_ui node census (`WOW_NODE_PROBE=<secs>`): once, `t` seconds in, print one line per
|
||||
/// live `ComputedNode` entity — resolved rect (logical px, y-down), visibility, and the entity's
|
||||
/// full component list — the "who owns this rectangle" instrument for UI drawn OUTSIDE the
|
||||
/// FrameXML quad pass (the glue widgets, loading screen, overlays), which `WOW_UI_PROBE`'s quad
|
||||
/// dump can't see. Born hunting a phantom gold-bordered box over the mail window's send tab.
|
||||
/// `WOW_NODE_PROBE=<secs>`: once, one line per `ComputedNode` entity with its rect (logical px,
|
||||
/// y-down), visibility and components: the UI outside the FrameXML quad pass, which
|
||||
/// `WOW_UI_PROBE` cannot see.
|
||||
pub(crate) struct NodeProbePlugin;
|
||||
|
||||
impl Plugin for NodeProbePlugin {
|
||||
@@ -24,7 +19,7 @@ impl Plugin for NodeProbePlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`NodeProbePlugin`] state: the fire time and the once-latch.
|
||||
/// [`NodeProbePlugin`] state.
|
||||
#[derive(Resource)]
|
||||
struct NodeProbe {
|
||||
at: f32,
|
||||
@@ -45,10 +40,7 @@ fn fire_node_probe(world: &mut World) {
|
||||
.iter(world)
|
||||
.next()
|
||||
.map_or(1.0, bevy::window::Window::scale_factor);
|
||||
// **`UiGlobalTransform`, not `GlobalTransform`.** Bevy moved UI onto its own 2-D transform;
|
||||
// a UI entity no longer carries `GlobalTransform` at all, so the old query matched *nothing*
|
||||
// and this probe answered "0 nodes" — a plausible-looking reading rather than a failure —
|
||||
// for every run since that upgrade (found 2026-08-29, reaching for it to check a caret).
|
||||
// `UiGlobalTransform`: a bevy UI entity carries no `GlobalTransform`.
|
||||
let mut q = world.query::<(
|
||||
Entity,
|
||||
&bevy::ui::ComputedNode,
|
||||
@@ -59,7 +51,7 @@ fn fire_node_probe(world: &mut World) {
|
||||
.iter(world)
|
||||
.map(|(e, node, gt, vis)| (e, node.size(), gt.translation, vis.is_none_or(|v| v.get())))
|
||||
.collect();
|
||||
// A zero here is now itself the anomaly — the screen has no UI, or the query has rotted again.
|
||||
// Zero nodes is an anomaly: a bare screen or a query that no longer matches.
|
||||
if rows.is_empty() {
|
||||
warn!("node probe: NO ui nodes matched — the screen is bare, or this probe has rotted");
|
||||
}
|
||||
@@ -70,7 +62,7 @@ fn fire_node_probe(world: &mut World) {
|
||||
|it| {
|
||||
it.map(|c| c.name().shortname().to_string())
|
||||
.filter(|n| {
|
||||
// Drop the ubiquitous plumbing components — the signal is the rest.
|
||||
// Drop the plumbing components.
|
||||
!matches!(
|
||||
n.as_str(),
|
||||
"Transform"
|
||||
@@ -98,12 +90,8 @@ fn fire_node_probe(world: &mut World) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The entity census (`WOW_ENTITY_CENSUS=<secs>`, REAL seconds): once, `t` seconds in, print one
|
||||
/// line per live archetype — entity count plus its signal components, largest first — and a machine-readable
|
||||
/// summary. The "what IS the entity count made of" instrument: the standing HUD reads tens of
|
||||
/// thousands of entities, and every per-frame cost that scales with *residency* (0362's
|
||||
/// change-tick sweeps, transform propagation, render extraction) is only attributable once
|
||||
/// residency itself has names. Born with the cost-ledger campaign.
|
||||
/// `WOW_ENTITY_CENSUS=<secs>` (real seconds): once, one line per archetype, largest first, with
|
||||
/// its entity count and signal components, then a summary.
|
||||
pub(crate) struct EntityCensusPlugin;
|
||||
|
||||
impl Plugin for EntityCensusPlugin {
|
||||
@@ -117,7 +105,7 @@ impl Plugin for EntityCensusPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`EntityCensusPlugin`] state: the fire time and the once-latch.
|
||||
/// [`EntityCensusPlugin`] state.
|
||||
#[derive(Resource)]
|
||||
struct EntityCensus {
|
||||
at: f32,
|
||||
@@ -127,15 +115,12 @@ struct EntityCensus {
|
||||
/// Archetype lines the census prints; everything smaller folds into the summary's `other_n`.
|
||||
const ENTITY_CENSUS_ROWS: usize = 60;
|
||||
|
||||
/// Signal components shown per archetype line — enough to name what the entities are without
|
||||
/// drowning the line in a 30-component render archetype.
|
||||
/// Signal components shown per archetype line.
|
||||
const ENTITY_CENSUS_COMPS: usize = 14;
|
||||
|
||||
fn fire_entity_census(world: &mut World) {
|
||||
{
|
||||
// REAL seconds, not virtual: the census is timed to compose with `WOW_LIVE_FPS_AT`
|
||||
// (also real), and virtual time lags real by the load stalls — a virtual-timed one-shot
|
||||
// scheduled "just before sampling" fires after the probe has already exited.
|
||||
// Real seconds, to compose with `WOW_LIVE_FPS_AT`; virtual time lags by the load stalls.
|
||||
let time = world.resource::<Time<bevy::time::Real>>().elapsed_secs();
|
||||
let probe = world.resource::<EntityCensus>();
|
||||
if probe.fired || time < probe.at {
|
||||
@@ -144,12 +129,7 @@ fn fire_entity_census(world: &mut World) {
|
||||
}
|
||||
world.resource_mut::<EntityCensus>().fired = true;
|
||||
|
||||
// The anchor split (0732 slice A's premise check). `RigAnchor` leaves are the single largest
|
||||
// archetype in the scene — 53 % of all entities — and slice A's claim is that most of them ride
|
||||
// nothing. "Rides nothing" is directly observable: an anchor whose entity has no `Children` is
|
||||
// hosting no attachment, no emitter, no ribbon, no card. Counted here rather than inferred from
|
||||
// the model's bone sources, because the model says what COULD attach and the world says what
|
||||
// DID.
|
||||
// `RigAnchor`s with no `Children` host nothing: no attachment, emitter, ribbon or card.
|
||||
{
|
||||
let mut q = world.query_filtered::<Option<&bevy::prelude::Children>, bevy::prelude::With<benilla_world::rig_anim::RigAnchor>>();
|
||||
let (mut total, mut childless) = (0u32, 0u32);
|
||||
@@ -178,14 +158,12 @@ fn fire_entity_census(world: &mut World) {
|
||||
.filter_map(|id| components.get_info(*id))
|
||||
.map(|c| c.name().shortname().to_string())
|
||||
.collect();
|
||||
// Whether this archetype sits in the render-visibility population: bevy's
|
||||
// `check_visibility` sweeps every `ViewVisibility` row once PER ACTIVE CAMERA,
|
||||
// so `vis=y` rows are the ones a second camera (booth) re-bills the frame for.
|
||||
// `vis=y`: bevy's `check_visibility` sweeps these rows once per active camera.
|
||||
let in_vis_population = full.iter().any(|n| n == "ViewVisibility");
|
||||
let signal: Vec<String> = full
|
||||
.iter()
|
||||
.filter(|n| {
|
||||
// Drop the ubiquitous plumbing components — the signal is the rest.
|
||||
// Drop the plumbing components.
|
||||
!matches!(
|
||||
n.as_str(),
|
||||
"Transform"
|
||||
@@ -199,9 +177,7 @@ fn fire_entity_census(world: &mut World) {
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
// A bare transform node has no signal left after the filter — and two such
|
||||
// archetypes differing only in plumbing (Children vs not) would print as identical
|
||||
// rows. For those, the plumbing IS the signal: print the full list.
|
||||
// With one signal component or none, the plumbing tells archetypes apart: print it all.
|
||||
let names = if signal.len() <= 1 { full } else { signal };
|
||||
let shown = names.len().min(ENTITY_CENSUS_COMPS);
|
||||
let more = names.len() - shown;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,14 @@
|
||||
//! The `waterfx` capture viewer — the foam **instrument** (decision 0022: see it before tuning
|
||||
//! it). A server-less synthetic rig: one dummy wading unit over a synthetic water footprint
|
||||
//! (a real 4.1667-yd wet-cell lattice, so patch building and bank clipping run for real) with a
|
||||
//! flat backdrop for contrast, driven through the NORMAL emitter path — nothing here bypasses the
|
||||
//! shipped systems; the rig only supplies a unit, water, and motion.
|
||||
//! The `waterfx` capture viewer for water foam: a server-less rig of one wading dummy over a
|
||||
//! synthetic 4.1667-yd wet-cell lattice with a flat backdrop, driven through the shipped emitter.
|
||||
//!
|
||||
//! `WOW_CAPTURE=waterfx` + knobs: `WOW_WFX_MODE` (`ring`|`wake`|`turn`), `WOW_WFX_SPEED` (yd/s),
|
||||
//! `WOW_WFX_HEAD` (wake heading, WoW degrees, 0 = +X — point it along a real bank),
|
||||
//! `WOW_WFX_AGE` (s of motion before the shot), `WOW_WFX_DEPTH` (yd below the surface; > ~0.8
|
||||
//! also exercises the step-in one-shot), camera `WOW_WFX_AZ`/`EL`/`DIST`. Not a golden scenario —
|
||||
//! output depends on the knobs.
|
||||
//! `WOW_CAPTURE=waterfx` with knobs: `WOW_WFX_MODE` (`ring`|`wake`|`turn`), `WOW_WFX_SPEED` (yd/s),
|
||||
//! `WOW_WFX_HEAD` (wake heading, WoW degrees, 0 = +X), `WOW_WFX_AGE` (seconds of motion before the
|
||||
//! shot), `WOW_WFX_DEPTH` (yd below the surface; past ~0.8 also fires the step-in one-shot), and
|
||||
//! camera `WOW_WFX_AZ`/`EL`/`DIST`. Not a golden scenario.
|
||||
//!
|
||||
//! **`WOW_WFX_AT=x,y,z` wades the dummy in the REAL world instead** (`z` = the liquid surface
|
||||
//! height there, which `benilla-formats --example water_here` prints). The synthetic lattice and
|
||||
//! backdrop stand down and the rig wades in the streamed ADT/WMO liquid at that pin — which is the
|
||||
//! only way to see the two things a synthetic square of water cannot show: how a patch **clips at a
|
||||
//! real bank**, and how it **sorts against the neighbouring water chunks** (B348 — one square of
|
||||
//! water has no neighbour to be painted over by). `WOW_MAP` picks the map.
|
||||
//! `WOW_WFX_AT=x,y,z` wades the dummy in the real streamed liquid instead (`z` the surface height,
|
||||
//! which `benilla-formats --example water_here` prints; `WOW_MAP` picks the map), the only rig that
|
||||
//! shows bank clipping and sorting against neighbouring water chunks.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -29,34 +22,32 @@ use benilla_world::liquid::{FoamPatch, WaterChunkInfo};
|
||||
/// Which foam behaviour the viewer exercises.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub(crate) enum WfxMode {
|
||||
/// Standing unit → the pulsing RING.
|
||||
/// Standing unit: the pulsing ring.
|
||||
Ring,
|
||||
/// Unit translating in WoW +X → the trailing WAKE (ends at the rig centre).
|
||||
/// Unit translating along the heading: the trailing wake, ending at the rig centre.
|
||||
Wake,
|
||||
/// Unit turning in place → full-size RINGs (the `& 0x30` state).
|
||||
/// Unit turning in place: full-size rings (the `& 0x30` state).
|
||||
Turn,
|
||||
}
|
||||
|
||||
/// The `waterfx` viewer request (built by [`crate::capture`] from env knobs). Its presence turns
|
||||
/// on the rig systems below.
|
||||
/// The `waterfx` viewer request, built by [`crate::capture`] from the knobs; its presence turns on
|
||||
/// the rig.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct WaterFxView {
|
||||
pub(crate) mode: WfxMode,
|
||||
/// Translation speed for [`WfxMode::Wake`] (yd/s, along [`Self::heading`]).
|
||||
pub(crate) speed: f32,
|
||||
/// Which way a [`WfxMode::Wake`] walks, as a WoW yaw in radians (0 = +X). No shoreline in the
|
||||
/// game is axis-aligned, so a wake that can only run along +X can never be laid **along** a
|
||||
/// bank — the one arrangement where a trail's whole length ties against the waterline at once.
|
||||
/// Which way a [`WfxMode::Wake`] walks, as a WoW yaw in radians (0 = +X), to lay it along a
|
||||
/// bank.
|
||||
pub(crate) heading: f32,
|
||||
/// Seconds the unit moves/stands before the shot (foam accumulates).
|
||||
/// Seconds the unit moves or stands before the shot.
|
||||
pub(crate) age: f32,
|
||||
/// Rig centre in raw WoW coords `(x, y, surface_z)` — where the unit ends up at shot time.
|
||||
/// Rig centre in WoW coords `(x, y, surface_z)`, where the unit is at shot time.
|
||||
pub(crate) center: [f32; 3],
|
||||
/// Feet depth below the surface (yd) — must land inside the wading gate.
|
||||
/// Feet depth below the surface (yd); must land inside the wading gate.
|
||||
pub(crate) depth: f32,
|
||||
/// Wade in the **real** streamed liquid at [`Self::center`] (`WOW_WFX_AT`) rather than over the
|
||||
/// synthetic lattice: no backdrop, no fixture water, just the dummy. A real shoreline is the
|
||||
/// only rig that exercises bank clipping and multi-chunk sorting.
|
||||
/// Wade in the real streamed liquid at [`Self::center`] (`WOW_WFX_AT`): no backdrop, no
|
||||
/// fixture water.
|
||||
pub(crate) live: bool,
|
||||
}
|
||||
|
||||
@@ -64,12 +55,11 @@ pub(crate) struct WaterFxView {
|
||||
#[derive(Component)]
|
||||
pub(crate) struct WaterFxDummy;
|
||||
|
||||
/// The MCLQ wet-cell edge (yd) — the synthetic lattice mirrors the real liquid granularity.
|
||||
/// The MCLQ wet-cell edge (yd).
|
||||
const CELL: f32 = 33.333_332 / 8.0;
|
||||
|
||||
/// Once the capture harness arms the scene, stand up the rig: a dark backdrop plane just under
|
||||
/// the surface, a synthetic wet-cell lattice (12×12 cells ≈ 50 yd square), and one wading dummy.
|
||||
/// Sets [`FxViewState::attached_at`] as the age-clock zero.
|
||||
/// Once the scene is armed, stands up the backdrop, a 12x12-cell lattice (~50 yd square) and the
|
||||
/// dummy, and sets [`FxViewState::attached_at`] as the age clock's zero.
|
||||
pub(crate) fn spawn(
|
||||
mut commands: Commands,
|
||||
view: Option<Res<WaterFxView>>,
|
||||
@@ -94,8 +84,7 @@ pub(crate) fn spawn(
|
||||
return;
|
||||
}
|
||||
|
||||
// A big flat mid-gray "water body" backdrop 0.15 yd under the surface, so the additive foam
|
||||
// reads against a stable tone (the real liquid shader is beside the point here).
|
||||
// A flat backdrop 0.15 yd under the surface, so the additive foam reads against a stable tone.
|
||||
commands.spawn((
|
||||
WaterFxDummy,
|
||||
Mesh3d(meshes.add(Plane3d::default().mesh().size(120.0, 120.0).build())),
|
||||
@@ -107,9 +96,7 @@ pub(crate) fn spawn(
|
||||
Transform::from_translation(wow_to_bevy([cx, cy, surf - 0.15])),
|
||||
));
|
||||
|
||||
// The synthetic water chunk: a 12×12 grid of wet cells centred on the rig, flat at the surface
|
||||
// — the same components the terrain streamer attaches to real liquid, so the foam emitter and
|
||||
// patch builder run the shipped path.
|
||||
// The components the terrain streamer attaches to real liquid, so the shipped path runs.
|
||||
let n = 12;
|
||||
let half = n as f32 * CELL * 0.5;
|
||||
let (x0, y0) = (cx - half, cy - half);
|
||||
@@ -122,8 +109,7 @@ pub(crate) fn spawn(
|
||||
commands.spawn((
|
||||
WaterFxDummy,
|
||||
WaterChunkInfo::new(
|
||||
// The fixture stands in for an outdoor lake: ADT-sourced still water, so the
|
||||
// `liquid_at` delegation answers it for an outdoors subject.
|
||||
// An outdoor lake: ADT still water, which `liquid_at` answers outdoors.
|
||||
benilla_world::liquid::LiquidSource::AdtChunk,
|
||||
benilla_formats::LiquidKind::Still,
|
||||
[n + 1, n + 1],
|
||||
@@ -149,9 +135,8 @@ pub(crate) fn spawn(
|
||||
);
|
||||
}
|
||||
|
||||
/// The wading dummy: a plain streamed-unit shape (no display id, visual pre-attached so the entity
|
||||
/// subsystem never gives it a fallback cube over the foam). A wake starts far enough back that its
|
||||
/// trail ENDS at the rig centre after `age` seconds.
|
||||
/// The wading dummy: a streamed unit with no display and its visual pre-attached, so it gets no
|
||||
/// fallback cube. A wake starts far enough back to end at the rig centre after `age` seconds.
|
||||
fn spawn_dummy(commands: &mut Commands, view: &WaterFxView, cx: f32, cy: f32, surf: f32) {
|
||||
let (start_x, start_y) = if view.mode == WfxMode::Wake {
|
||||
let back = view.speed * view.age;
|
||||
@@ -169,16 +154,13 @@ fn spawn_dummy(commands: &mut Commands, view: &WaterFxView, cx: f32, cy: f32, su
|
||||
display_id: None,
|
||||
scale: 1.0,
|
||||
},
|
||||
// Stated at the spawn, not left to `entities::publish_world_units`: the reconciler runs
|
||||
// between the wire drain and the rest of the frame, and this fixture spawns in
|
||||
// `WorldStage::Present` — so waiting for it would cost the rig its first frame of foam
|
||||
// and shift every ripple's age by one step for the whole aged capture.
|
||||
// Set here, not by `entities::publish_world_units`, which runs before this
|
||||
// `WorldStage::Present` spawn and would cost the rig its first frame of foam.
|
||||
benilla_world::world_unit::WorldUnit {
|
||||
wades: true,
|
||||
scale: 1.0,
|
||||
height: crate::entities::CollisionHeight::default().0,
|
||||
// The fixture is a foam rig, not a scene body: it has no model box and the capture is
|
||||
// outdoors, where the exterior cull stands down anyway. `None` = don't decide.
|
||||
// No model box; outdoors the exterior cull stands down anyway.
|
||||
bound: None,
|
||||
},
|
||||
crate::entities::VisualAttached,
|
||||
@@ -186,8 +168,8 @@ fn spawn_dummy(commands: &mut Commands, view: &WaterFxView, cx: f32, cy: f32, su
|
||||
));
|
||||
}
|
||||
|
||||
/// Drive the dummy each frame: translate (wake) or spin (turn) until the age elapses; the foam
|
||||
/// emitter reads the motion through its normal velocity/yaw proxies.
|
||||
/// Moves the dummy (wake) or spins it (turn) until the age elapses; the emitter reads the motion
|
||||
/// through its normal velocity and yaw proxies.
|
||||
pub(crate) fn drive(
|
||||
view: Option<Res<WaterFxView>>,
|
||||
state: Option<Res<FxViewState>>,
|
||||
@@ -206,8 +188,8 @@ pub(crate) fn drive(
|
||||
for mut t in &mut units {
|
||||
match view.mode {
|
||||
WfxMode::Wake => {
|
||||
// bevy = (−wow.y, wow.z, −wow.x), so a WoW heading (cos h, sin h) walks the dummy
|
||||
// along Bevy (−sin h, 0, −cos h).
|
||||
// bevy = (-wow.y, wow.z, -wow.x), so WoW heading (cos h, sin h) is Bevy
|
||||
// (-sin h, 0, -cos h).
|
||||
let step = view.speed * time.delta_secs();
|
||||
t.translation.x -= step * view.heading.sin();
|
||||
t.translation.z -= step * view.heading.cos();
|
||||
|
||||
@@ -25,9 +25,8 @@ use select::{Mode, Special};
|
||||
mod twist;
|
||||
pub(crate) use twist::{wrap_pi, BodyTwist};
|
||||
|
||||
/// Parks an off-frustum rig's pose while its clocks and events run, as the reference does not
|
||||
/// tick one (`0x683dd0`). Where they differ: parked rigs here still draw, and every parked unit
|
||||
/// stays audible, where the reference keeps only `MORE_AUDIBLE` creatures audible off-screen.
|
||||
/// Parks an off-frustum rig's pose while its clocks run, as the reference does not tick one
|
||||
/// (`0x683dd0`); a parked unit's events fire only for a `MORE_AUDIBLE` template.
|
||||
mod lod;
|
||||
|
||||
/// Wielded `(item class, subclass)` per hand as worn (`GetWeapon(slot, 1)`, `0x605e30`, what the
|
||||
|
||||
@@ -67,7 +67,8 @@ pub(super) struct ShardEmitter {
|
||||
path: String,
|
||||
/// `DYNAMICOBJECT_RADIUS`: the wire radius is the spread (`0x6ebad0`).
|
||||
radius: f32,
|
||||
/// Shards per second: `CharParamOne` times the graphics-quality factor, here its 1.0 maximum.
|
||||
/// Shards per second: `CharParamOne` times the `spellEffectLevel` factor, 1.0 at its
|
||||
/// default "2".
|
||||
rate: f32,
|
||||
/// Fractional emissions carried between frames.
|
||||
accum: f32,
|
||||
|
||||
+565
-1361
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,6 @@
|
||||
//! The per-frame wire→ECS bridge systems: [`apply_net_updates`] drains the inbound
|
||||
//! [`SessionEvent`] channel through the handler table, and [`tag_self_player`] marks our own
|
||||
//! streamed entity. The parent module owns the channel/type surface and, since 2327, every
|
||||
//! packet's handler is a subsystem's own or the bridge's own ([`super::session`],
|
||||
//! [`super::objects`], [`super::world`]); nothing is applied here.
|
||||
//! streamed entity. Nothing is applied here: every packet's handler lives with its subsystem.
|
||||
|
||||
use benilla_protocol::SessionEvent;
|
||||
use bevy::prelude::*;
|
||||
@@ -14,27 +12,17 @@ mod seam_tests;
|
||||
|
||||
// ── The per-frame bridge systems ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The drain: take this frame's events off the channel and run each through the handler table
|
||||
/// ([`super::handlers`]) **in wire order**, one after another, each handler a one-shot system
|
||||
/// over this exclusive world with its commands applied before the next (decisions 2305, 2306,
|
||||
/// 2327). One frame, packet order, before anything else in
|
||||
/// [`benilla_world::schedule::WorldStage::Net`] runs: the property 0006 built and 2265 said
|
||||
/// every split must keep.
|
||||
/// Runs this frame's events through the handler table ([`super::handlers`]) in wire order, each
|
||||
/// handler's commands applied before the next, before anything else in
|
||||
/// [`benilla_world::schedule::WorldStage::Net`] runs.
|
||||
pub(crate) fn apply_net_updates(world: &mut World) {
|
||||
let events: Vec<SessionEvent> = world.resource::<NetEvents>().0.try_iter().collect();
|
||||
super::handlers::dispatch(world, events);
|
||||
}
|
||||
|
||||
/// Tag our own player's streamed entity with [`SelfPlayer`] once we know our guid — by matching the
|
||||
/// [`Guid`] component against [`SelfGuid`]. The renderer skips this entity (the controller owns our
|
||||
/// avatar); the controller reads its transform to take control. Done as its own pass (rather than at
|
||||
/// spawn) so it's robust to the order our guid and our create packet arrive in.
|
||||
///
|
||||
/// The controller's animation motion source (`MovementState`) rides the tag: a cross-map worldport
|
||||
/// despawns every tracked entity — our avatar included — and the new map re-streams it, so any
|
||||
/// per-entity state attached only at the one-shot take-control edge is lost on transfer. That was
|
||||
/// the ".tele to another continent" bug: the re-tagged avatar had no `MovementState`, the anim
|
||||
/// selector read it as stationary, and it slid around in the Stand pose.
|
||||
/// Tags our own streamed entity with [`SelfPlayer`] by matching [`Guid`] against [`SelfGuid`].
|
||||
/// A pass of its own, not at spawn, so either arrival order of our guid and our create block works;
|
||||
/// a cross-map worldport re-streams the avatar and it is tagged again.
|
||||
pub(super) fn tag_self_player(
|
||||
mut commands: Commands,
|
||||
self_guid: Res<SelfGuid>,
|
||||
@@ -45,9 +33,7 @@ pub(super) fn tag_self_player(
|
||||
};
|
||||
for (entity, guid) in &untagged {
|
||||
if guid.0 == me {
|
||||
// Identity only. The controller-fed [`crate::creature_anim::MovementState`] used to
|
||||
// ride along here, but it belongs to whichever body we are *steering*, which is not
|
||||
// always this one — `player::embody` owns it now (decision 1281).
|
||||
// Identity only: `MovementState` belongs to the body we steer (`player::embody`).
|
||||
commands.entity(entity).insert(SelfPlayer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
//! **Every packet's handler sees what the packets before it did** — the property 2306 measured
|
||||
//! across the migration's seam (a run boundary was a command flush mid-frame) and the one the
|
||||
//! whole dispatch rests on now that every packet is its own handler with its commands applied
|
||||
//! before the next (decision 2327). The first test drives the real drain on the built client
|
||||
//! over one frame two ways and compares; the rest are 1478's speed-staging laws, which used to
|
||||
//! be unit tests over a staging map and are now the same laws over consecutive packets.
|
||||
//! Every packet's handler sees what the packets before it did: the first test drives the real
|
||||
//! drain over one frame two ways and compares, the rest pin the speed-change ordering laws over
|
||||
//! consecutive packets.
|
||||
|
||||
use benilla_protocol::field::{FIELD_UNIT_HEALTH, FIELD_UNIT_LEVEL, FIELD_UNIT_MAXHEALTH};
|
||||
use benilla_protocol::messages::{ObjectType, SpeedKind, SplineMode};
|
||||
@@ -68,7 +65,7 @@ fn updates() -> Vec<SessionEvent> {
|
||||
]
|
||||
}
|
||||
|
||||
/// A packet of another subsystem's — any would do; the mailbox's needs no open window.
|
||||
/// A packet of another subsystem's; the mailbox's needs no open window.
|
||||
fn claimed() -> SessionEvent {
|
||||
SessionEvent::NextMailTime { seconds: -86400.0 }
|
||||
}
|
||||
@@ -186,13 +183,9 @@ fn speeds_after(events: Vec<SessionEvent>) -> Option<MoveSpeeds> {
|
||||
world.get::<UnitSpeeds>(e).map(|s| s.0)
|
||||
}
|
||||
|
||||
/// **B213, pinned at the drain.** vmangos's `HandleMoveWorldportAckOpcode` sends the self create
|
||||
/// block (`Map::Add` → `SendInitSelf`, still carrying the mount's 11.2 yd/s) and then, three
|
||||
/// statements later, strips the mount because the destination map forbids one — so
|
||||
/// `SMSG_FORCE_RUN_SPEED_CHANGE` 7.0 rides the same tick. In wire order the change wins; before
|
||||
/// 1478 the create's *deferred* `UnitSpeeds` insert landed last and `.tele` into BWL left the
|
||||
/// avatar running at mount speed on foot. Under a handler per packet the create's insert has
|
||||
/// landed before the change's handler runs, and the change edits the live component.
|
||||
/// A worldport sends the self create block still at mount speed (`MovementHandler.cpp:115`), then
|
||||
/// dismounts where the map forbids a mount (`MovementHandler.cpp:183`) in the same tick; in wire
|
||||
/// order the force change wins.
|
||||
#[test]
|
||||
fn a_force_change_beats_a_create_from_the_same_frame() {
|
||||
let mounted = MoveSpeeds {
|
||||
@@ -215,8 +208,7 @@ fn a_force_change_beats_a_create_from_the_same_frame() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The reverse order is just as much the wire's order: a create block is the server's newest
|
||||
/// snapshot of the mover, so one arriving *after* a change replaces it whole.
|
||||
/// A create block is the server's newest snapshot of the mover, so one after a change replaces it.
|
||||
#[test]
|
||||
fn a_create_after_a_change_replaces_it() {
|
||||
let on_foot = MoveSpeeds {
|
||||
@@ -258,8 +250,7 @@ fn a_change_on_a_live_mover_keeps_its_other_slots() {
|
||||
assert_eq!(s.turn_rate, on_foot.turn_rate);
|
||||
}
|
||||
|
||||
/// A change with nowhere to land — a mover whose create carried no movement block — invents no
|
||||
/// speed set: nothing is inserted for a set that never existed.
|
||||
/// A change on a mover whose create carried no movement block inserts nothing.
|
||||
#[test]
|
||||
fn a_change_with_no_speed_set_invents_none() {
|
||||
assert!(speeds_after(vec![create_with_speeds(None), force_run(7.0)]).is_none());
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
//! The inbound **handler table** (decision 2305) — the reference's own shape for the wire's
|
||||
//! arrival side, and the cut 2265 §A1 asked for between the net bridge and the game windows.
|
||||
//!
|
||||
//! The real client keeps an opcode → handler table inside `NetClient` (`+0x74`, 828 slots; 387
|
||||
//! registrations by 37 subsystem clusters) and its dispatcher `0x537aa0` knows
|
||||
//! none of them: it looks the opcode up and calls what it finds, **in packet order**, discarding an
|
||||
//! unregistered opcode in silence. Here the table is [`NetHandlers`]: a [`SessionEventKind`] →
|
||||
//! handlers map that every subsystem fills for itself through [`NetHandlerApp::net_handler`], and
|
||||
//! the drain ([`super::apply_net_updates`]) is exclusive over the world so the handlers — ordinary
|
||||
//! systems taking `In<SessionEvent>`, registered as one-shots — run one after another in the
|
||||
//! order the packets arrived, each seeing what the one before it did.
|
||||
//!
|
||||
//! **Why not one typed `Message<T>` per family with a reader system each** (2265's sketch): a
|
||||
//! reader per family runs *after* the whole drain, so two families' packets interleaved in one
|
||||
//! frame are handled family by family, not in packet order — a chat line and a combat-log line
|
||||
//! that arrived in one drain would swap. The table keeps the one property 2265 said must survive
|
||||
//! any split: one frame, packet order, before anything else runs.
|
||||
//!
|
||||
//! **The table is the whole dispatch** (since 2327). Every kind has at least one handler, read
|
||||
//! off the built client by `every_session_event_kind_has_one_owner`; the drain's own match, the
|
||||
//! runs it took across the seam and the broadcast list (2305, 2306, 2326) are gone.
|
||||
//! The inbound handler table, the reference's shape: `NetClient` holds an opcode → handler table
|
||||
//! (`+0x74`, 828 slots) and its dispatcher `0x537aa0` calls each packet's handler in packet order,
|
||||
//! dropping an unregistered opcode in silence. Here each subsystem registers one-shot systems per
|
||||
//! [`SessionEventKind`] through [`NetHandlerApp::net_handler`], and the exclusive drain runs them
|
||||
//! in packet order, each seeing what the one before it did.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -26,18 +10,17 @@ use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::ecs::system::SystemId;
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// One registered handler: the one-shot system and the name it registers under (for the census).
|
||||
/// One registered handler: the one-shot system and its name for the census.
|
||||
type Handler = (SystemId<In<SessionEvent>>, &'static str);
|
||||
|
||||
/// The table. Filled at plugin build by every subsystem that answers a packet; read by the drain.
|
||||
/// The table, filled at plugin build and read by the drain.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct NetHandlers {
|
||||
by_kind: HashMap<SessionEventKind, Vec<Handler>>,
|
||||
}
|
||||
|
||||
impl NetHandlers {
|
||||
/// Every kind with a handler, with the handlers' names in registration order — the owner
|
||||
/// test's view of the table.
|
||||
/// Every kind with a handler, and the handlers' names in registration order.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn census(&self) -> std::collections::BTreeMap<SessionEventKind, Vec<&'static str>> {
|
||||
self.by_kind
|
||||
@@ -46,10 +29,8 @@ impl NetHandlers {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Run **every** registered handler once with `ev` — whatever kind it registered for — and
|
||||
/// name the ones that could not run. A handler matches on its own kind and ignores the rest,
|
||||
/// so what this exercises is the part a wrong-kind event still reaches: the system's
|
||||
/// parameters being fetched from this world.
|
||||
/// Runs every handler once with `ev`, whatever its kind, and names those that could not run:
|
||||
/// a wrong-kind event still fetches the system's parameters.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn probe(&self, world: &mut World, ev: &SessionEvent) -> Vec<String> {
|
||||
let mut failed = Vec::new();
|
||||
@@ -68,8 +49,7 @@ impl NetHandlers {
|
||||
self.by_kind.entry(kind).or_default().push(handler);
|
||||
}
|
||||
|
||||
/// Run every handler registered for the event's kind, in registration order, each with the
|
||||
/// event (cloned for all but the last).
|
||||
/// Runs the event's handlers in registration order.
|
||||
fn run(&self, world: &mut World, ev: SessionEvent) {
|
||||
let Some(list) = self.by_kind.get(&SessionEventKind::from(&ev)) else {
|
||||
return;
|
||||
@@ -86,15 +66,13 @@ impl NetHandlers {
|
||||
|
||||
fn call(world: &mut World, id: SystemId<In<SessionEvent>>, name: &str, ev: SessionEvent) {
|
||||
if let Err(e) = world.run_system_with(id, ev) {
|
||||
// A registered handler that cannot run is a bug in the registering plugin (a missing
|
||||
// resource, most likely), never the wire's fault — loud, so the smoke gate sees it.
|
||||
// A plugin bug (most likely a missing resource), logged loud so the smoke gate sees it.
|
||||
error!("net: handler `{name}` did not run: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Registering a packet handler from a plugin: `app.net_handler(SessionEventKind::X, on_x)`,
|
||||
/// where `on_x` is an ordinary system taking `In<SessionEvent>` and whatever it needs. Several
|
||||
/// handlers may answer one kind; they run in registration order.
|
||||
/// `app.net_handler(SessionEventKind::X, on_x)` registers a system taking `In<SessionEvent>`;
|
||||
/// several handlers on one kind run in registration order.
|
||||
pub(crate) trait NetHandlerApp {
|
||||
fn net_handler<M>(
|
||||
&mut self,
|
||||
@@ -118,8 +96,7 @@ impl NetHandlerApp for App {
|
||||
}
|
||||
}
|
||||
|
||||
/// One drain's dispatch, **in wire order**: every event runs its handlers in place, each
|
||||
/// handler's commands applied before the next event's.
|
||||
/// One drain's dispatch in wire order, each handler's commands applied before the next event.
|
||||
pub(crate) fn dispatch(world: &mut World, events: Vec<SessionEvent>) {
|
||||
let handlers = world.remove_resource::<NetHandlers>().unwrap_or_default();
|
||||
for ev in events {
|
||||
@@ -132,7 +109,7 @@ pub(crate) fn dispatch(world: &mut World, events: Vec<SessionEvent>) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// What ran, in order — a handler's name and the event it saw.
|
||||
/// What ran, in order.
|
||||
#[derive(Resource, Default)]
|
||||
struct Log(Vec<String>);
|
||||
|
||||
@@ -204,9 +181,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **Every kind has a handler**, read off the built client. A kind with none is a packet the
|
||||
/// client decodes and then drops on the floor — the reference discards an unregistered
|
||||
/// opcode in silence; this says so at test time.
|
||||
/// A kind with no handler is a decoded packet dropped in silence, as the reference does.
|
||||
#[test]
|
||||
fn every_session_event_kind_has_one_owner() {
|
||||
let mut app = crate::game_plugins::schedule_tests::headless_client();
|
||||
@@ -224,11 +199,8 @@ mod tests {
|
||||
assert!(unowned.is_empty(), "{}", unowned.join("\n"));
|
||||
}
|
||||
|
||||
/// **Every registered handler can run on the built client.** The dispatch match's parameters
|
||||
/// were fetched every frame from the first, so a resource nobody inserted failed at boot; a
|
||||
/// handler's are fetched when its packet arrives, which for a rare packet is never in a
|
||||
/// smoke run. This fetches them all, once, on the headless client — every plugin built and
|
||||
/// finished — by running each handler with an event of a kind it ignores.
|
||||
/// A handler's parameters are fetched only when its packet arrives, which for a rare packet is
|
||||
/// never in a smoke run; this fetches them all once, with an event each handler ignores.
|
||||
#[test]
|
||||
fn every_registered_handler_can_run_on_the_built_client() {
|
||||
let mut app = crate::game_plugins::schedule_tests::headless_client();
|
||||
@@ -245,11 +217,8 @@ mod tests {
|
||||
assert!(failed.is_empty(), "{}", failed.join("\n"));
|
||||
}
|
||||
|
||||
/// **A handler matches the kind it registered for.** `net_handler(K::X, on_y)` routes X to
|
||||
/// `on_y`, and `on_y` opens with `if let SessionEvent::X` — two spellings of one kind, which
|
||||
/// nothing else ties together: a handler registered under the wrong kind compiles, runs, and
|
||||
/// silently ignores its packet. Read off the source: every registration's handler names its
|
||||
/// kind in its body. A session-end listener takes `In(_)` and is exempt.
|
||||
/// A handler registered under the wrong kind compiles and silently ignores its packet, so each
|
||||
/// handler must name `SessionEvent::<Kind>` in its body; a session-end listener is exempt.
|
||||
#[test]
|
||||
fn every_handler_names_the_kind_it_registered_for() {
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
@@ -305,9 +274,7 @@ mod tests {
|
||||
})
|
||||
.map(|(_, l)| l)
|
||||
.collect();
|
||||
// The whole variant name, not a prefix of one: `SessionEvent::Chat` is the head
|
||||
// of `ChatPlayerNotFound` too, so a bare `contains` let a handler registered for
|
||||
// `Chat` pass by matching a cousin (six kinds are prefixes of others).
|
||||
// The whole variant name: `Chat` is also a prefix of `ChatPlayerNotFound`.
|
||||
let names_kind = {
|
||||
let body = body.join("\n");
|
||||
let needle = format!("SessionEvent::{kind}");
|
||||
|
||||
+189
-443
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,8 @@
|
||||
//! Per-frame motion model for streamed units: server splines + remote-player dead-reckoning.
|
||||
//!
|
||||
//! Two motion sources drive an entity's [`Transform`] between the sparse server packets (decisions 0052
|
||||
//! + 0053):
|
||||
//! - **[`Spline`]** — a server-authored path, sampled at constant speed by
|
||||
//! [`spline::sample_splines`]. Creatures ride these. Two sources, one component: a fresh
|
||||
//! `SMSG_MONSTER_MOVE` ([`spline::monster_move_spline`]), and the walk a unit was **already** on when
|
||||
//! it streamed into view, joined mid-path from its create block ([`spline::create_spline`], 0708).
|
||||
//! - **[`RemoteMotion`]** — another player's flag-driven locomotion, integrated by
|
||||
//! [`remote::extrapolate_remote_units`]: the client's own dead-reckoning in miniature (extrapolate
|
||||
//! from the last reported state, snap to the truth on the next packet). A jump is a **ballistic
|
||||
//! event** — the launch played out locally under gravity — not flag-driven walking (decision 0053).
|
||||
//!
|
||||
//! The parent [`super`] module owns the wire→ECS bridge that seeds + corrects these from the packet
|
||||
//! stream; this module owns the integration, split by concern: [`spline`] (the path walk + its
|
||||
//! terrain re-ground), [`remote`] (dead-reckoning + jump ballistics), [`relay`] (***when*** a relayed
|
||||
//! move replays — the per-unit fire-time chain), [`facing`] (wire facing
|
||||
//! resolution + the client-local idle re-face and its [`FacingStep`] shuffle latch). This face keeps
|
||||
//! the shared pose glue. Poses live canonically in raw WoW space on the components; the `Transform`
|
||||
//! is derived (translation + placement rotation only — scale, baked by the renderer, is preserved).
|
||||
//! Per-frame motion for streamed units between server packets. A [`Spline`] is a server path
|
||||
//! sampled at constant speed, from `SMSG_MONSTER_MOVE` or joined mid-path from a create block;
|
||||
//! [`RemoteMotion`] is another player's flag-driven dead-reckoning, snapped to each new packet,
|
||||
//! with a jump played out locally under gravity. Poses live in raw WoW space on the components;
|
||||
//! the `Transform` is derived from them and keeps the renderer's baked scale.
|
||||
|
||||
use benilla_assets::coords::{wow_rotation_to_bevy, wow_to_bevy};
|
||||
use bevy::prelude::*;
|
||||
@@ -48,37 +33,21 @@ pub(super) use spline::{
|
||||
pub(crate) use spline::{ground_derived, grounded_y};
|
||||
pub(crate) use spline::{CreatureSwimming, GroundClamped, Spline, SplineStopped};
|
||||
|
||||
/// The Bevy rotation of a wire **yaw** — a mover's orientation, in radians about WoW +Z. It maps
|
||||
/// straight to a Bevy +Y yaw under `wow_to_bevy` (`coords`' `yaw_conjugates_through_the_basis_map`)
|
||||
/// — no 180° offset, unlike doodad/WMO *placement* rotation.
|
||||
/// The Bevy rotation of a wire yaw (radians about WoW +Z): a Bevy +Y yaw with no 180° offset,
|
||||
/// unlike doodad and WMO placement.
|
||||
pub(super) fn wire_yaw(orientation: f32) -> Quat {
|
||||
Quat::from_rotation_y(orientation)
|
||||
}
|
||||
|
||||
/// The Bevy rotation of a **GameObject** placement: its `GAMEOBJECT_ROTATION` quaternion, not its
|
||||
/// `GAMEOBJECT_FACING` yaw.
|
||||
/// The Bevy rotation of a GameObject placement: its `GAMEOBJECT_ROTATION` quaternion, which the
|
||||
/// reference's render matrix and collision use (`0x5f7910`); `GAMEOBJECT_FACING` (`0x5f9fb0`) is
|
||||
/// never consulted for placement.
|
||||
///
|
||||
/// The reference builds a GO's render matrix from that quaternion — `0x5f7910` (the every-frame
|
||||
/// slot-13 Animate) composes `GetPosition` → gx-rotate by the 4 floats at GO-fields `+0x10` →
|
||||
/// gx-scale, and the same 4×4 (`obj+0x218`) is what its collision reads. `GAMEOBJECT_FACING` is a
|
||||
/// *separate* accessor (`0x5f9fb0`) that the placement never consults.
|
||||
///
|
||||
/// A yaw is the special case here, not a simplification of it: vmangos copies `rotation0/1` from the
|
||||
/// spawn row verbatim and fills `rotation2/3` from the facing when the row leaves them zero
|
||||
/// (`GameObject::UpdateRotationFields`), so a row that authors no `rotation0/1` sends
|
||||
/// `(0, 0, sin(o/2), cos(o/2))` — and that quaternion **is** `rot_z(o)` exactly. 54 747 of its
|
||||
/// 56 632 live spawn rows are that case and place bit-identically either way. The 1 885 that do not (1 410
|
||||
/// authoring a real tilt in `rotation0/1`, 475 a yaw that disagrees with their own `orientation`
|
||||
/// column; 1 411 of them on a visible display) are exactly what a yaw-only placement gets wrong —
|
||||
/// and it misreads as a *position* bug, because an M2's geometry sits off its own origin: a signpost
|
||||
/// pointer's plank is 3.3 yd above the origin and 0.3–2.1 yd out, so dropping a 70° tilt swings that
|
||||
/// plank 4.3 yd from a spawn point that never moved (decision 1459, bug B89).
|
||||
///
|
||||
/// Falls back to the facing when the wire carried no quaternion at all, and when what it carried has
|
||||
/// no length to normalize — a create block folds absent fields to zero, and an all-zero quat would
|
||||
/// normalize to `NaN` and blank the object. Length is otherwise *not* assumed: one shipped row
|
||||
/// authors a tilt with `rotation2/3` zero, so the server fills the yaw pair on top of it and sends a
|
||||
/// non-unit quat; `wow_rotation_to_bevy` normalizes.
|
||||
/// vmangos fills `rotation2/3` from the facing when the spawn row leaves them zero
|
||||
/// (`GameObject::UpdateRotationFields`), so a yaw-only row still sends `rot_z(o)`, and a row with
|
||||
/// a tilt may send a non-unit quat, which `wow_rotation_to_bevy` normalizes. An absent or all-zero
|
||||
/// quat (a create block folds absent fields to zero) falls back to the facing, since it would
|
||||
/// normalize to `NaN`.
|
||||
pub(super) fn gameobject_rotation(quat: Option<[f32; 4]>, orientation: f32) -> Quat {
|
||||
match quat {
|
||||
Some(q) if q.iter().map(|c| c * c).sum::<f32>() > 0.5 => wow_rotation_to_bevy(q),
|
||||
@@ -86,8 +55,8 @@ pub(super) fn gameobject_rotation(quat: Option<[f32; 4]>, orientation: f32) -> Q
|
||||
}
|
||||
}
|
||||
|
||||
/// A full [`Transform`] for a freshly-spawned entity from a raw-WoW position + an already-resolved
|
||||
/// placement rotation ([`wire_yaw`] for a mover, [`gameobject_rotation`] for a GameObject).
|
||||
/// A spawn [`Transform`] from a raw-WoW position and a resolved rotation ([`wire_yaw`] or
|
||||
/// [`gameobject_rotation`]).
|
||||
pub(super) fn pose_transform(position: [f32; 3], rotation: Quat) -> Transform {
|
||||
Transform {
|
||||
translation: wow_to_bevy(position),
|
||||
@@ -96,16 +65,12 @@ pub(super) fn pose_transform(position: [f32; 3], rotation: Quat) -> Transform {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update an existing entity's translation + rotation from a raw-WoW pose, **preserving its scale**
|
||||
/// (the renderer bakes per-display model scale into the transform; a move must not reset it). The
|
||||
/// entity may not be query-visible the very frame it spawned — then the move is simply skipped (the
|
||||
/// spawn pose already placed it, and the next move lands once commands flush).
|
||||
/// Writes a raw-WoW pose onto an existing entity, keeping the renderer's baked scale; an entity
|
||||
/// not yet query-visible on its spawn frame is skipped, as its spawn pose already placed it.
|
||||
///
|
||||
/// **A wire pose is authoritative over the client-local facing turn**, so it drops any
|
||||
/// [`DisplayFacing`]: the smoother re-seeds its raw facing and its history ring from the pose
|
||||
/// written here, which is the client's spawn/resync shape (`0x601020` sets the goal and wipes the
|
||||
/// ring). Doing it here rather than at the call sites is deliberate — a future pose writer cannot
|
||||
/// forget it and leave a unit swinging back to a heading the server has since moved it off.
|
||||
/// A wire pose overrides the local facing turn, so it drops any [`DisplayFacing`] and the smoother
|
||||
/// re-seeds from this pose, as the reference's resync sets the goal and wipes the ring
|
||||
/// (`0x601020`).
|
||||
pub(super) fn write_pose(
|
||||
commands: &mut Commands,
|
||||
transforms: &mut Query<&mut Transform>,
|
||||
@@ -120,7 +85,7 @@ pub(super) fn write_pose(
|
||||
}
|
||||
}
|
||||
|
||||
/// Recover the WoW yaw from a unit [`Transform`] rotation (always built as [`Quat::from_rotation_y`]).
|
||||
/// The WoW yaw of a unit rotation, which is always built by [`Quat::from_rotation_y`].
|
||||
fn yaw_of(rotation: Quat) -> f32 {
|
||||
rotation.to_euler(EulerRot::YXZ).0
|
||||
}
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
//! Facing: the wire's `MonsterMoveFacing` resolution ([`resolve_facing`]) and the unit
|
||||
//! **display-facing smoother** ([`drive_display_facing`]) — the client's `0x600cd0` goal chain and
|
||||
//! its `+0xc98` box filter — with the [`FacingStep`] latch the anim layer's turn-shuffle reads
|
||||
//! (decision 0123).
|
||||
//! Facing: resolving the wire's `MonsterMoveFacing`, and the unit display-facing smoother (the
|
||||
//! 1.12 client's `0x600cd0` goal chain and `+0xc98` box filter) with the turn-shuffle latch.
|
||||
//!
|
||||
//! **A unit has two facings and only one of them is the wire's**.
|
||||
//! The raw movement facing `CMovement+0x1c` is what the server put there; the *rendered character
|
||||
//! body root* is the smoothed display facing `CGUnit+0xc94`, and the client turns that one
|
||||
//! client-side, every frame, toward a goal it picks from an ordered chain. Two of that chain's goals
|
||||
//! are local decisions the server never sends: a stationary unit squares up on its
|
||||
//! `UNIT_FIELD_TARGET`, and **an NPC turns to face you for as long as its interaction window is
|
||||
//! open** (bug B110, decision 1467).
|
||||
//!
|
||||
//! benilla collapses the two facings into the unit's `Transform.rotation`, which is therefore the
|
||||
//! *display* facing; [`DisplayFacing::wire`] keeps the raw one alongside it, because the goal chain
|
||||
//! falls back to the raw facing — and that fallback is what swings an NPC **back** to its authored
|
||||
//! heading when you close its window. In the client the raw facing separately places the
|
||||
//! collision/cull node, so a turning NPC's collision does not move; ours does. That asymmetry is a
|
||||
//! named approximation, not fidelity: a unit's collision footprint is a capsule and its cull volume
|
||||
//! a sphere, so rotating them is very nearly a no-op.
|
||||
//! A unit has a raw wire facing (`CMovement+0x1c`) and a display facing (`CGUnit+0xc94`) that the
|
||||
//! client turns every frame toward a goal from an ordered chain; two goals are local and never
|
||||
//! sent: squaring up on `UNIT_FIELD_TARGET`, and an NPC facing you while its window is open.
|
||||
//! `Transform.rotation` is the display facing; [`DisplayFacing::wire`] keeps the raw one, the
|
||||
//! chain's fallback goal, which swings an NPC back when its window closes.
|
||||
//! Deviation: the reference places collision and culling by the raw facing, ours follow the
|
||||
//! display facing, because a capsule and a sphere barely change under rotation.
|
||||
|
||||
use benilla_assets::coords::bevy_to_wow;
|
||||
use benilla_protocol::{EntityKind, MonsterMoveFacing};
|
||||
@@ -26,80 +16,50 @@ use bevy::prelude::*;
|
||||
use super::super::{ActiveMover, GuidIndex, NetEntity, ObjectStore, SelfPlayer};
|
||||
use super::{yaw_of, RemoteMotion, Spline};
|
||||
|
||||
/// The turn-shuffle latch's **sign dead-band** — the client's `[0x80c5c8]` = +1e-5 /
|
||||
/// `[0x80c5c4]` = −1e-5 (`0x60843b`–`0x608473`), tested on the yaw **this pump actually applied**.
|
||||
/// Above it the frame latches `+0xd58` bit `0x800` (→ ShuffleLeft 11), below its negative bit
|
||||
/// `0x1000` (→ ShuffleRight 12), and between the two nothing latches: it is a *did the body move
|
||||
/// at all* test, not a "close enough" one. Shared with the remote facing interp
|
||||
/// ([`super::remote`]) — the same latch drives a standing remote's mouse-turn shuffle.
|
||||
///
|
||||
/// **This replaces `FACING_SETTLED = 0.05` (~3°), which was wrong twice over** (decision 1655).
|
||||
/// 0123 recorded `0x80c5c4`/`0x80c5c8` as "the client's small/large-delta thresholds" and left an
|
||||
/// "eyeballable stand-in" in their place; re-read at the bytes they are ±1e-5, a symmetric pair
|
||||
/// about zero, and the two bits they gate are *left/right*, not small/large. Sixty times too wide,
|
||||
/// and applied to the wrong quantity, the stand-in dropped the latch about half-way through every
|
||||
/// turn — which is most of why a vendor turned to face you with frozen feet.
|
||||
/// The turn-shuffle latch's sign band, `[0x80c5c8]` = +1e-5 and `[0x80c5c4]` = -1e-5
|
||||
/// (`0x60843b`-`0x608473`), tested on the yaw the pump applied: above it latches bit `0x800`
|
||||
/// (ShuffleLeft 11), below it `0x1000` (ShuffleRight 12). Shared with the remote facing interp.
|
||||
pub(super) const TURN_LATCH_BAND: f32 = 1.0e-5;
|
||||
|
||||
/// The filter's **dead-band** (`[0x8029d0]` = 0.01 rad ≈ 0.57°), tested on the *unfolded* goal−current
|
||||
/// delta and **inclusive** (`fcomp` + `jp` at `600eb5`–`600ec0`). Inside it the goal is taken
|
||||
/// verbatim and the history ring is reset, so a slowly-turning player is tracked with zero lag.
|
||||
/// `[0x8029d0]`, in radians, tested inclusively on the unfolded goal-minus-current delta
|
||||
/// (`600eb5`-`600ec0`); inside it the goal is taken verbatim and the ring resets.
|
||||
const DEAD_BAND: f32 = 0.01;
|
||||
|
||||
/// The box filter's average weight (`[0x8029b0]`) — a plain mean over the 4-sample ring.
|
||||
/// `[0x8029b0]`: a plain mean over the 4-sample ring.
|
||||
const RING_AVG: f32 = 0.25;
|
||||
|
||||
/// The fraction of the (clamped) averaged delta applied per pump (`[0x7ffa24]`). With the
|
||||
/// overshoot clamp binding on every approach pass this makes the steady behaviour a **per-frame
|
||||
/// halving** of the error: ~8–9 frames from π down to [`DEAD_BAND`].
|
||||
/// `[0x7ffa24]`: with the overshoot clamp binding, the error halves every pump.
|
||||
const STEP_FRACTION: f32 = 0.5;
|
||||
|
||||
/// `UNIT_FIELD_FLAGS & 0x40000` — `UNIT_FLAG_STUNNED`, goal-chain row 4.
|
||||
/// `UNIT_FLAG_STUNNED`, goal-chain row 4.
|
||||
const UNIT_FLAG_STUNNED: u32 = 0x0004_0000;
|
||||
|
||||
/// The `Emotes.dbc` `EmoteFlags` bit that **permits** the target/interaction facing while a looping
|
||||
/// state emote is active (`600d95`/`600d98 test ch,0x20`). A valid emote-state record *without* this
|
||||
/// bit suppresses the whole chain and pins the unit to its raw facing; the bit's *name* is
|
||||
/// inferred — the byte fact is the condition tested.
|
||||
/// The `EmoteFlags` bit that permits the target and interaction facing during a state emote
|
||||
/// (`600d98 test ch,0x20`); the name is ours, the tested bit is the reference's.
|
||||
const EMOTE_PERMITS_FACING: u32 = 0x2000;
|
||||
|
||||
/// A stationary unit's display-facing state — the client's `CGUnit+0xc98` goal plus its
|
||||
/// `+0xc9c..+0xca8` delta-history ring. Inserted the first frame the unit is governed by
|
||||
/// [`drive_display_facing`] and removed the moment it stops being (a path, a relay stream, a fresh
|
||||
/// wire pose), so it is only ever alive while the client-local turn actually owns the body.
|
||||
/// A stationary unit's display-facing state, the client's `CGUnit+0xc98` goal and
|
||||
/// `+0xc9c..+0xca8` delta ring; present only while [`drive_display_facing`] governs the unit.
|
||||
#[derive(Component)]
|
||||
pub(crate) struct DisplayFacing {
|
||||
/// The **raw** (server-authoritative) yaw — the client's `CMovement+0x1c`. Seeded from the
|
||||
/// transform the wire left behind, and the goal every chain row but the two turn rows returns
|
||||
/// to. Nothing client-local ever writes it, which is exactly why the NPC swings back.
|
||||
/// The raw wire yaw (`CMovement+0x1c`), never written client-side.
|
||||
wire: f32,
|
||||
/// The 4-sample delta history. A zero head means "start fresh" — the ring-FILL leg.
|
||||
/// A zero head means the next pump takes the ring-fill leg.
|
||||
hist: [f32; 4],
|
||||
}
|
||||
|
||||
/// A stationary unit's display facing **moved this frame**: the signed yaw the pump applied (WoW
|
||||
/// yaw, positive = counterclockwise = turning left). The anim layer folds it into the unit's turn
|
||||
/// view — the client's facing-delta shuffle latch (`0x607ed0` bits `0x800`/`0x1000`;
|
||||
/// decision 0123) — so a squaring-up creature foot-shuffles instead of
|
||||
/// pivoting frozen, and each shuffle's return to Stand re-rolls the idle variation.
|
||||
///
|
||||
/// **The applied step, not the remaining gap** (decision 1655): the client's latch reads
|
||||
/// `0x607ed0`'s accumulator — the increment it writes to `+0xc94` at `608239` — against
|
||||
/// [`TURN_LATCH_BAND`]. The two differ at the tail of a per-frame-halving ease, which is exactly
|
||||
/// where a shuffle is still meant to be playing. Removed the frame the body stops moving.
|
||||
/// The signed yaw the facing pump applied this frame (positive turns left), the client's
|
||||
/// `0x607ed0` shuffle latch input; removed the frame the body stops moving.
|
||||
#[derive(Component)]
|
||||
pub(crate) struct FacingStep(pub(crate) f32);
|
||||
|
||||
/// Which row of the goal chain a unit's facing goal came from — trace-only ([`trace_face`]), so a
|
||||
/// "why is this NPC pointing there" question is answered from numbers instead of by eye.
|
||||
/// Which goal-chain row won, for the `face` trace.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum GoalSource {
|
||||
/// Every row that pins the unit to its raw facing: moving, posed, emote-suppressed, stunned,
|
||||
/// or simply nothing to look at.
|
||||
Raw,
|
||||
/// Row 7 — the bearing to `UNIT_FIELD_TARGET`.
|
||||
/// Row 7, the bearing to `UNIT_FIELD_TARGET`.
|
||||
Target,
|
||||
/// Row 9 — the bearing to the local player, because this unit is the interaction NPC.
|
||||
/// Row 9, the bearing to the local player from the interaction NPC.
|
||||
Interact,
|
||||
}
|
||||
|
||||
@@ -113,12 +73,8 @@ impl GoalSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a [`MonsterMoveFacing`] to a WoW orientation (radians) for a unit at `unit_pos` (raw WoW).
|
||||
/// `Angle` is the orientation verbatim; `Spot`/`Target` are the horizontal bearing from the unit to
|
||||
/// the point / the target unit's position (`atan2(dy, dx)`, the WoW convention that maps straight to
|
||||
/// [`Quat::from_rotation_y`]). `target_pos` looks up another unit's raw-WoW position for `Target`.
|
||||
/// `None` when there is nothing to face — [`MonsterMoveFacing::None`], an unknown/unstreamed target,
|
||||
/// or a point coincident with the unit (a degenerate bearing that would spin it to 0).
|
||||
/// A [`MonsterMoveFacing`] as a WoW orientation for a unit at `unit_pos`: `Spot` and `Target`
|
||||
/// are the horizontal `atan2(dy, dx)` bearing; `None` also for an unstreamed or coincident target.
|
||||
pub(in crate::net) fn resolve_facing(
|
||||
facing: MonsterMoveFacing,
|
||||
unit_pos: [f32; 3],
|
||||
@@ -136,56 +92,47 @@ pub(in crate::net) fn resolve_facing(
|
||||
}
|
||||
}
|
||||
|
||||
/// The horizontal bearing from `from` to `to` (raw WoW positions), or `None` when the two are
|
||||
/// essentially coincident — a degenerate `atan2` that would snap the unit to 0.
|
||||
/// The horizontal bearing from `from` to `to`, `None` when they coincide.
|
||||
fn bearing(from: [f32; 3], to: [f32; 3]) -> Option<f32> {
|
||||
let (dx, dy) = (to[0] - from[0], to[1] - from[1]);
|
||||
(dx * dx + dy * dy >= 1.0e-4).then(|| dy.atan2(dx))
|
||||
}
|
||||
|
||||
/// Row 3 of the goal chain — the **emote-state gate** (`600d6c`–`600d9d`). A unit in a looping state
|
||||
/// emote (`UNIT_NPC_EMOTESTATE`) is pinned to its raw facing *unless* the emote's `Emotes.dbc`
|
||||
/// `EmoteFlags` carries [`EMOTE_PERMITS_FACING`]. An absent or unknown emote id suppresses nothing —
|
||||
/// the client's `bl = 0` legs.
|
||||
/// Goal-chain row 3 (`600d6c`-`600d9d`): a state emote (`UNIT_NPC_EMOTESTATE`) whose
|
||||
/// `Emotes.dbc` flags lack [`EMOTE_PERMITS_FACING`] pins the unit to its raw facing; an absent
|
||||
/// or unknown emote suppresses nothing.
|
||||
fn emote_suppresses_facing(emote_state: u32, flags: impl FnOnce(u32) -> Option<u32>) -> bool {
|
||||
if emote_state == 0 {
|
||||
return false;
|
||||
}
|
||||
match flags(emote_state) {
|
||||
Some(f) => f & EMOTE_PERMITS_FACING == 0,
|
||||
None => false, // no such record — the client's `rec == 0` leg
|
||||
None => false, // the client's `rec == 0` leg
|
||||
}
|
||||
}
|
||||
|
||||
/// One pump of the client's `+0xc98` box filter (`0x600cd0`, `600ea8`–`600fd5`), byte-for-byte.
|
||||
/// Returns the new display yaw given the previous one, the goal and the unit's history ring.
|
||||
///
|
||||
/// **There is no `dt` in this filter** — it is per-*pump*, not per-second. The overshoot clamp binds
|
||||
/// on every approach pass, so the steady behaviour is `delta_{n+1} = 0.5 · delta_n`. That is a
|
||||
/// deliberate departure from a frame-rate-independent ease: it is the law the client runs, and at
|
||||
/// any plausible frame rate it settles inside a sixth of a second either way.
|
||||
/// One pump of the client's `+0xc98` box filter (`0x600cd0`, `600ea8`-`600fd5`). It has no `dt`:
|
||||
/// the reference halves the error per frame, not per second.
|
||||
fn filter_step(cur: f32, goal: f32, hist: &mut [f32; 4]) -> f32 {
|
||||
use std::f32::consts::TAU;
|
||||
// The dead-band is tested on the UNFOLDED delta and is inclusive (`600eb5`/`600ec0`).
|
||||
let raw_delta = goal - cur;
|
||||
if raw_delta.abs() <= DEAD_BAND {
|
||||
*hist = [0.0; 4];
|
||||
return goal.rem_euclid(TAU);
|
||||
}
|
||||
let delta = crate::creature_anim::wrap_pi(raw_delta);
|
||||
// A reversal empties the ring, so a turn that changes direction does not average against the
|
||||
// deltas it is undoing (`600f12`–`600f3b`).
|
||||
// A reversal empties the ring (`600f12`-`600f3b`).
|
||||
if delta * hist[0] < 0.0 {
|
||||
hist[0] = 0.0;
|
||||
}
|
||||
let step = if hist[0] == 0.0 {
|
||||
*hist = [delta; 4]; // the ring FILL leg — no averaging on this pass (`600f50`–`600f6a`)
|
||||
*hist = [delta; 4]; // the ring-fill leg, no averaging (`600f50`-`600f6a`)
|
||||
delta
|
||||
} else {
|
||||
hist.copy_within(0..3, 1); // `600f76 memmove(+0xca0, +0xc9c, 12)`
|
||||
hist[0] = delta;
|
||||
let avg = hist.iter().sum::<f32>() * RING_AVG;
|
||||
// Never overshoot the goal (`600f9e`–`600fc9`).
|
||||
// Never overshoot the goal (`600f9e`-`600fc9`).
|
||||
if avg.abs() > delta.abs() {
|
||||
delta
|
||||
} else {
|
||||
@@ -195,36 +142,24 @@ fn filter_step(cur: f32, goal: f32, hist: &mut [f32; 4]) -> f32 {
|
||||
(cur + step * STEP_FRACTION).rem_euclid(TAU)
|
||||
}
|
||||
|
||||
/// Turn every **stationary, client-governed** unit toward the goal the client's `0x600cd0` chain
|
||||
/// picks for it, through the one smoother that chain feeds ([`filter_step`]).
|
||||
///
|
||||
/// The chain, in the client's exact branch order — the ordering *is* the behaviour:
|
||||
/// Turns every stationary unit toward the goal the client's `0x600cd0` chain picks, in its
|
||||
/// branch order (the order is the behaviour):
|
||||
///
|
||||
/// | row | test | goal |
|
||||
/// |---|---|---|
|
||||
/// | 0 | the body we steer | *(excluded by [`ActiveMover`])* |
|
||||
/// | 1 | moving | raw — *(excluded by [`Spline`]; a patrolling NPC does not re-face)* |
|
||||
/// | 2 | `standState != 0` — sitting, kneeling, dead | raw |
|
||||
/// | 3 | a state emote whose `EmoteFlags` lacks [`EMOTE_PERMITS_FACING`] | raw |
|
||||
/// | 0 | the body we steer | excluded by [`ActiveMover`] |
|
||||
/// | 1 | moving | raw, excluded by [`Spline`] |
|
||||
/// | 2 | `standState != 0` | raw |
|
||||
/// | 3 | a state emote lacking [`EMOTE_PERMITS_FACING`] | raw |
|
||||
/// | 4 | [`UNIT_FLAG_STUNNED`] | raw |
|
||||
/// | 5 | the combat scripted override `+0xc58 & 1` → `+0xcac` | *(not modelled — see below)* |
|
||||
/// | 6 | a remote player character | raw — *(excluded by [`RemoteMotion`])* |
|
||||
/// | 5 | the combat override `+0xc58 & 1` to `+0xcac` (writer `0x624f10`) | not built |
|
||||
/// | 6 | a remote player | raw, excluded by [`RemoteMotion`] |
|
||||
/// | 7 | `UNIT_FIELD_TARGET` resolves | bearing to that unit |
|
||||
/// | 9 | **this unit is the interaction NPC** | **bearing to the local player** |
|
||||
/// | — | none of the above | raw |
|
||||
/// | 9 | this unit is the interaction NPC | bearing to the local player |
|
||||
/// | - | none of the above | raw |
|
||||
///
|
||||
/// Row 7 sitting above row 9 is why a vendor that is *fighting* keeps facing its attacker rather
|
||||
/// than you. Row 5 is a combat-side facing override with a single writer in the client
|
||||
/// (`0x624f10`, `UnitCombat_C.cpp`) and no benilla equivalent yet; it is a separate goal source, not
|
||||
/// part of this one, and its absence only means we never pin a unit the client would have pinned.
|
||||
///
|
||||
/// **No range, line-of-sight, faction or is-alive test appears anywhere on this path** — the
|
||||
/// `CanInteract` family is ruled out at the client's call graph. The only leash is the window's own
|
||||
/// range close, which [`crate::ui_session::close_npc_session_out_of_range`] already owns.
|
||||
///
|
||||
/// `pub(crate)` rather than `pub(in crate::net)` for one reason: the shuffle latch's *consumer*
|
||||
/// lives in `creature_anim`, and the only test that can catch the latch being produced but never
|
||||
/// read has to run both systems in one app (`creature_anim::driver::tests`).
|
||||
/// No range, line-of-sight, faction or alive test is on this path; the window's own range close
|
||||
/// is the only leash.
|
||||
#[allow(clippy::type_complexity)] // one Bevy system's full input set
|
||||
pub(crate) fn drive_display_facing(
|
||||
mut commands: Commands,
|
||||
@@ -239,12 +174,9 @@ pub(crate) fn drive_display_facing(
|
||||
self_q: Query<Entity, With<SelfPlayer>>,
|
||||
mut transforms: Query<&mut Transform>,
|
||||
mut facings: Query<&mut DisplayFacing>,
|
||||
// A remote player's latch belongs to the facing interp in [`super::remote`] — the cleanup here
|
||||
// must not sweep it (the two systems would fight over the component every frame).
|
||||
// A remote player's latch belongs to the remote facing interp; sweeping it here would fight it.
|
||||
latched: Query<Entity, (With<FacingStep>, Without<RemoteMotion>)>,
|
||||
// A unit that has started walking or become a relay mover is no longer governed: its transform
|
||||
// belongs to the sampler / extrapolator, and its state must go so the next seed reads the pose
|
||||
// they left rather than a stale pre-walk heading.
|
||||
// A unit that starts moving drops its state, so the next seed reads the pose it stops at.
|
||||
ungoverned: Query<Entity, (With<DisplayFacing>, Or<(With<Spline>, With<RemoteMotion>)>)>,
|
||||
) {
|
||||
let self_pos = self_q
|
||||
@@ -254,15 +186,14 @@ pub(crate) fn drive_display_facing(
|
||||
.map(|t| bevy_to_wow(t.translation));
|
||||
let interact_npc = interact.and_then(|r| r.0);
|
||||
|
||||
// Collect (unit, goal, source) from immutable position reads first, then apply — so the
|
||||
// target's transform and the unit's are never borrowed mutably at the same time.
|
||||
// Collect goals first, then apply, so no transform is borrowed mutably while another is read.
|
||||
let mut goals: Vec<(Entity, Option<f32>, GoalSource)> = Vec::new();
|
||||
for (e, net, store) in &candidates {
|
||||
if net.kind != EntityKind::Unit {
|
||||
continue; // a GameObject sits at its authored facing; players are excluded above
|
||||
continue; // a GameObject sits at its authored facing
|
||||
}
|
||||
let fields = &store.0;
|
||||
// Rows 2-4: the posture/state gates that pin a unit to its raw facing.
|
||||
// Rows 2-4.
|
||||
if fields.unit_stand_state() != 0
|
||||
|| fields.unit_flags() & UNIT_FLAG_STUNNED != 0
|
||||
|| emote_suppresses_facing(fields.unit_emote_state(), |id| {
|
||||
@@ -276,7 +207,7 @@ pub(crate) fn drive_display_facing(
|
||||
continue;
|
||||
};
|
||||
let unit_pos = bevy_to_wow(unit_t.translation);
|
||||
// Row 7: the bearing to UNIT_FIELD_TARGET, when it resolves to something streamed.
|
||||
// Row 7.
|
||||
let target = fields
|
||||
.unit_target()
|
||||
.and_then(|g| index.0.get(&g).copied())
|
||||
@@ -286,8 +217,7 @@ pub(crate) fn drive_display_facing(
|
||||
goals.push((e, Some(goal), GoalSource::Target));
|
||||
continue;
|
||||
}
|
||||
// Row 9: the interaction face-me. `InteractNpc` is the benilla-side twin of the client's
|
||||
// one global `[0xb4e2d0]` — whichever NPC window is open, and nothing when none is.
|
||||
// Row 9: `InteractNpc` stands for the client's global `[0xb4e2d0]`, the open NPC window.
|
||||
let facing_me = (interact_npc == Some(e))
|
||||
.then_some(self_pos)
|
||||
.flatten()
|
||||
@@ -305,34 +235,25 @@ pub(crate) fn drive_display_facing(
|
||||
};
|
||||
let cur = yaw_of(tf.rotation).rem_euclid(std::f32::consts::TAU);
|
||||
let Ok(mut state) = facings.get_mut(e) else {
|
||||
// First frame governed: the transform still holds exactly what the wire (a pose, a
|
||||
// finished path) left there, so it *is* the raw facing. Seed and let the next pump
|
||||
// start from a clean ring — the client's `0x601020` shape.
|
||||
// First frame governed: the transform still holds the wire's facing, so seed from it
|
||||
// with a clean ring (`0x601020`).
|
||||
commands.entity(e).insert(DisplayFacing {
|
||||
wire: cur,
|
||||
hist: [0.0; 4],
|
||||
});
|
||||
continue;
|
||||
};
|
||||
// Every row but the two turn rows returns the unit to its raw facing — the fallback is a
|
||||
// *goal*, not a freeze, which is what swings an NPC back when you close its window.
|
||||
// The raw facing is a goal, not a freeze: it swings an NPC back when its window closes.
|
||||
let goal = goal.unwrap_or(state.wire);
|
||||
let new = filter_step(cur, goal, &mut state.hist);
|
||||
// Write-on-change only (1473): a settled unit's `new` is bitwise `goal.rem_euclid(TAU)`
|
||||
// every frame (the dead-band leg of `filter_step` returns the goal verbatim, never a
|
||||
// re-derivation from `cur`), so this compare goes quiet one frame after settling. The
|
||||
// unconditional write it replaces marked every standing NPC's `Transform` changed every
|
||||
// frame — re-propagating its whole subtree and defeating each downstream `Changed` gate
|
||||
// for the entire idle crowd (1445's swim gate, the water-side classifier's moved sweep,
|
||||
// mesh re-extract) — the same defect `face_billboards` fixed for cards.
|
||||
// Write only on change: a settled unit's `new` is bitwise the goal, so a standing crowd
|
||||
// stops tripping every downstream `Changed<Transform>` gate.
|
||||
let rot = Quat::from_rotation_y(new);
|
||||
if tf.rotation != rot {
|
||||
tf.rotation = rot;
|
||||
}
|
||||
// The turn-shuffle latch (decision 0123, corrected by 1650): the client tests the yaw
|
||||
// this pump APPLIED (`0x607ed0`'s `param_2`, the increment written to `+0xc94`) against
|
||||
// the symmetric ±[`TURN_LATCH_BAND`] sign band — not the gap still to cover, and not a
|
||||
// "close enough" threshold. Folded because a pump may cross the wrap.
|
||||
// The latch tests the yaw this pump applied (`0x607ed0`'s `param_2`, the increment
|
||||
// written to `+0xc94`), not the gap left; folded because a pump may cross the wrap.
|
||||
let step = crate::creature_anim::wrap_pi(new - cur);
|
||||
if step.abs() > TURN_LATCH_BAND {
|
||||
commands.entity(e).insert(FacingStep(step));
|
||||
@@ -340,7 +261,6 @@ pub(crate) fn drive_display_facing(
|
||||
}
|
||||
trace_face(e, source, goal, cur, new);
|
||||
}
|
||||
// Drop stale latches: units that settled, lost their goal, or started moving this frame.
|
||||
for e in &latched {
|
||||
if !stepping.contains(&e) {
|
||||
commands.entity(e).remove::<FacingStep>();
|
||||
@@ -351,9 +271,7 @@ pub(crate) fn drive_display_facing(
|
||||
}
|
||||
}
|
||||
|
||||
/// The facing instrument (`WOW_MOVE_TRACE=<path> WOW_MOVE_TRACE_TAGS=face`) — one line per governed
|
||||
/// unit per frame that actually turns, naming which goal-chain row won. "Why is this NPC pointing
|
||||
/// there" is a numbers question; this is the number.
|
||||
/// The `face` trace tag: one line per turning unit per frame, naming the goal-chain row that won.
|
||||
fn trace_face(e: Entity, source: GoalSource, goal: f32, cur: f32, new: f32) {
|
||||
if !benilla_assets::trace::enabled_for("face") || (new - cur).abs() < 1.0e-6 {
|
||||
return;
|
||||
@@ -374,8 +292,7 @@ mod tests {
|
||||
use benilla_assets::coords::wow_to_bevy;
|
||||
use std::f32::consts::{FRAC_PI_2, PI, TAU};
|
||||
|
||||
/// The dead-band is INCLUSIVE and snaps the goal verbatim, resetting the ring — so a player
|
||||
/// strolling around a vendor is tracked with no lag at all.
|
||||
/// The dead-band is inclusive.
|
||||
#[test]
|
||||
fn inside_the_dead_band_the_goal_is_taken_verbatim() {
|
||||
let mut hist = [0.3; 4];
|
||||
@@ -384,7 +301,6 @@ mod tests {
|
||||
assert_eq!(hist, [0.0; 4], "the ring resets inside the dead-band");
|
||||
}
|
||||
|
||||
/// The first pump off an empty ring takes the FILL leg: no averaging, half the delta.
|
||||
#[test]
|
||||
fn the_first_pump_fills_the_ring_and_halves_the_error() {
|
||||
let mut hist = [0.0; 4];
|
||||
@@ -393,8 +309,6 @@ mod tests {
|
||||
assert_eq!(hist, [FRAC_PI_2; 4], "the ring fills with the delta");
|
||||
}
|
||||
|
||||
/// The steady behaviour is a per-frame HALVING — not a per-second rate — and π converges into
|
||||
/// the dead-band in ~8-9 pumps. This is the shape benilla had wrong before decision 1467.
|
||||
#[test]
|
||||
fn the_error_halves_per_pump_and_pi_settles_in_nine() {
|
||||
let mut hist = [0.0; 4];
|
||||
@@ -413,8 +327,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A reversal empties the ring so the new direction is not averaged against the deltas it is
|
||||
/// undoing — the client's sign-flip reset.
|
||||
#[test]
|
||||
fn a_reversal_resets_the_ring_head() {
|
||||
let mut hist = [0.4; 4];
|
||||
@@ -429,18 +341,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The turn takes the short way round the circle, never the long one.
|
||||
#[test]
|
||||
fn the_turn_takes_the_short_arc() {
|
||||
let mut hist = [0.0; 4];
|
||||
// Goal is 0.2 rad counterclockwise of `cur`, expressed the long way (cur near TAU).
|
||||
// The goal is 0.2 rad counterclockwise of `cur`, across the wrap.
|
||||
let out = filter_step(TAU - 0.1, 0.1, &mut hist);
|
||||
let moved = crate::creature_anim::wrap_pi(out - (TAU - 0.1));
|
||||
assert!(moved > 0.0 && moved < 0.2, "short way, part way: {moved}");
|
||||
}
|
||||
|
||||
/// A state emote suppresses the whole chain only when its record LACKS the permitting bit; an
|
||||
/// absent id and an unknown record both suppress nothing (the client's `bl = 0` legs).
|
||||
#[test]
|
||||
fn the_emote_state_gate_matches_the_clients_bl() {
|
||||
assert!(!emote_suppresses_facing(0, |_| Some(0)), "no emote state");
|
||||
@@ -455,20 +364,13 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Drive the real system in a minimal app: a vendor 5 yd north of us, an interaction window
|
||||
/// open on it, and nothing else. It must turn to face us, settle, and — the client's
|
||||
/// distinctive observable — **swing back** to its authored heading when the window closes.
|
||||
///
|
||||
/// This is the test that would have caught the mechanism being built and never firing: it
|
||||
/// exercises the goal chain and the `InteractNpc` wiring, not just [`filter_step`].
|
||||
#[test]
|
||||
fn the_interaction_npc_turns_to_face_us_and_swings_back_on_close() {
|
||||
let mut app = App::new();
|
||||
app.init_resource::<GuidIndex>()
|
||||
.init_resource::<crate::ui_session::InteractNpc>()
|
||||
.add_systems(Update, drive_display_facing);
|
||||
// Us at the origin; the vendor 5 yd along +x, authored facing +x (away from us, which is
|
||||
// exactly the reporter's screenshot).
|
||||
// Us at the origin; the vendor 5 yd along +x, facing +x, away from us.
|
||||
let me = app
|
||||
.world_mut()
|
||||
.spawn((SelfPlayer, ActiveMover, Transform::default()))
|
||||
@@ -492,7 +394,6 @@ mod tests {
|
||||
.id();
|
||||
|
||||
let yaw = |app: &App| yaw_of(app.world().entity(npc).get::<Transform>().unwrap().rotation);
|
||||
// The first pump only seeds the state (the transform still holds the wire's own pose).
|
||||
app.update();
|
||||
assert!(
|
||||
(yaw(&app) - 0.0).abs() < 1.0e-4,
|
||||
@@ -503,7 +404,6 @@ mod tests {
|
||||
app.world().entity(npc).contains::<DisplayFacing>(),
|
||||
"the seeding frame inserts the smoother state"
|
||||
);
|
||||
// No window open yet: the goal is the raw facing, so it stays put however long we run.
|
||||
for _ in 0..16 {
|
||||
app.update();
|
||||
}
|
||||
@@ -513,7 +413,7 @@ mod tests {
|
||||
yaw(&app)
|
||||
);
|
||||
|
||||
// Open a window on it. We are due WEST of the vendor, so the bearing it must adopt is pi.
|
||||
// We are due west of the vendor: a bearing of pi.
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::ui_session::InteractNpc>()
|
||||
.0 = Some(npc);
|
||||
@@ -526,8 +426,6 @@ mod tests {
|
||||
"the vendor faces us within the dead-band, got {facing_us}"
|
||||
);
|
||||
|
||||
// Close it. The goal reverts to the untouched raw facing and the same filter swings the
|
||||
// body back — the client never wrote the raw facing, so neither did we.
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::ui_session::InteractNpc>()
|
||||
.0 = None;
|
||||
@@ -541,24 +439,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The turn-shuffle latch's own law (decision 1655): [`FacingStep`] carries **the yaw this
|
||||
/// pump applied** — the client's `0x607ed0` accumulator, the increment it writes to `+0xc94` —
|
||||
/// tested against the symmetric ±[`TURN_LATCH_BAND`] sign band (`[0x80c5c8]`/`[0x80c5c4]`).
|
||||
///
|
||||
/// The regression this fences is the pair of substitutions 0123 made: the *remaining* gap in
|
||||
/// place of the applied step, and an eyeballed ~3° in place of ±1e-5. Because the ease halves
|
||||
/// its error every pump, "3° still to go" arrives while the body is still visibly moving, so
|
||||
/// the latch released about half-way through every turn and the foot-shuffle never blended in.
|
||||
/// The middle assertion is that exact frame: the latch is still held while the gap left is
|
||||
/// under the old threshold.
|
||||
/// The latch is held while under 0.05 rad of the turn remains, a gap a "close enough"
|
||||
/// threshold would release.
|
||||
#[test]
|
||||
fn the_latch_carries_the_step_applied_not_the_gap_remaining() {
|
||||
let mut app = App::new();
|
||||
app.init_resource::<GuidIndex>()
|
||||
.init_resource::<crate::ui_session::InteractNpc>()
|
||||
.add_systems(Update, drive_display_facing);
|
||||
// Us off the vendor's axis: the bearing is +2.601 rad, reached counterclockwise, so every
|
||||
// step is positive — the left turn, and ShuffleLeft (11) downstream.
|
||||
// A bearing of +2.601 rad, reached counterclockwise: every step is a left turn.
|
||||
app.world_mut().spawn((
|
||||
SelfPlayer,
|
||||
ActiveMover,
|
||||
@@ -607,7 +496,6 @@ mod tests {
|
||||
latched_inside_the_old_band = true;
|
||||
}
|
||||
}
|
||||
// Nothing moved this frame, so nothing may be latched.
|
||||
None => assert!(
|
||||
applied.abs() <= TURN_LATCH_BAND,
|
||||
"unlatched, but the body moved {applied}"
|
||||
@@ -628,7 +516,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Row 7 sits above row 9: a vendor that is *fighting* keeps facing its attacker, not us.
|
||||
/// Row 7 sits above row 9: a fighting vendor keeps facing its victim.
|
||||
#[test]
|
||||
fn a_target_outranks_the_interaction_face_me() {
|
||||
use benilla_protocol::ObjectFields;
|
||||
@@ -638,7 +526,7 @@ mod tests {
|
||||
.add_systems(Update, drive_display_facing);
|
||||
app.world_mut()
|
||||
.spawn((SelfPlayer, ActiveMover, Transform::default()));
|
||||
// The vendor's victim is due NORTH of it (+y in WoW), i.e. a bearing of +pi/2.
|
||||
// The victim is due north of the vendor: a bearing of +pi/2.
|
||||
let victim = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
@@ -651,7 +539,7 @@ mod tests {
|
||||
Transform::from_translation(wow_to_bevy([5.0, 5.0, 0.0])),
|
||||
))
|
||||
.id();
|
||||
// UNIT_FIELD_TARGET (index 16) is a guid pair; give the victim guid 7.
|
||||
// UNIT_FIELD_TARGET is field 16, a guid pair; the victim is guid 7.
|
||||
let npc = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
@@ -681,11 +569,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The rotation write is a dead-band, not a metronome: once a governed unit settles, its
|
||||
/// `Transform` stops being marked changed. This is the regression fence for 1473's defeat
|
||||
/// finding — an unconditional same-value write re-propagated every standing NPC's subtree
|
||||
/// every frame and silently un-gated each downstream `Changed` consumer (1445's swim gate,
|
||||
/// the water-side classifier's moved sweep, mesh re-extract).
|
||||
#[test]
|
||||
fn a_settled_unit_stops_dirtying_its_transform() {
|
||||
#[derive(Resource, Default)]
|
||||
@@ -698,10 +581,8 @@ mod tests {
|
||||
.init_resource::<crate::ui_session::InteractNpc>()
|
||||
.init_resource::<Dirty>()
|
||||
.add_systems(Update, (drive_display_facing, spy).chain());
|
||||
// Us OFF the vendor's axis (bearing ≈ 2.60 rad), deliberately: a goal at exactly ±π sits
|
||||
// on the yaw wrap, where the client's unfolded-delta dead-band (its own byte-verified
|
||||
// quirk) never snaps — the ease still goes bitwise-quiet there, but only via float
|
||||
// underflow, ~16 pumps later. The representative case snaps inside the band in ~9.
|
||||
// Off the vendor's axis: a goal at exactly pi sits on the wrap, where the unfolded-delta
|
||||
// dead-band never snaps and the ease only goes quiet by underflow, ~16 pumps later.
|
||||
app.world_mut().spawn((
|
||||
SelfPlayer,
|
||||
ActiveMover,
|
||||
@@ -723,8 +604,7 @@ mod tests {
|
||||
},
|
||||
))
|
||||
.id();
|
||||
// Open a window on it: the vendor legitimately turns, dirtying the transform — the
|
||||
// control that proves the spy sees real writes.
|
||||
// The control: a turning vendor dirties its transform.
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::ui_session::InteractNpc>()
|
||||
.0 = Some(npc);
|
||||
@@ -734,7 +614,7 @@ mod tests {
|
||||
app.world().resource::<Dirty>().0 > 0,
|
||||
"a turning unit dirties its transform"
|
||||
);
|
||||
// The turn settles inside the dead-band in ~9 pumps; run past it, then check steady state.
|
||||
// The turn settles in about nine pumps.
|
||||
for _ in 0..16 {
|
||||
app.update();
|
||||
}
|
||||
@@ -745,7 +625,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A coincident pair yields no bearing — a degenerate `atan2` would spin the unit to 0.
|
||||
#[test]
|
||||
fn a_coincident_pair_has_no_bearing() {
|
||||
assert!(bearing([1.0, 2.0, 3.0], [1.0, 2.0, 9.0]).is_none());
|
||||
|
||||
@@ -1,38 +1,22 @@
|
||||
//! The movement modes the server grants a unit we do **not** control — the `SMSG_SPLINE_MOVE_*`
|
||||
//! family's state (decision 1780).
|
||||
//! The movement modes the server grants a unit we do not control: the `SMSG_SPLINE_MOVE_*` state.
|
||||
|
||||
use benilla_protocol::SplineMode;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::creature_anim::move_flags;
|
||||
|
||||
/// **A streamed unit's server-granted movement modes**, as `MOVEMENTFLAGS` bits — root, water-walk,
|
||||
/// feather-fall, hover, walk-mode and swim.
|
||||
///
|
||||
/// This is [`crate::player::state::MoveModes`]' observer twin: the same bits, in the same word, for
|
||||
/// a body somebody else is driving. It exists because those bits had **nowhere to live** for a unit
|
||||
/// that is not our mover — a remote player's arrive inside [`super::RemoteMotion::flags`] with each
|
||||
/// relayed pose, and a creature, which never sends a pose at all, had no flags word whatsoever. So
|
||||
/// every mode a creature was granted was simply dropped: `crates/benilla-app/src/net/motion/
|
||||
/// remote.rs` said as much in as many words ("a remote's own granted modes are not modelled yet").
|
||||
///
|
||||
/// **One word per unit is the reference's own shape**, not a convenience: `CMovement` is embedded at
|
||||
/// `CGUnit+0x9a8` and its `+0x40` MOVEMENTFLAGS dword is *the* per-unit movement state, written by
|
||||
/// the relay merge, the create block, the spline installer and this opcode family alike. We keep
|
||||
/// the granted-mode half separate from
|
||||
/// [`super::RemoteMotion::flags`] for one reason: the relay merge re-authors that word wholesale
|
||||
/// from every pose, and a creature has no poses to be re-authored from.
|
||||
///
|
||||
/// Absent component ⇒ no modes granted, which is the overwhelming majority of units; it is inserted
|
||||
/// on the first `SMSG_SPLINE_MOVE_*` that names the unit and lives as long as the entity, exactly as
|
||||
/// the reference's word lives as long as the `CGUnit` (`0x7c4850`: `CMovement` is
|
||||
/// constructed once per unit and nothing else clears it).
|
||||
/// A streamed unit's server-granted movement modes as `MOVEMENTFLAGS` bits: root, water-walk,
|
||||
/// feather-fall, hover, walk-mode and swim. The reference keeps one flags dword per unit
|
||||
/// (`CMovement` at `CGUnit+0x9a8`, word `+0x40`); the granted half is kept apart from
|
||||
/// [`super::RemoteMotion::flags`] because the relay merge rewrites that word from every pose and
|
||||
/// a creature sends none. Absent means no modes; once inserted it lives as long as the entity, as
|
||||
/// the reference's word lives as long as the unit (`0x7c4850`).
|
||||
#[derive(Component, Default, Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) struct UnitMoveModes(pub(crate) u32);
|
||||
|
||||
impl UnitMoveModes {
|
||||
/// Grant or revoke one mode. `apply` is always the direction of the bit — the run/walk pair's
|
||||
/// inversion against its opcode names is folded at the parse ([`SplineMode::WalkMode`]).
|
||||
/// `apply` is the bit's direction; the run/walk opcodes' inversion is folded at the parse
|
||||
/// ([`SplineMode::WalkMode`]).
|
||||
pub(crate) fn set(&mut self, mode: SplineMode, apply: bool) {
|
||||
if apply {
|
||||
self.0 |= mode.flag();
|
||||
@@ -41,39 +25,28 @@ impl UnitMoveModes {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rooted (`MOVEFLAG_ROOT`) — this unit **cannot be splined**: the reference's server-position
|
||||
/// apply `0x6187a0` refuses outright while the bit is set (`0x6187c2 test ah,0x10`). A rooted
|
||||
/// unit cannot translate, cannot jump, cannot be splined.
|
||||
/// `MOVEFLAG_ROOT`: the unit cannot move, jump or be splined; the reference's server-position
|
||||
/// apply `0x6187a0` refuses while the bit is set (`0x6187c2`).
|
||||
pub(crate) fn rooted(self) -> bool {
|
||||
self.0 & move_flags::ROOT != 0
|
||||
}
|
||||
|
||||
/// Hovering (`MOVEFLAG_HOVER`) — the body rests [`crate::player::HOVER_HEIGHT`] above the floor.
|
||||
/// `MOVEFLAG_HOVER`: the body rests [`crate::player::HOVER_HEIGHT`] above the floor.
|
||||
pub(crate) fn hovering(self) -> bool {
|
||||
self.0 & move_flags::HOVER != 0
|
||||
}
|
||||
|
||||
/// Water-walking (`MOVEFLAG_WATERWALKING`) — the liquid surface counts as walkable ground.
|
||||
/// `MOVEFLAG_WATERWALKING`: the liquid surface counts as walkable ground.
|
||||
pub(crate) fn water_walking(self) -> bool {
|
||||
self.0 & move_flags::WATER_WALKING != 0
|
||||
}
|
||||
}
|
||||
|
||||
// **`MOVEFLAG_SAFE_FALL` deliberately has no accessor here.** The bit is carried in the word (and
|
||||
// folded into the animation selector's view like every other), but nothing reads it *from this
|
||||
// component*, for a structural reason: benilla integrates a fall for exactly two bodies — our own
|
||||
// mover, whose feather fall is [`crate::player::state::MoveModes::feather_fall`], and a relayed
|
||||
// player, whose `MOVEFLAG_SAFE_FALL` rides its own poses inside the server-authored merge mask and
|
||||
// is read straight off [`super::RemoteMotion::flags`] there. A creature, the one body this
|
||||
// component is really for, never falls: it rides its [`super::Spline`] and its Z is the ground
|
||||
// clamp's. An accessor with no caller would be a claim that the mode is wired when it is not.
|
||||
// `MOVEFLAG_SAFE_FALL` has no accessor: only our mover (`MoveModes::feather_fall`) and relayed
|
||||
// players (their own pose flags) fall; a creature rides its spline and never does.
|
||||
|
||||
/// **What `SetRoot 0x7c7340` wipes from the flags word at apply**, as a mask to AND with: the four
|
||||
/// direction bits, the two keyboard turn bits and the two pitch bits (`0xff`), the `0x8000` latch,
|
||||
/// and the five deferred-input latches (`0x1f0000`) — `and dword ptr [esi+0x40], 0xffe07f00`.
|
||||
///
|
||||
/// It is a **one-shot wipe at apply**, not a standing gate, and it is the whole reason a rooted
|
||||
/// mover stops rather than coasting: with the direction bits gone the unit fails the client's
|
||||
/// integration gate (`move_flags::INTEGRATED`, `0x616e20 test dword [esi+0x40],0x20ff`) and is not
|
||||
/// stepped at all. `ROOT` itself is preserved by the mask.
|
||||
/// What `SetRoot` `0x7c7340` clears from the flags word at apply, as an AND mask: the direction,
|
||||
/// turn and pitch bits (`0xff`), the `0x8000` latch and the input latches (`0x1f0000`); `ROOT`
|
||||
/// survives. A one-shot wipe, not a gate: with no direction bits the unit fails the integration
|
||||
/// test (`0x616e20`, `move_flags::INTEGRATED`) and stops rather than coasting.
|
||||
pub(crate) const ROOT_APPLY_WIPE: u32 = 0xffe0_7f00;
|
||||
|
||||
@@ -1,41 +1,19 @@
|
||||
//! **When** a remote unit's relayed move replays — the reference's per-unit replay chain, byte-traced
|
||||
//! (decision 0615; supersedes the invented estimator of 0601).
|
||||
//! When a remote unit's relayed move replays: the reference's per-unit replay chain (`0x618c30`,
|
||||
//! its lateness ring `0x618b50`, the queue drain `0x615c30`).
|
||||
//!
|
||||
//! A remote's inbound `MSG_MOVE_*` is not applied at arrival: it is given a client **fire-time** and
|
||||
//! waits in the unit's queue until the clock reaches it, the dead-reckon covering the mover's own
|
||||
//! timeline in between (decision 0601). The law that picks that
|
||||
//! fire-time lives in `0x618c30` + its window helper `0x618b50`, and it is **per unit** — five cells
|
||||
//! on the unit's own CMovement, no manager cursor:
|
||||
//!
|
||||
//! | cell | here | meaning |
|
||||
//! |------|------|---------|
|
||||
//! | `+0xa8` | [`RelayChain::last_fire_ms`] | the fire-time the previous packet was given |
|
||||
//! | `+0xac` | [`RelayChain::last_wire_ms`] | the previous packet's wire stamp |
|
||||
//! | `+0xbc[32]`/`+0x13c` | [`RelayChain::ring`]/`ring_idx` | the lateness ring + its cursor |
|
||||
//! | `+0x140` | [`RelayChain::base_ms`] | the lateness base the ring is stored against |
|
||||
//!
|
||||
//! The law, in one line: **a moving unit replays on the sender's own cadence** — `fire = prev fire +
|
||||
//! (this wire stamp − prev wire stamp)`, so the spacing our client plays back is exactly the spacing
|
||||
//! the stamps carry, no matter how the packets clumped in flight. The buffer that absorbs the clumping
|
||||
//! is only ever re-sized while the mover is **idle with nothing queued**: there the chain re-bases by
|
||||
//! the worst lateness seen over the last 32 packets, which is a de-jitter buffer sized by measured
|
||||
//! jitter rather than by an estimate re-derived per packet. Either way the offset from arrival is held
|
||||
//! in `[−500, +1000] ms`.
|
||||
//!
|
||||
//! Byte index: `0x618c30` (chain: seed `@0x618c54`, wire delta `@0x618ca0-cc2`, skew out `@0x618ccf`,
|
||||
//! idle+empty gate `@0x618ce4`/`@0x618cf3`, re-base `@0x618cfc-d41`, clamps `@0x618d0d`/`@0x618d49`,
|
||||
//! fire store `@0x618dcc`, due test `@0x618dd2`) · `0x618b50` (the ring) · `0x615c30` (the drain).
|
||||
//! An inbound `MSG_MOVE_*` gets a fire-time and waits in the unit's queue until then. A moving unit
|
||||
//! replays on the sender's cadence, `fire = prev fire + (wire stamp - prev wire stamp)`; only an
|
||||
//! idle mover with nothing queued re-bases, by the worst lateness of the last 32 packets. The
|
||||
//! offset from arrival stays in `[-500, +1000]` ms. The state is five cells on the unit's
|
||||
//! `CMovement`: `+0xa8`, `+0xac`, `+0xbc[32]`, `+0x13c`, `+0x140`.
|
||||
|
||||
use benilla_protocol::{JumpInfo, RelayVerb, TransportPose};
|
||||
|
||||
/// One relayed move exactly as it came off the wire — the payload of
|
||||
/// [`benilla_protocol::SessionEvent::UnitMove`], before [`RelayChain`] decides *when* it applies.
|
||||
/// One relayed move as it came off the wire, before [`RelayChain`] schedules it.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct RelayMove {
|
||||
/// The `MovementInfo` time word — vmangos's own ms clock stamped at **receipt**
|
||||
/// (`MovementInfo::Read`: `stime = WorldTimer::getMSTime()`, a live `steady_clock`; relayed
|
||||
/// verbatim by `Write`), so every mover's stamps share one coherent server clock. The chain paces
|
||||
/// replay by the deltas between consecutive stamps.
|
||||
/// The server's ms clock at receipt, one clock for every mover (vmangos `Object.cpp:64`,
|
||||
/// written back at `Object.cpp:194`).
|
||||
pub(crate) wire_ms: u32,
|
||||
pub(crate) position: [f32; 3],
|
||||
pub(crate) orientation: f32,
|
||||
@@ -44,89 +22,61 @@ pub(crate) struct RelayMove {
|
||||
pub(crate) fall_time: u32,
|
||||
pub(crate) jump: Option<JumpInfo>,
|
||||
pub(crate) transport: Option<TransportPose>,
|
||||
/// **What this packet's opcode means on top of the pose** (decision 2064) — the receiver's
|
||||
/// switch, and the only thing about a relay that is not in its `MovementInfo`.
|
||||
/// What the opcode means on top of the pose.
|
||||
pub(crate) verb: RelayVerb,
|
||||
}
|
||||
|
||||
impl RelayMove {
|
||||
/// **Does this queued move arm the pre-fire reconcile?** The two blends
|
||||
/// ([`super::remote::facing_lerp`] and the position lerp) exist to land a queued move's pose
|
||||
/// *smoothly* at its fire-time, and the reference arms them for every relay but **one**: the
|
||||
/// queued node's tag `0x26`, which `0x619030` (facing) and `0x619090` (position) both skip.
|
||||
///
|
||||
/// **That tag is the TELEPORT's, and this client believed for two years it was the
|
||||
/// heartbeat's** (decision 2064, correcting 0601/0603). A teleport is the one move smoothing
|
||||
/// cannot help — its position is a discontinuity, so blending toward it walks the mover, swept
|
||||
/// capsule and all, across the gap the teleport exists to skip. A heartbeat has no such
|
||||
/// problem and the reference blends it like anything else.
|
||||
/// Whether this move arms the pre-fire facing and position blends: every relay but a teleport
|
||||
/// (node tag `0x26`, skipped by `0x619030` and `0x619090`); a heartbeat blends like any other.
|
||||
pub(crate) fn reconciles(&self) -> bool {
|
||||
self.verb != RelayVerb::Teleport
|
||||
}
|
||||
}
|
||||
|
||||
/// One **scheduled** relayed move — the reference's queued move-event node (`0x617570`: fire-time at
|
||||
/// `node[+8]`, pose at `node[+0x10]`).
|
||||
/// A scheduled relayed move, the reference's queued node (`0x617570`: fire-time at `node[+8]`,
|
||||
/// pose at `node[+0x10]`).
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PendingMove {
|
||||
/// When to apply, on the real-time ms clock (the reference's `ev[+8]`).
|
||||
/// On the real-time ms clock.
|
||||
pub(crate) fire_ms: f64,
|
||||
/// What to apply.
|
||||
pub(crate) mv: RelayMove,
|
||||
}
|
||||
|
||||
/// The reference's skew clamp (`0x618c30 @0x618d0d` and `@0x618d49`: `lea eax,[skew+0x1f4]; cmp
|
||||
/// eax,0x5dc` saturating the biased value into `[0, 1500]`): a fire-time never lands more than 500 ms
|
||||
/// **before** its packet's arrival, nor more than 1000 ms after.
|
||||
/// The skew clamp (`@0x618d0d`, `@0x618d49`: `skew + 0x1f4` saturated into `[0, 0x5dc]`).
|
||||
const SKEW_MIN_MS: f64 = -500.0;
|
||||
const SKEW_MAX_MS: f64 = 1000.0;
|
||||
|
||||
/// The lateness window: 32 packets — `0x618b50`'s ring at `CMovement+0xbc` with its cursor at `+0x13c`
|
||||
/// masked `& 0x1f` (32 dwords is exactly `0x13c − 0xbc`).
|
||||
/// `0x618b50`'s ring at `CMovement+0xbc`, cursor at `+0x13c` masked `& 0x1f`.
|
||||
const LATENESS_WINDOW: usize = 32;
|
||||
|
||||
/// The "this mover is mid-motion" mask the re-base gates on — `0x618c30 @0x618ce4`'s
|
||||
/// `test [esi+0x40],0x20ff`: the four direction bits, both turn bits, both pitch bits (`0x40`/`0x80`),
|
||||
/// and `FALLING` (`0x2000`). While any is set the chain keeps the sender's pacing untouched; only a
|
||||
/// standing, un-queued mover re-sizes the buffer.
|
||||
/// The mid-motion mask that blocks a re-base (`@0x618ce4`, `test [esi+0x40],0x20ff`): direction,
|
||||
/// turn and pitch bits and `FALLING`.
|
||||
const BUSY_MASK: u32 = 0x20ff;
|
||||
|
||||
/// A remote unit's replay chain — the per-unit `CMovement` timing cells (see the module header).
|
||||
/// Lives on [`super::RemoteMotion`]; one per mover, never shared (the pre-0615 estimator was a single
|
||||
/// app-wide resource, which is structurally wrong: two movers behind different network paths must
|
||||
/// carry different buffers).
|
||||
/// A remote unit's replay chain, one per mover: two movers on different paths carry different
|
||||
/// buffers.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct RelayChain {
|
||||
/// Whether the chain has seen its first packet — the reference's `CMovement+0x40` bit 31, set
|
||||
/// alongside the first seed of the two cells below (`0x618c54-6a`).
|
||||
/// `CMovement+0x40` bit 31, set with the first seed (`0x618c54`-`0x618c6a`).
|
||||
seeded: bool,
|
||||
/// `+0xa8` — the fire-time the previous packet was given (client ms). The whole chain hangs off
|
||||
/// this: the next fire is *this* plus the wire step.
|
||||
/// `+0xa8`: the previous packet's fire-time.
|
||||
last_fire_ms: f64,
|
||||
/// `+0xac` — the previous packet's wire stamp. Advanced only by a **forward** step
|
||||
/// (`@0x618cb8`: `jle` skips the store), so a stale or duplicate stamp can't drag the chain back.
|
||||
/// `+0xac`: the previous wire stamp, advanced only by a forward step (`@0x618cb8`).
|
||||
last_wire_ms: u32,
|
||||
/// `+0xbc[32]` — the lateness ring: how late each of the last 32 packets ran against the chain,
|
||||
/// each stored **relative to [`Self::base_ms`] as it stood at that packet** (`0x618b50` writes
|
||||
/// `base + lateness`). That offset is what stops a spike already absorbed into the base from being
|
||||
/// charged a second time on the next re-base.
|
||||
/// `+0xbc[32]`: each packet's lateness stored as `base + lateness` at the time, so a spike
|
||||
/// already in the base is not charged twice.
|
||||
ring: [f64; LATENESS_WINDOW],
|
||||
/// `+0x13c` — the ring write cursor, wrapped at 32.
|
||||
/// `+0x13c`.
|
||||
ring_idx: usize,
|
||||
/// `+0x140` — the lateness base the ring is stored against, i.e. the buffer the chain currently
|
||||
/// holds. Re-based to the window max whenever an idle mover with an empty queue takes a packet.
|
||||
/// `+0x140`: the buffer the chain holds.
|
||||
base_ms: f64,
|
||||
}
|
||||
|
||||
impl RelayChain {
|
||||
/// Give this relayed move its client fire-time (`0x618c30`), advancing the chain.
|
||||
///
|
||||
/// `now_ms` is the packet's arrival on our real-time ms clock, `flags`/`queue_empty` the mover's
|
||||
/// state **before** this move applies (the reference reads `[esi+0x40]` and `[esi+0x150]` off the
|
||||
/// live CMovement at exactly this point). The caller applies the move at once if the returned
|
||||
/// fire-time is already due, else queues it for the drain — the reference's `mgr+0x128 ≥ fire`
|
||||
/// test (`@0x618dd2`), which for us is `fire ≤ now` because our arrival stamp *is* the frame clock
|
||||
/// the drain compares against.
|
||||
/// The move's fire-time (`0x618c30`); `flags` and `queue_empty` are the mover's state before
|
||||
/// it applies (`[esi+0x40]`, `[esi+0x150]`). The fire is stored at `@0x618dcc`; one at or
|
||||
/// before `now_ms` is due now (`@0x618dd2`).
|
||||
pub(crate) fn schedule(
|
||||
&mut self,
|
||||
wire_ms: u32,
|
||||
@@ -134,16 +84,13 @@ impl RelayChain {
|
||||
flags: u32,
|
||||
queue_empty: bool,
|
||||
) -> f64 {
|
||||
// First packet for this mover: anchor both cells on it, so the step below is zero and the
|
||||
// move fires at arrival (`@0x618c54`).
|
||||
// The first packet fires at arrival (`@0x618c54`).
|
||||
if !self.seeded {
|
||||
self.seeded = true;
|
||||
self.last_wire_ms = wire_ms;
|
||||
self.last_fire_ms = now_ms;
|
||||
}
|
||||
// The sender's own step between packets. Wrapping, because the server's ms clock is a `u32`
|
||||
// (vmangos uptime ms — it wraps every 49.7 days); a non-forward step contributes nothing and
|
||||
// leaves the reference stamp alone (`@0x618cb6-c2`).
|
||||
// The server's `u32` ms clock wraps; a non-forward step adds nothing (`@0x618cb6`-`c2`).
|
||||
let step = wire_ms.wrapping_sub(self.last_wire_ms) as i32;
|
||||
let wire_delta = if step > 0 {
|
||||
self.last_wire_ms = wire_ms;
|
||||
@@ -151,15 +98,12 @@ impl RelayChain {
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// How much the flight took beyond the chain's own pacing. `skew` is what the reference writes
|
||||
// out at `@0x618ccf`; note `now + skew == last_fire + wire_delta`, which IS the law: replay
|
||||
// paced by the sender's stamps.
|
||||
// `skew` (`@0x618ccf`) makes `now + skew == last_fire + wire_delta`.
|
||||
let arrival_delta = now_ms - self.last_fire_ms;
|
||||
let mut skew = wire_delta - arrival_delta;
|
||||
let window_max = self.record_lateness(arrival_delta - wire_delta);
|
||||
// The buffer is only ever re-sized on a standing mover with nothing queued (`@0x618ce4`,
|
||||
// `@0x618cf3`): re-base by the worst lateness the window still remembers, never letting the
|
||||
// chain step backwards past the previous fire (`@0x618d3b`).
|
||||
// Only a standing mover with nothing queued re-bases (`@0x618ce4`, `@0x618cf3`; the re-base
|
||||
// `@0x618cfc`-`d41`), never stepping back past the previous fire (`@0x618d3b`).
|
||||
if flags & BUSY_MASK == 0 && queue_empty {
|
||||
skew = (skew + window_max - self.base_ms).clamp(SKEW_MIN_MS, SKEW_MAX_MS);
|
||||
if now_ms + skew < self.last_fire_ms {
|
||||
@@ -172,38 +116,23 @@ impl RelayChain {
|
||||
fire_ms
|
||||
}
|
||||
|
||||
/// **The sender skipped time** (`MSG_MOVE_TIME_SKIPPED`, decision 1935): advance this chain's
|
||||
/// copy of the mover's wire clock by `lag_ms`, without scheduling anything. The reference does
|
||||
/// exactly this and nothing else — `0x603b40` resolves the unit and `0x61ab90` runs
|
||||
/// `[CMovement+0xac] += lag`, which is this field.
|
||||
///
|
||||
/// It has to be applied even though no pose moved, because [`Self::schedule`] pays the whole
|
||||
/// chain off `last_wire_ms`: leave it short and the mover's next real packet reads as a `step`
|
||||
/// `lag` ms larger than it was, buys that much extra `wire_delta`, and fires `lag` late — a
|
||||
/// hitch on one unit that looks like packet loss and isn't. Wrapping, for
|
||||
/// [`Self::schedule`]'s reason: the stamp is a `u32` ms clock that wraps every 49.7 days.
|
||||
///
|
||||
/// A chain that has never seen a packet is left alone: there is no reference stamp to advance
|
||||
/// yet, and the first real packet seeds both cells from itself.
|
||||
/// `MSG_MOVE_TIME_SKIPPED`: `[CMovement+0xac] += lag` and nothing else (`0x603b40`,
|
||||
/// `0x61ab90`); skipped, the next packet would fire `lag_ms` late.
|
||||
pub(crate) fn skip_time(&mut self, lag_ms: u32) {
|
||||
if self.seeded {
|
||||
self.last_wire_ms = self.last_wire_ms.wrapping_add(lag_ms);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record this packet's lateness and return the window's worst (`0x618b50`): store
|
||||
/// `base + lateness` at the cursor, advance it mod 32, and return the max over all 32 slots (the
|
||||
/// entry just written included — the reference seeds its scan with it and walks the whole ring).
|
||||
/// An unfilled slot reads as the zero the reference's freshly-constructed CMovement holds.
|
||||
/// Stores `base + lateness` and returns the max of all 32 slots, the new one included
|
||||
/// (`0x618b50`); an unfilled slot is zero, as in a fresh `CMovement`.
|
||||
fn record_lateness(&mut self, lateness_ms: f64) -> f64 {
|
||||
self.ring[self.ring_idx] = self.base_ms + lateness_ms;
|
||||
self.ring_idx = (self.ring_idx + 1) % LATENESS_WINDOW;
|
||||
self.ring.iter().copied().fold(f64::NEG_INFINITY, f64::max)
|
||||
}
|
||||
|
||||
/// The scheduling lead this move got — `fire − arrival`, i.e. how long the dead-reckon has to
|
||||
/// cover before the pose lands. Negative means the move was already due and applies at arrival.
|
||||
/// Trace/diagnostic only (`super::remote::trace_schedule`).
|
||||
/// `fire - arrival`, for the trace; negative means the move applied at arrival.
|
||||
pub(crate) fn lead_ms(&self, now_ms: f64) -> f64 {
|
||||
self.last_fire_ms - now_ms
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
//! Unit tests for the pure motion kernels — the spline sampler, the dead-reckoning integrator,
|
||||
//! the jump ballistics, and the facing turn (each child module's math, exercised together here
|
||||
//! like [`super`]'s original single-file block).
|
||||
//! Tests for the motion kernels: the spline sampler, dead-reckoning, jump ballistics, the relay
|
||||
//! chain, facing and gameobject placement.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -31,22 +30,17 @@ fn speeds() -> MoveSpeeds {
|
||||
|
||||
#[test]
|
||||
fn remote_fall_arc_reports_height_only_on_the_landing_edge() {
|
||||
// Takeoff (grounded → FALLING): snapshot this Z, report nothing yet.
|
||||
assert_eq!(fall_arc_step(false, true, None, 100.0), (Some(100.0), None));
|
||||
// Still airborne (FALLING → FALLING): hold the takeoff Z, still nothing.
|
||||
assert_eq!(
|
||||
fall_arc_step(true, true, Some(100.0), 80.0),
|
||||
(Some(100.0), None)
|
||||
);
|
||||
// Landing (FALLING → grounded) with a known takeoff: report the fall height (WoW Z up, so
|
||||
// takeoff − landing), and clear the reference.
|
||||
assert_eq!(
|
||||
fall_arc_step(true, false, Some(100.0), 70.0),
|
||||
(None, Some(30.0))
|
||||
);
|
||||
// Landing after entering view mid-fall (no takeoff seen): no height reference → no prediction.
|
||||
// Entered view mid-fall: no takeoff, no prediction.
|
||||
assert_eq!(fall_arc_step(true, false, None, 70.0), (None, None));
|
||||
// Grounded → grounded: nothing tracked, nothing reported.
|
||||
assert_eq!(fall_arc_step(false, false, None, 70.0), (None, None));
|
||||
}
|
||||
|
||||
@@ -67,16 +61,8 @@ fn motion(flags: u32, orientation: f32) -> RemoteMotion {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The observed swimmer's body actually tilts** — the swim body-pitch render law (`0x60a110`,
|
||||
/// decision 0464 §1), which shipped in July 2026 with no test of its own and no trace field, so the
|
||||
/// only instrument that could contradict it was the director's eye.
|
||||
///
|
||||
/// The law is [`crate::creature_anim::swim_body_rotation`] and it is now *one* function: this
|
||||
/// asserts the observed lane end to end — a relayed `MSG_MOVE_*` carrying `SWIMMING | FORWARD`
|
||||
/// and a nose-up pitch lands on [`RemoteMotion::pitch`], and the rotation the extrapolator writes
|
||||
/// decomposes back to exactly that pitch about the body's local X, with the yaw untouched and no
|
||||
/// roll. Then the three gates that must render LEVEL, because a zero tilt is only correct when
|
||||
/// one of them is the reason.
|
||||
/// The swim body-pitch render law (`0x60a110`) on a relayed swimmer, and the three cases that
|
||||
/// render level.
|
||||
#[test]
|
||||
fn a_relayed_swimmer_renders_pitched_and_the_gates_render_level() {
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
@@ -84,13 +70,12 @@ fn a_relayed_swimmer_renders_pitched_and_the_gates_render_level() {
|
||||
|
||||
use crate::creature_anim::swim_body_rotation;
|
||||
|
||||
// What the rotation the pose owners write decomposes back to: (yaw, pitch, roll).
|
||||
// (yaw, pitch, roll).
|
||||
let decompose = |flags: u32, pitch: f32, yaw: f32| {
|
||||
swim_body_rotation(yaw, flags, pitch).to_euler(EulerRot::YXZ)
|
||||
};
|
||||
|
||||
// The relay lands the wire pitch on the component (`apply_move`), and the extrapolator
|
||||
// renders it: a swimmer nose-down 0.6 rad on a 1.2 rad heading.
|
||||
// A swimmer nose-down 0.6 rad on a 1.2 rad heading.
|
||||
let mut rm = motion(0, 0.0);
|
||||
let mv = super::relay::RelayMove {
|
||||
wire_ms: 0,
|
||||
@@ -103,8 +88,7 @@ fn a_relayed_swimmer_renders_pitched_and_the_gates_render_level() {
|
||||
transport: None,
|
||||
verb: benilla_protocol::RelayVerb::Pose,
|
||||
};
|
||||
// Through the real arrival path, not a hand-set field: `apply_move` is what the relay and
|
||||
// the queue drain both go through, so this is the seam that would drop the pitch.
|
||||
// Through `apply_move`, the seam the relay and the queue drain share.
|
||||
let mut world = bevy::prelude::World::new();
|
||||
world.init_resource::<bevy::ecs::message::Messages<crate::creature_anim::HardLanding>>();
|
||||
let e = world.spawn_empty().id();
|
||||
@@ -134,13 +118,9 @@ fn a_relayed_swimmer_renders_pitched_and_the_gates_render_level() {
|
||||
);
|
||||
assert!(roll.abs() < 1e-5, "a swimmer never banks: {roll}");
|
||||
|
||||
// Nose-UP is the opposite sign through the same axis — the two must not collapse.
|
||||
let (_, up, _) = decompose(rm.flags, 0.6, 0.0);
|
||||
assert!((up - 0.6).abs() < 1e-5, "nose-up 0.6 rad renders: {up}");
|
||||
|
||||
// The three LEVEL gates. Each is a *correct* zero, which is exactly why the trace reports
|
||||
// the reported pitch beside the rendered one — otherwise they are indistinguishable from a
|
||||
// swimmer whose pitch never arrived.
|
||||
for (name, flags) in [
|
||||
("an idle floater", move_flags::SWIMMING),
|
||||
(
|
||||
@@ -158,13 +138,12 @@ fn a_relayed_swimmer_renders_pitched_and_the_gates_render_level() {
|
||||
|
||||
#[test]
|
||||
fn swim_dead_reckon_folds_the_pitch_into_the_travel() {
|
||||
// A swimmer's wire pitch folds into the travel direction the way the client's swim velocity
|
||||
// basis does (`0x7c5880`): vertical sin(pitch)·swim speed, horizontal scaled by cos(pitch).
|
||||
// `0x7c5880`: vertical sin(pitch) * swim speed, horizontal scaled by cos(pitch).
|
||||
let pitch = 0.5_f32;
|
||||
let mut rm = motion(move_flags::SWIMMING | move_flags::FORWARD, 0.0);
|
||||
rm.pitch = pitch;
|
||||
let (pos, _, vertical, speed) = rm.advance(speeds(), 1.0);
|
||||
// Facing 0 = WoW +X; swim speed 4.0 for 1 s.
|
||||
// Facing +X, swim speed 4.0 for 1 s.
|
||||
assert!(
|
||||
(pos[0] - 4.0 * pitch.cos()).abs() < 1e-4,
|
||||
"horizontal shrinks by cos(pitch): {}",
|
||||
@@ -181,7 +160,6 @@ fn swim_dead_reckon_folds_the_pitch_into_the_travel() {
|
||||
);
|
||||
assert!((speed - 4.0).abs() < 1e-5, "anim rate reads the 3D speed");
|
||||
|
||||
// Level swim (pitch 0) stays flat; an idle floater (no direction bits) doesn't drift.
|
||||
let level = motion(move_flags::SWIMMING | move_flags::FORWARD, 0.0);
|
||||
let (pos, ..) = level.advance(speeds(), 1.0);
|
||||
assert_eq!(pos[2], 0.0);
|
||||
@@ -194,29 +172,26 @@ fn swim_dead_reckon_folds_the_pitch_into_the_travel() {
|
||||
#[test]
|
||||
fn resolve_facing_angle_spot_and_target() {
|
||||
let none = |_g: u64| None;
|
||||
// Angle is verbatim.
|
||||
assert_eq!(
|
||||
resolve_facing(MonsterMoveFacing::Angle(1.25), [0.0; 3], none),
|
||||
Some(1.25)
|
||||
);
|
||||
// Spot due WoW +X (north) from the unit → orientation 0.
|
||||
// WoW +X is north: orientation 0.
|
||||
assert_eq!(
|
||||
resolve_facing(MonsterMoveFacing::Spot([5.0, 0.0, 0.0]), [0.0; 3], none),
|
||||
Some(0.0)
|
||||
);
|
||||
// Spot due WoW +Y (west) → orientation +π/2.
|
||||
// WoW +Y is west: orientation +pi/2.
|
||||
assert_eq!(
|
||||
resolve_facing(MonsterMoveFacing::Spot([0.0, 5.0, 0.0]), [0.0; 3], none),
|
||||
Some(std::f32::consts::FRAC_PI_2)
|
||||
);
|
||||
// Target resolves through the lookup; the bearing uses the unit's own position as origin.
|
||||
assert_eq!(
|
||||
resolve_facing(MonsterMoveFacing::Target(0x42), [1.0, 1.0, 0.0], |g| {
|
||||
(g == 0x42).then_some([1.0, 6.0, 0.0])
|
||||
}),
|
||||
Some(std::f32::consts::FRAC_PI_2)
|
||||
);
|
||||
// None, an unknown target, and a coincident point all yield no facing (never a spin-to-0).
|
||||
assert_eq!(
|
||||
resolve_facing(MonsterMoveFacing::None, [0.0; 3], none),
|
||||
None
|
||||
@@ -238,7 +213,6 @@ fn resolve_facing_angle_spot_and_target() {
|
||||
|
||||
#[test]
|
||||
fn remote_motion_runs_forward_along_facing() {
|
||||
// Facing WoW +X (orientation 0), moving forward for 1s at run 7 → +7 in X, no Y, no turn.
|
||||
let (pos, o, _vz, speed) = motion(move_flags::FORWARD, 0.0).advance(speeds(), 1.0);
|
||||
assert!((pos[0] - 7.0).abs() < 1e-3, "forward advances +X: {pos:?}");
|
||||
assert!(pos[1].abs() < 1e-3, "no lateral drift: {pos:?}");
|
||||
@@ -248,7 +222,6 @@ fn remote_motion_runs_forward_along_facing() {
|
||||
|
||||
#[test]
|
||||
fn remote_motion_backpedal_uses_run_back_speed() {
|
||||
// Facing +X, BACKWARD with no forward override → moves −X at the slower run-back speed.
|
||||
let (pos, _o, _vz, speed) = motion(move_flags::BACKWARD, 0.0).advance(speeds(), 1.0);
|
||||
assert!(
|
||||
(pos[0] + 4.5).abs() < 1e-3,
|
||||
@@ -257,12 +230,8 @@ fn remote_motion_backpedal_uses_run_back_speed() {
|
||||
assert_eq!(speed, 4.5);
|
||||
}
|
||||
|
||||
/// **A walking remote is a walking remote in every direction** (decision 1752). This block used to
|
||||
/// test the backpedal ahead of the walk bit, so an observed player who toggled walk and pressed S
|
||||
/// extrapolated at run-back speed (4.5) — fast enough to clear the `> 2× walkSpeed` boundary and
|
||||
/// play the *run* clip while its owner walked. The bytes take the walk arm (`0x7c4d11` →
|
||||
/// `0x7c4d4d`) before the run arm's backward min (`0x7c4d1d`), so both directions are
|
||||
/// `min(walk, run)` = 2.5.
|
||||
/// The walk arm (`0x7c4d11` to `0x7c4d4d`) comes before the run arm's backward min (`0x7c4d1d`),
|
||||
/// so a walk is `min(walk, run)` in both directions.
|
||||
#[test]
|
||||
fn a_walking_remote_backpedals_at_walk_speed_not_run_back() {
|
||||
let s = speeds();
|
||||
@@ -281,10 +250,10 @@ fn a_walking_remote_backpedals_at_walk_speed_not_run_back() {
|
||||
"…and a WALKING backpedal is still 2.5, not run_back's 4.5: {pos:?}"
|
||||
);
|
||||
assert_eq!(speed, 2.5);
|
||||
// The control: without the walk bit the same press is the run-back min, unchanged.
|
||||
// Without the walk bit, the run-back min.
|
||||
let (_pos, _o, _vz, speed) = motion(move_flags::BACKWARD, 0.0).advance(s, 1.0);
|
||||
assert_eq!(speed, 4.5);
|
||||
// …and swimming still pre-empts the walk bit entirely — there is no swim-walk.
|
||||
// Swimming pre-empts the walk bit.
|
||||
let (_pos, _o, _vz, speed) = motion(
|
||||
move_flags::FORWARD | move_flags::SWIMMING | move_flags::WALK_MODE,
|
||||
0.0,
|
||||
@@ -295,9 +264,7 @@ fn a_walking_remote_backpedals_at_walk_speed_not_run_back() {
|
||||
|
||||
#[test]
|
||||
fn remote_motion_swim_backpedal_takes_min_of_the_swim_pair() {
|
||||
// The byte law (`0x7c4c90`'s backward arms): backward speed is
|
||||
// `min(back, forward)` for both pairs — the plain back speed whenever it's the slower
|
||||
// (always, at vanilla values), clamped if a server force-sets it above the forward speed.
|
||||
// `0x7c4c90`'s backward arms: `min(back, forward)` for both pairs.
|
||||
let mut s = speeds();
|
||||
s.swim_back = 2.5;
|
||||
let (pos, _o, _vz, speed) =
|
||||
@@ -307,7 +274,7 @@ fn remote_motion_swim_backpedal_takes_min_of_the_swim_pair() {
|
||||
"swim backpedal advances −X by swim_back: {pos:?}"
|
||||
);
|
||||
assert_eq!(speed, 2.5);
|
||||
s.swim_back = 9.0; // above forward swim (4.0) — the min clamps to swim
|
||||
s.swim_back = 9.0; // above forward swim (4.0)
|
||||
let (_pos, _o, _vz, speed) =
|
||||
motion(move_flags::SWIMMING | move_flags::BACKWARD, 0.0).advance(s, 1.0);
|
||||
assert_eq!(
|
||||
@@ -318,7 +285,7 @@ fn remote_motion_swim_backpedal_takes_min_of_the_swim_pair() {
|
||||
|
||||
#[test]
|
||||
fn remote_motion_strafe_left_moves_90deg_left() {
|
||||
// Facing +X (north), strafe-left is +90° → +Y (west in WoW), at run speed.
|
||||
// Facing +X (north), strafe-left is +Y (west), at run speed.
|
||||
let (pos, o, _vz, _s) = motion(move_flags::STRAFE_LEFT, 0.0).advance(speeds(), 1.0);
|
||||
assert!(
|
||||
(pos[1] - 7.0).abs() < 1e-3,
|
||||
@@ -330,7 +297,6 @@ fn remote_motion_strafe_left_moves_90deg_left() {
|
||||
|
||||
#[test]
|
||||
fn remote_motion_turn_in_place_rotates_facing_only() {
|
||||
// TURN_LEFT with no translation: facing rotates by +turn_rate·dt; no position change, speed 0.
|
||||
let (pos, o, _vz, speed) = motion(move_flags::TURN_LEFT, 0.0).advance(speeds(), 0.5);
|
||||
assert!(
|
||||
(o - std::f32::consts::FRAC_PI_2).abs() < 1e-3,
|
||||
@@ -354,8 +320,7 @@ fn remote_motion_stationary_when_no_move_flags() {
|
||||
|
||||
#[test]
|
||||
fn jump_seed_derives_velocity_and_clamps() {
|
||||
// The wire zspeed is DOWN-positive (a rising jump is negative — VERIFIED, the real client sends
|
||||
// -7.955547): the take-off UP-speed is `-zspeed`. Horizontal = (cos,sin)·xyspeed (world XY).
|
||||
// The wire zspeed is down-positive: the 1.12 client sends -7.955547 for a rising jump.
|
||||
let j = JumpInfo {
|
||||
zspeed: -7.955_547,
|
||||
cos_angle: 1.0,
|
||||
@@ -371,27 +336,23 @@ fn jump_seed_derives_velocity_and_clamps() {
|
||||
(xy[0] - 7.0).abs() < 1e-3 && xy[1].abs() < 1e-3,
|
||||
"horizontal +X: {xy:?}"
|
||||
);
|
||||
// Mid-fall (1s in): up-speed = -zspeed − g·t (now negative, descending).
|
||||
// 1 s in: -zspeed - g * t.
|
||||
let (vz1, _) = jump_seed(Some(j), 1000, false);
|
||||
assert!(
|
||||
(vz1 - (7.955_547 - GRAVITY)).abs() < 1e-3,
|
||||
"vertical decays by gravity: {vz1}"
|
||||
);
|
||||
// A long fall is clamped to terminal velocity.
|
||||
let (vzt, _) = jump_seed(Some(j), 10_000, false);
|
||||
assert!(
|
||||
(vzt + TERMINAL_VELOCITY).abs() < 1e-3,
|
||||
"clamped to −terminal: {vzt}"
|
||||
);
|
||||
// A non-jumping packet → grounded: no vertical, no horizontal freeze.
|
||||
assert_eq!(jump_seed(None, 0, false), (0.0, [0.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_motion_jump_is_a_parabola_not_flag_walking() {
|
||||
// Airborne (JUMPING) with a frozen +X launch of 7 yd/s and +Z 7.955547 yd/s. Even though the
|
||||
// FORWARD flag is set, the horizontal is the *frozen* launch (not run speed), and the height
|
||||
// follows the arc under gravity — the launch played out locally, not flag-driven walking.
|
||||
// FORWARD is set, but the horizontal is the frozen 7 yd/s launch, not run speed.
|
||||
let mut rm = motion(move_flags::FALLING | move_flags::FORWARD, 0.0);
|
||||
rm.vertical_velocity = 7.955_547;
|
||||
rm.jump_xy_vel = [7.0, 0.0];
|
||||
@@ -401,12 +362,7 @@ fn remote_motion_jump_is_a_parabola_not_flag_walking() {
|
||||
"horizontal coasts at the frozen 7 yd/s: {pos:?}"
|
||||
);
|
||||
assert!(pos[1].abs() < 1e-3, "no lateral drift: {pos:?}");
|
||||
// **The analytic height, `v₀·t − ½g·t²`** (decision 1740). This asserted `v₀·t` until the
|
||||
// airborne-integrator round — explicit Euler, moving the whole step at the START-of-step speed
|
||||
// with no gravity in the displacement at all, which over this 0.5 s step is 3.98 yd against a
|
||||
// true 1.57. The local mover had the mirror-image error in the other direction. Both now run
|
||||
// the one exact step ([`crate::player::mover::fall_step`]), so this is the closed form and it
|
||||
// holds at any `dt`.
|
||||
// The closed form `v0 * t - g * t^2 / 2`, exact at any `dt`.
|
||||
let analytic = 7.955_547 * 0.5 - 0.5 * GRAVITY * 0.5 * 0.5;
|
||||
assert!(
|
||||
(pos[2] - analytic).abs() < 1e-3,
|
||||
@@ -425,7 +381,7 @@ fn remote_motion_jump_is_a_parabola_not_flag_walking() {
|
||||
|
||||
#[test]
|
||||
fn spline_interpolates_constant_speed_and_faces_travel() {
|
||||
// Two legs: 10 yd east (+X), then 10 yd north-ish (+Y), over 4s total (constant speed → 2s/leg).
|
||||
// Two 10 yd legs over 4 s: 2 s each at constant speed.
|
||||
let start = Instant::now();
|
||||
let s = Spline {
|
||||
deck: None,
|
||||
@@ -465,8 +421,7 @@ fn spline_interpolates_constant_speed_and_faces_travel() {
|
||||
|
||||
#[test]
|
||||
fn spline_travel_pitch_is_the_segment_climb_angle() {
|
||||
// A 45° climbing leg (10 yd east, 10 yd up) reports pitch asin(dz/len) = π/4 (+up) — the
|
||||
// observed-mover pitch rule `asin(dir.z)` the swimming-creature body pitch renders.
|
||||
// A 45 degree climb reports pitch `asin(dir.z)` = pi/4, up positive.
|
||||
let start = Instant::now();
|
||||
let s = Spline {
|
||||
deck: None,
|
||||
@@ -487,8 +442,6 @@ fn spline_travel_pitch_is_the_segment_climb_angle() {
|
||||
|
||||
#[test]
|
||||
fn monster_move_carries_every_waypoint() {
|
||||
// The whole decoded polyline rides into the spline — a curved patrol keeps its corners, not a
|
||||
// straight start→endpoint collapse. `sample` (tested above) then walks all of them constant-speed.
|
||||
let path = vec![
|
||||
[0.0, 0.0, 0.0],
|
||||
[10.0, 0.0, 0.0],
|
||||
@@ -512,26 +465,21 @@ fn monster_move_carries_every_waypoint() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **`MSG_MOVE_TIME_SKIPPED` advances the chain's wire clock and nothing else** (decision 1935).
|
||||
/// The whole of the reference's handler is `[CMovement+0xac] += lag` (`0x603b40` → `0x61ab90`),
|
||||
/// and `+0xac` is this chain's `last_wire_ms`. The property that matters is downstream: after the
|
||||
/// skip, the mover's next packet — whose stamp is `lag` further on than it would otherwise have
|
||||
/// been — must schedule as though nothing unusual happened. Drop the skip and that same packet
|
||||
/// buys `lag` ms of extra `wire_delta` and fires that much late.
|
||||
/// `MSG_MOVE_TIME_SKIPPED` is `[CMovement+0xac] += lag` (`0x603b40` to `0x61ab90`): the next
|
||||
/// packet schedules as if nothing were skipped.
|
||||
#[test]
|
||||
fn a_reported_skip_keeps_the_relay_chain_level_with_the_sender() {
|
||||
let step = |chain: &mut RelayChain, wire_ms: u32, now_ms: f64| {
|
||||
chain.schedule(wire_ms, now_ms, 0, true)
|
||||
};
|
||||
// Two chains fed identically, except that one is told about the sender's 300 ms skip.
|
||||
// Two chains, one told about the sender's 300 ms skip.
|
||||
let (mut told, mut untold) = (RelayChain::default(), RelayChain::default());
|
||||
step(&mut told, 10_000, 0.0);
|
||||
step(&mut untold, 10_000, 0.0);
|
||||
|
||||
told.skip_time(300);
|
||||
|
||||
// The sender's next packet: 100 ms of real play later, but its stamp has ALSO carried the
|
||||
// 300 ms it skipped — so the wire step is 400 while only 100 ms of our clock passed.
|
||||
// 100 ms later on our clock, but the stamp carries the skip too: a wire step of 400.
|
||||
let a = step(&mut told, 10_400, 100.0);
|
||||
let b = step(&mut untold, 10_400, 100.0);
|
||||
assert!(
|
||||
@@ -546,8 +494,6 @@ fn a_reported_skip_keeps_the_relay_chain_level_with_the_sender() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A skip for a mover whose chain has never seen a packet is inert: there is no reference stamp
|
||||
/// to advance, and the first real packet seeds both cells off itself.
|
||||
#[test]
|
||||
fn a_skip_before_the_first_packet_changes_nothing() {
|
||||
let mut seeded = RelayChain::default();
|
||||
@@ -562,7 +508,6 @@ fn a_skip_before_the_first_packet_changes_nothing() {
|
||||
|
||||
#[test]
|
||||
fn monster_move_flying_spline_is_not_grounded() {
|
||||
// A FLYING path keeps the server's Z — the ground-clamp must leave it alone.
|
||||
let s = monster_move_spline(
|
||||
vec![[0.0, 0.0, 0.0], [10.0, 0.0, 50.0]],
|
||||
0,
|
||||
@@ -621,13 +566,10 @@ fn monster_move_without_a_travelable_path_clears_the_spline() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Flags that make the chain treat a mover as mid-motion (`0x20ff`'s FORWARD bit is enough).
|
||||
/// Any `0x20ff` bit makes the chain treat a mover as mid-motion.
|
||||
const MOVING: u32 = move_flags::FORWARD;
|
||||
|
||||
/// **The headline property** (decision 0615, the reference's `0x618c30`): while a mover is moving,
|
||||
/// replay is paced by the *sender's* stamps — `fire = prev fire + wire step` — so however the packets
|
||||
/// clumped in flight, they replay at the spacing the stamps carry. Here four 500 ms-apart stamps
|
||||
/// arrive at 0 / 520 / 1450 / 1460 ms (one late, then a two-packet burst) and still fire 500 ms apart.
|
||||
/// `0x618c30`: stamps 500 ms apart arriving late and in a burst still fire 500 ms apart.
|
||||
#[test]
|
||||
fn relay_chain_replays_on_the_senders_cadence() {
|
||||
let mut chain = RelayChain::default();
|
||||
@@ -643,9 +585,8 @@ fn relay_chain_replays_on_the_senders_cadence() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The chain's two seeds and its guards: the first packet anchors both cells and fires at arrival; a
|
||||
/// stale/duplicate stamp contributes no step (`@0x618cb8`'s `jle`); and the server's `u32` ms clock
|
||||
/// wrapping mid-session is just another forward step.
|
||||
/// The first packet fires at arrival, a stale stamp adds no step (`@0x618cb8`), and the `u32`
|
||||
/// clock's wrap is a forward step.
|
||||
#[test]
|
||||
fn relay_chain_seeds_holds_stale_stamps_and_survives_the_clock_wrap() {
|
||||
let mut chain = RelayChain::default();
|
||||
@@ -654,8 +595,7 @@ fn relay_chain_seeds_holds_stale_stamps_and_survives_the_clock_wrap() {
|
||||
4_000.0,
|
||||
"first packet: fire at arrival, whatever the server's clock reads"
|
||||
);
|
||||
// A re-sent / out-of-order stamp: no forward step, so the chain doesn't advance past the
|
||||
// previous fire (and the reference stamp is left alone — the next real step measures from it).
|
||||
// Re-sent and out-of-order stamps; the next step measures from the last that counted.
|
||||
assert_eq!(chain.schedule(7_000, 4_100.0, MOVING, true), 4_000.0);
|
||||
assert_eq!(chain.schedule(6_900, 4_200.0, MOVING, true), 4_000.0);
|
||||
assert_eq!(
|
||||
@@ -663,7 +603,7 @@ fn relay_chain_seeds_holds_stale_stamps_and_survives_the_clock_wrap() {
|
||||
4_300.0,
|
||||
"the next forward stamp steps 300 ms from the last one that counted"
|
||||
);
|
||||
// The wrap: 150 is 251 ms after u32::MAX − 100 on a wrapping ms clock.
|
||||
// 150 is 251 ms after u32::MAX - 100.
|
||||
let mut chain = RelayChain::default();
|
||||
chain.schedule(u32::MAX - 100, 0.0, MOVING, true);
|
||||
assert_eq!(
|
||||
@@ -673,36 +613,20 @@ fn relay_chain_seeds_holds_stale_stamps_and_survives_the_clock_wrap() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **What the chain would do with a server-authored SELF move** — the measurement decision 0725's
|
||||
/// inline apply rests on, rather than an assertion about it. The reference has one move machine and
|
||||
/// routes a self-addressed `MSG_MOVE_*` (a GM `.go forward`, a `.cheat fly` toggle, an anticheat
|
||||
/// snap-back) through this same chain, so the honest question is what fire-time it hands one.
|
||||
///
|
||||
/// Two arms, and the second is the one that matters. **Fresh chain → arrival**: the local mover's
|
||||
/// chain is fed by these packets and nothing else, and in ordinary play none arrive at all, so the
|
||||
/// first one is a seed and fires immediately. **Seeded chain → the sender's cadence, held**: once
|
||||
/// there are stamps to pace against, a packet that arrives *earlier* than the previous fire plus
|
||||
/// the wire step is deliberately delayed to preserve the spacing the stamps carry. That is the
|
||||
/// chain's headline property working exactly as designed — and it is the property benilla's self
|
||||
/// arm skips, because reproducing the "cadence" between one GM command and the next buys nothing
|
||||
/// while delaying a correction to our own pose.
|
||||
///
|
||||
/// The stamps are vmangos's own: `SetAsServerSide` writes a fresh `WorldTimer::getMSTime()` into
|
||||
/// `stime`, so the wire steps track the real time between the commands.
|
||||
/// What the reference's chain does with a server-authored self move, which benilla applies at
|
||||
/// once: the first fires at arrival, a later early one is held to the cadence. The stamps are
|
||||
/// server time (vmangos `MovementInfo.h:208`).
|
||||
#[test]
|
||||
fn the_chain_paces_a_server_authored_self_move_and_would_hold_an_early_one() {
|
||||
let mut chain = RelayChain::default();
|
||||
// Standing still, nothing queued — the state a GM command finds us in.
|
||||
assert_eq!(
|
||||
chain.schedule(1_000, 0.0, 0, true),
|
||||
0.0,
|
||||
"the first server-authored move seeds the chain and fires at arrival"
|
||||
);
|
||||
// 30 s later, arriving 140 ms behind the chain's pacing: the pacing law says fire at arrival
|
||||
// (a late packet is already overdue), and the lateness enters the window.
|
||||
// 30 s later, 140 ms late: fires at arrival.
|
||||
assert_eq!(chain.schedule(31_000, 30_140.0, 0, true), 30_140.0);
|
||||
// 30 s later again, arriving 90 ms *ahead* of the pacing. The chain holds it back to keep the
|
||||
// sender's spacing — so the reference would apply this one 90 ms after it landed.
|
||||
// 30 s later again, 90 ms early: held 90 ms.
|
||||
assert_eq!(
|
||||
chain.schedule(61_000, 60_050.0, 0, true),
|
||||
60_140.0,
|
||||
@@ -710,38 +634,31 @@ fn the_chain_paces_a_server_authored_self_move_and_would_hold_an_early_one() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The de-jitter buffer is re-sized **only** on a standing mover with an empty queue (`@0x618ce4` /
|
||||
/// `@0x618cf3`), and it is sized by the window's worst lateness (`0x618b50`) — then held, not
|
||||
/// re-charged: the ring stores lateness *relative to the base*, so a spike already absorbed doesn't
|
||||
/// buy a second helping of buffer on the next idle packet.
|
||||
/// Only a standing mover with an empty queue re-bases (`@0x618ce4`, `@0x618cf3`), by the window's
|
||||
/// worst lateness (`0x618b50`), and an absorbed spike is not charged again.
|
||||
#[test]
|
||||
fn relay_chain_rebases_the_buffer_only_when_idle_and_unqueued() {
|
||||
// A 200 ms-late packet enters the window while the mover is moving: no re-base, no buffer.
|
||||
// A packet 200 ms late while moving.
|
||||
let mut chain = RelayChain::default();
|
||||
chain.schedule(0, 0.0, MOVING, true);
|
||||
assert_eq!(chain.schedule(500, 700.0, MOVING, true), 500.0);
|
||||
// Still moving when the next one lands: the chain stays glued to the sender's cadence.
|
||||
let mut moving = chain.clone();
|
||||
assert_eq!(
|
||||
moving.schedule(1000, 1000.0, MOVING, true),
|
||||
1000.0,
|
||||
"mid-motion: the 200 ms spike buys no buffer"
|
||||
);
|
||||
// The same packet on a mover that has come to a stop with nothing queued: NOW the chain
|
||||
// re-bases, and the buffer it takes is exactly the window's worst lateness.
|
||||
let mut idle = chain.clone();
|
||||
assert_eq!(
|
||||
idle.schedule(1000, 1000.0, 0, true),
|
||||
1200.0,
|
||||
"idle + empty: re-based by the window max (200 ms late)"
|
||||
);
|
||||
// ...and the next idle packet holds that buffer rather than charging the spike again.
|
||||
assert_eq!(
|
||||
idle.schedule(1500, 1500.0, 0, true),
|
||||
1700.0,
|
||||
"the absorbed spike is not re-charged: still 200 ms of lead"
|
||||
);
|
||||
// A queued event blocks the re-base even when the mover is idle.
|
||||
let mut queued = chain.clone();
|
||||
assert_eq!(
|
||||
queued.schedule(1000, 1000.0, 0, false),
|
||||
@@ -750,28 +667,24 @@ fn relay_chain_rebases_the_buffer_only_when_idle_and_unqueued() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The reference's skew clamp (`@0x618d0d`/`@0x618d49`): a fire never lands more than 1000 ms after
|
||||
/// its packet's arrival, nor more than 500 ms before it.
|
||||
/// The skew clamp (`@0x618d0d`, `@0x618d49`): a fire lands within 500 ms before and 1000 ms after
|
||||
/// arrival.
|
||||
#[test]
|
||||
fn relay_chain_holds_the_offset_inside_the_reference_clamp() {
|
||||
// A 2 s wire step delivered 100 ms after the last fire would schedule 1.9 s out — capped.
|
||||
// A 2 s wire step 100 ms after the last fire would be 1.9 s out.
|
||||
let mut chain = RelayChain::default();
|
||||
chain.schedule(0, 0.0, MOVING, true);
|
||||
assert_eq!(chain.schedule(2000, 100.0, MOVING, true), 1100.0);
|
||||
// A stalled sender (no forward step) whose packet lands 4 s after the last fire would schedule
|
||||
// 4 s in the past — floored at arrival − 500 ms, which is due-on-arrival either way.
|
||||
// A stalled sender 4 s after the last fire would be 4 s in the past.
|
||||
let mut chain = RelayChain::default();
|
||||
chain.schedule(0, 1000.0, MOVING, true);
|
||||
assert_eq!(chain.schedule(0, 5000.0, MOVING, true), 4500.0);
|
||||
}
|
||||
|
||||
/// Under a scripted jitter pattern — a steady stream, a stalled tail, a catch-up burst, an idle
|
||||
/// resync, then motion again — the chain stays well-formed: fire-times never go backwards (which is
|
||||
/// what lets the queue be a plain FIFO with no re-sort), and the lead over arrival stays inside the
|
||||
/// reference's clamp.
|
||||
/// Fire-times never go backwards, which lets the queue be a plain FIFO.
|
||||
#[test]
|
||||
fn relay_chain_stays_monotone_and_bounded_under_scripted_jitter() {
|
||||
// (wire stamp, arrival, moving?) — 500 ms stamps throughout; the arrivals are the abuse.
|
||||
// (wire stamp, arrival, moving): 500 ms stamps, jittered arrivals.
|
||||
let script: [(u32, f64, bool); 14] = [
|
||||
(500, 100.0, true), // first packet
|
||||
(1000, 600.0, true), // steady
|
||||
@@ -805,21 +718,18 @@ fn relay_chain_stays_monotone_and_bounded_under_scripted_jitter() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pre-fire reconcile lerp (decision 0601; the reference's `0x619090`/`0x6191c0`): an armed
|
||||
/// correction converges linearly in time and lands exactly on the event position at fire-time; a
|
||||
/// sub-tolerance prediction disagrees with nothing and the pose is untouched; Z joins the arm
|
||||
/// test only while swimming.
|
||||
/// The pre-fire reconcile (`0x619090`, `0x6191c0`): Z joins the arm test only while swimming.
|
||||
#[test]
|
||||
fn reconcile_lerp_lands_on_the_event_at_its_fire_time() {
|
||||
let target = [10.0, 0.0, 0.0];
|
||||
// Five 100 ms frames toward a fire 500 ms out: linear-in-time convergence, exact landing.
|
||||
// Five 100 ms frames toward a fire 500 ms out.
|
||||
let mut pos = [0.0, 0.0, 0.0];
|
||||
for i in 1..=5 {
|
||||
let remaining_after = 0.5 - 0.1 * i as f32;
|
||||
pos = reconcile_lerp(pos, pos, target, false, 0.1, remaining_after);
|
||||
}
|
||||
assert!((pos[0] - 10.0).abs() < 1e-4, "landed on the event: {pos:?}");
|
||||
// Prediction already agrees (within the 0.0278-yd tolerance): no correction at all.
|
||||
// Within the 0.0278 yd tolerance.
|
||||
let held = reconcile_lerp(
|
||||
[5.0, 5.0, 0.0],
|
||||
[10.0, 0.01, 0.0],
|
||||
@@ -829,7 +739,6 @@ fn reconcile_lerp_lands_on_the_event_at_its_fire_time() {
|
||||
0.4,
|
||||
);
|
||||
assert_eq!(held, [5.0, 5.0, 0.0], "sub-tolerance miss arms nothing");
|
||||
// A Z-only miss arms only while swimming (the reference's 2D-vs-3D flag split).
|
||||
let dry = reconcile_lerp(
|
||||
[0.0; 3],
|
||||
[10.0, 0.0, 1.0],
|
||||
@@ -843,42 +752,35 @@ fn reconcile_lerp_lands_on_the_event_at_its_fire_time() {
|
||||
assert_ne!(wet, [0.0; 3], "swimming: Z arms the correction");
|
||||
}
|
||||
|
||||
/// The pre-fire facing interp (the reference's `0x618f80` ω + `0x7c4f30` integrate — the only
|
||||
/// smoothed facing path a remote has): linear-in-time rotation landing exactly on the event's
|
||||
/// facing at fire-time, always the short way around the ±π fold, with a dead-zone for a
|
||||
/// negligible turn.
|
||||
/// The pre-fire facing interp (`0x618f80`, `0x7c4f30`) takes the short way round.
|
||||
#[test]
|
||||
fn facing_lerp_turns_the_short_way_and_lands_at_fire_time() {
|
||||
use std::f32::consts::TAU;
|
||||
// Five 100 ms frames toward a fire 500 ms out: lands exactly on the event facing.
|
||||
// Five 100 ms frames toward a fire 500 ms out.
|
||||
let mut o = 0.0f32;
|
||||
for i in 1..=5 {
|
||||
let remaining_after = 0.5 - 0.1 * i as f32;
|
||||
o = facing_lerp(o, 1.5, 0.1, remaining_after);
|
||||
}
|
||||
assert!((o - 1.5).abs() < 1e-4, "landed on the event facing: {o}");
|
||||
// The ±π fold: from 0.1 toward 6.2 (≈ −0.083 the short way) the first frame must rotate
|
||||
// NEGATIVE (through 0), never the ~6.1-rad long way.
|
||||
// From 0.1 toward 6.2, about -0.083 the short way, through 0.
|
||||
let stepped = facing_lerp(0.1, 6.2, 0.1, 0.4);
|
||||
assert!(
|
||||
stepped < 0.1 && stepped > 6.2 - TAU,
|
||||
"short way around: {stepped}"
|
||||
);
|
||||
// A sub-dead-zone delta isn't worth turning for.
|
||||
let held = facing_lerp(1.0, 1.0 + 1.0e-8, 0.1, 0.4);
|
||||
assert_eq!(held, 1.0, "dead-zone: negligible turn skipped");
|
||||
}
|
||||
|
||||
/// The frame loop as [`crate::net`] chains it: `apply_net_updates` routes each arriving packet
|
||||
/// (applied at arrival, or queued), then `drain_pending_moves` empties everything due — apply
|
||||
/// **before** drain, both on the same frame clock. Returns the order packets were actually applied
|
||||
/// in, tagged by their `fall_time`, plus the flags left on the unit at the end.
|
||||
/// The frame loop as [`crate::net`] chains it, arrival before drain on one clock; returns the
|
||||
/// applied order by `fall_time` and the final flags.
|
||||
fn replay_frames(script: &[(u32, f64, u32)]) -> (Vec<u32>, u32) {
|
||||
use super::relay::{PendingMove, RelayMove};
|
||||
let mut rm = motion(MOVING, 0.0);
|
||||
let mut applied = Vec::new();
|
||||
let apply = |rm: &mut RemoteMotion, mv: &RelayMove, applied: &mut Vec<u32>| {
|
||||
rm.flags = mv.flags; // the one bit of `apply_move` this ordering question turns on
|
||||
rm.flags = mv.flags; // the one part of `apply_move` the ordering needs
|
||||
applied.push(mv.fall_time);
|
||||
};
|
||||
for (id, &(wire_ms, arrival_ms, flags)) in script.iter().enumerate() {
|
||||
@@ -889,7 +791,7 @@ fn replay_frames(script: &[(u32, f64, u32)]) -> (Vec<u32>, u32) {
|
||||
orientation: 0.0,
|
||||
flags,
|
||||
pitch: 0.0,
|
||||
fall_time: id as u32, // the packet's identity, carried through the queue
|
||||
fall_time: id as u32, // the packet's identity
|
||||
jump: None,
|
||||
transport: None,
|
||||
verb: benilla_protocol::RelayVerb::Pose,
|
||||
@@ -911,21 +813,14 @@ fn replay_frames(script: &[(u32, f64, u32)]) -> (Vec<u32>, u32) {
|
||||
|
||||
#[test]
|
||||
fn a_due_arrival_never_jumps_the_queue() {
|
||||
// **The runaway-mover regression** (decision 0618). A mover's packets are applied in the order
|
||||
// they arrived, always — even when the newest one is already due on arrival while older ones sit
|
||||
// in the queue. Fire-times are monotone (0615), so a due arrival means everything queued is due
|
||||
// too: applying the arrival *directly* writes the newest state, and the drain then replays the
|
||||
// older queued packets over it in the same frame. Last write wins, last write is stale.
|
||||
//
|
||||
// The script: a 60 Hz burst that builds a one-deep queue, then a delivery stall (packet 4 lands
|
||||
// 64 ms late) so its fire-time — chained at 48 + 16 = 64 ms — is already past by arrival at 96.
|
||||
// Packet 4 is the STOP; packet 3, still queued in front of it, is FORWARD.
|
||||
// A 60 Hz burst builds a one-deep queue, then the Stop lands at 96 ms with its fire-time (64)
|
||||
// already past while the FORWARD packet 3 is still queued.
|
||||
let script: [(u32, f64, u32); 5] = [
|
||||
(1000, 0.0, MOVING), // 0 — seeds the chain, fires at arrival
|
||||
(1016, 0.0, MOVING), // 1 — same frame; fire 16, queued
|
||||
(1032, 16.0, MOVING), // 2 — fire 32, queued; the drain releases 1
|
||||
(1048, 32.0, MOVING), // 3 — fire 48, queued; the drain releases 2
|
||||
(1064, 96.0, 0), // 4 — the STOP. Fire 64, already due; 3 is still queued
|
||||
(1000, 0.0, MOVING), // 0: seeds, fires at arrival
|
||||
(1016, 0.0, MOVING), // 1: fire 16, queued
|
||||
(1032, 16.0, MOVING), // 2: fire 32, queued; the drain releases 1
|
||||
(1048, 32.0, MOVING), // 3: fire 48, queued; the drain releases 2
|
||||
(1064, 96.0, 0), // 4: the Stop, fire 64, due; 3 still queued
|
||||
];
|
||||
let (order, flags) = replay_frames(&script);
|
||||
assert_eq!(
|
||||
@@ -940,12 +835,11 @@ fn a_due_arrival_never_jumps_the_queue() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── GameObject placement: the `GAMEOBJECT_ROTATION` quaternion (decision 1459) ────────────────
|
||||
// ── GameObject placement: the `GAMEOBJECT_ROTATION` quaternion ────────────────
|
||||
|
||||
/// The seven `nightelfsignpostpointer02` arms of the Ravenwind post (Feralas, The Forgotten Coast)
|
||||
/// as vmangos' `gameobject` table spawns them: `(entry, position, orientation, rotation0..3)`.
|
||||
/// Six carry the pure-yaw encoding; entry 152580 — the one bug B89 was reported on — authors a
|
||||
/// 70° tilt in `rotation0/1`.
|
||||
/// The seven `nightelfsignpostpointer02` arms of the Ravenwind post (Feralas) from vmangos'
|
||||
/// `gameobject` table, `(entry, position, orientation, rotation0..3)`; six are pure yaw, and
|
||||
/// 152580 authors a 70 degree tilt in `rotation0/1`.
|
||||
const RAVENWIND_POST: [(u32, [f32; 3], f32, [f32; 4]); 7] = [
|
||||
(
|
||||
152574,
|
||||
@@ -991,17 +885,14 @@ const RAVENWIND_POST: [(u32, [f32; 3], f32, [f32; 4]); 7] = [
|
||||
),
|
||||
];
|
||||
|
||||
/// The middle of the pointer plank in the model's OWN space (WoW axes, Z up) — the mid-point of
|
||||
/// `nightelfsignpostpointer02.m2`'s collision hull, `benilla-extract m2coll`: x ±0.197,
|
||||
/// y 0.338‥2.075, z 3.062‥3.624. It is that offset from the origin — 3.3 yd up, ~1.2 yd out — that
|
||||
/// turns a dropped rotation into a *displaced* plank.
|
||||
/// The plank's mid-point in model space (WoW axes), the centre of the model's collision hull
|
||||
/// (`benilla-extract m2coll`: x +-0.197, y 0.338 to 2.075, z 3.062 to 3.624).
|
||||
const PLANK_MID_MODEL: [f32; 3] = [0.0, 1.206, 3.343];
|
||||
|
||||
/// Where the post itself stands (the six untilted arms all spawn on its axis).
|
||||
/// The post's axis, where the six untilted arms spawn.
|
||||
const POST_AXIS: [f32; 2] = [-4446.4, 2055.25];
|
||||
|
||||
/// Put a model-space point (WoW axes) through a spawn transform and read the answer back in WoW
|
||||
/// world coordinates — the mesh is baked into Bevy space, so the point converts on the way in.
|
||||
/// The plank's mid-point through a spawn transform, in WoW world coordinates.
|
||||
fn plank_in_world(position: [f32; 3], rotation: Quat) -> [f32; 3] {
|
||||
let t = super::pose_transform(position, rotation);
|
||||
benilla_assets::coords::bevy_to_wow(
|
||||
@@ -1009,10 +900,8 @@ fn plank_in_world(position: [f32; 3], rotation: Quat) -> [f32; 3] {
|
||||
)
|
||||
}
|
||||
|
||||
/// The pure-yaw quaternion vmangos writes for a spawn with no authored tilt IS `rot_z(facing)`, so
|
||||
/// placing by the quaternion must leave those spawns exactly where the facing put them. This is the
|
||||
/// whole safety argument for the switch: 54 747 of the 56 632 live spawn rows are this case, and if
|
||||
/// the two paths disagreed by even a hair, every prop in the world would shift.
|
||||
/// A pure-yaw quaternion is `rot_z(facing)` and places exactly like the facing; 54 747 of the
|
||||
/// 56 632 spawn rows are this case.
|
||||
#[test]
|
||||
fn a_pure_yaw_gameobject_quaternion_places_exactly_like_the_facing() {
|
||||
for orientation in [0.0_f32, 0.401426, -0.767946, -1.20428, 1.97222, 3.0, -2.7] {
|
||||
@@ -1024,7 +913,7 @@ fn a_pure_yaw_gameobject_quaternion_places_exactly_like_the_facing() {
|
||||
"orientation {orientation}: quat {by_quat:?} vs yaw {by_yaw:?}"
|
||||
);
|
||||
}
|
||||
// …and at the reported site, on the six real spawn rows that carry no tilt.
|
||||
// The six untilted spawn rows at the Ravenwind post.
|
||||
for (entry, position, orientation, quat) in RAVENWIND_POST.iter().take(6) {
|
||||
let by_quat = plank_in_world(
|
||||
*position,
|
||||
@@ -1039,10 +928,8 @@ fn a_pure_yaw_gameobject_quaternion_places_exactly_like_the_facing() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Entry 152580 — bug B89. Its authored 70° tilt is the difference between a plank ON the post and
|
||||
/// a plank 4.3 yd away in mid-air, and the spawn point itself is 3 yd off the post (the tilt is what
|
||||
/// carries the plank back onto it), so the facing-only placement cannot even land in the right
|
||||
/// neighbourhood.
|
||||
/// Entry 152580 spawns 3 yd off the post; its 70 degree tilt carries the plank back onto it, and
|
||||
/// facing-only placement leaves it 4.3 yd away in mid-air.
|
||||
#[test]
|
||||
fn the_tilted_ravenwind_pointer_lands_on_its_post() {
|
||||
let (_, position, orientation, quat) = RAVENWIND_POST[6];
|
||||
@@ -1052,18 +939,16 @@ fn the_tilted_ravenwind_pointer_lands_on_its_post() {
|
||||
);
|
||||
let by_yaw = plank_in_world(position, super::wire_yaw(orientation));
|
||||
|
||||
// The golden: hand-derived from the spawn row and the model's own hull, WoW world coordinates.
|
||||
// Hand-derived from the spawn row and the model's hull.
|
||||
for (got, want) in by_quat.iter().zip([-4444.139_f32, 2055.174, 46.512]) {
|
||||
assert!((got - want).abs() < 0.01, "{by_quat:?} vs the golden");
|
||||
}
|
||||
// What it means: the plank hangs off the post's axis at arm's length…
|
||||
let radius = |p: [f32; 3]| (p[0] - POST_AXIS[0]).hypot(p[1] - POST_AXIS[1]);
|
||||
assert!(
|
||||
radius(by_quat) < 2.5,
|
||||
"on the post: r = {}",
|
||||
radius(by_quat)
|
||||
);
|
||||
// …where the facing-only placement flings it clear of the post entirely, which is the report.
|
||||
assert!(radius(by_yaw) > 4.0, "off the post: r = {}", radius(by_yaw));
|
||||
let apart = (0..3)
|
||||
.map(|i| (by_quat[i] - by_yaw[i]).powi(2))
|
||||
@@ -1075,9 +960,7 @@ fn the_tilted_ravenwind_pointer_lands_on_its_post() {
|
||||
);
|
||||
}
|
||||
|
||||
/// No usable quaternion ⇒ the facing, not a `NaN`. A create block folds absent fields to zero, so
|
||||
/// "the wire sent nothing" can reach here as an all-zero quat — which has no length to normalize and
|
||||
/// would blank the object rather than mis-place it.
|
||||
/// A create block folds absent fields to zero, so an all-zero quaternion means none was sent.
|
||||
#[test]
|
||||
fn a_gameobject_without_a_usable_quaternion_falls_back_to_its_facing() {
|
||||
for quat in [None, Some([0.0; 4])] {
|
||||
@@ -1089,13 +972,8 @@ fn a_gameobject_without_a_usable_quaternion_falls_back_to_its_facing() {
|
||||
}
|
||||
}
|
||||
|
||||
/// **A flag-still remote is not integrated at all** (decision 1545) — the reference's own gate,
|
||||
/// `0x20ff` ([`move_flags::INTEGRATED`]): `CMovement::Update`'s substep loop (`0x616e20`) and the
|
||||
/// manager's per-mover tick (`0x6166f5`) both bail on a mover with no move/jump/fall bit, and
|
||||
/// such a unit is not even in the mover list. So its pose is the last
|
||||
/// packet's, verbatim — and the per-frame depenetration + down-cast the settled memo used to claw
|
||||
/// back (1490 item 2 / 1473 §3) does not run at all, for a stated reason rather than a proof that
|
||||
/// its answer was identical.
|
||||
/// A mover with no `0x20ff` bit is not integrated (`0x616e20`, `0x6166f5`): its pose is the
|
||||
/// last packet's.
|
||||
#[test]
|
||||
fn a_flag_still_remote_is_left_where_the_wire_put_it() {
|
||||
use avian3d::prelude::{Collider, RigidBody};
|
||||
@@ -1103,8 +981,7 @@ fn a_flag_still_remote_is_left_where_the_wire_put_it() {
|
||||
use bevy::transform::TransformPlugin;
|
||||
|
||||
let mut app = App::new();
|
||||
// `WorldCollision` takes the mover's trace exclusions (the ghost/DOOR set, 1767); the
|
||||
// real one is initialised by the world plugins, which a headless harness does not run.
|
||||
// `WorldCollision` needs the trace exclusions the world plugins would initialise.
|
||||
app.init_resource::<benilla_world::collision::MoverTraceExclusions>();
|
||||
app.add_plugins((
|
||||
MinimalPlugins,
|
||||
@@ -1115,8 +992,7 @@ fn a_flag_still_remote_is_left_where_the_wire_put_it() {
|
||||
));
|
||||
app.init_asset::<Mesh>()
|
||||
.init_resource::<benilla_world::collision::ColliderEpoch>();
|
||||
// The liquid/room facade the dead-reckon asks for a water-walker's surface (decision 1780),
|
||||
// seeded empty — no mover here has the mode, and an empty world answers "no liquid".
|
||||
// An empty liquid world: no water anywhere.
|
||||
benilla_world::world_point::init_world_point_resources(app.world_mut());
|
||||
app.finish();
|
||||
app.cleanup();
|
||||
@@ -1125,7 +1001,7 @@ fn a_flag_still_remote_is_left_where_the_wire_put_it() {
|
||||
crate::player::CAPSULE_HEIGHT - 2.0 * crate::player::CAPSULE_RADIUS,
|
||||
)));
|
||||
app.add_systems(Update, super::remote::extrapolate_remote_units);
|
||||
// A 10×10 up-wound floor at bevy y = 0 (wow z = 0).
|
||||
// A 10x10 up-wound floor at z = 0.
|
||||
let verts = vec![
|
||||
Vec3::new(-5.0, 0.0, -5.0),
|
||||
Vec3::new(5.0, 0.0, -5.0),
|
||||
@@ -1142,20 +1018,18 @@ fn a_flag_still_remote_is_left_where_the_wire_put_it() {
|
||||
.spawn((
|
||||
Transform::default(),
|
||||
motion(0, 0.0),
|
||||
// Real speeds, so the FORWARD leg below actually translates.
|
||||
// Real speeds, so the FORWARD leg below translates.
|
||||
crate::net::UnitSpeeds(speeds()),
|
||||
))
|
||||
.id();
|
||||
// The one that used to sink: standing where no collider exists at all — a mover inside a
|
||||
// building whose floor has not attached (B197's player site), or over a tile still streaming.
|
||||
// Standing where no collider exists: an unattached building floor, or a tile still streaming.
|
||||
let mut over_nothing = motion(0, 0.0);
|
||||
over_nothing.wow_pos = [50.0, 50.0, 10.0];
|
||||
let stranded = app
|
||||
.world_mut()
|
||||
.spawn((Transform::default(), over_nothing))
|
||||
.id();
|
||||
// Seed the idle mover a hair above the floor, the way a wire Z arrives: its own client's
|
||||
// resting clearance against the same geometry. The reference leaves that hair alone.
|
||||
// A hair above the floor, as a wire Z arrives: the sender's resting clearance.
|
||||
let seated = [0.0, 0.0, 0.01];
|
||||
app.world_mut()
|
||||
.entity_mut(idle)
|
||||
@@ -1171,16 +1045,13 @@ fn a_flag_still_remote_is_left_where_the_wire_put_it() {
|
||||
rm.wow_pos, seated,
|
||||
"flag-still ⇒ not integrated: the wire's Z stands, hair and all"
|
||||
);
|
||||
// Before 1545 this one was descending STEP_SNAP_SLACK (1/36 yd) EVERY FRAME with nothing to
|
||||
// end it — the whole defect, in one assertion.
|
||||
let rm = app.world().entity(stranded).get::<RemoteMotion>().unwrap();
|
||||
assert_eq!(
|
||||
rm.wow_pos,
|
||||
[50.0, 50.0, 10.0],
|
||||
"no ground under a standing mover is OUR world being incomplete, not a drop to take"
|
||||
);
|
||||
// A direction flag puts it back in the mover list, and 0626's resolve runs: the floor is 0.01
|
||||
// below, well inside the standing reach, so the resolve settles it onto the surface.
|
||||
// A direction flag brings the resolve back; the floor 0.01 below is inside its reach.
|
||||
app.world_mut()
|
||||
.entity_mut(idle)
|
||||
.get_mut::<RemoteMotion>()
|
||||
@@ -1194,11 +1065,10 @@ fn a_flag_still_remote_is_left_where_the_wire_put_it() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── The observer leg of the movement-mode family (decision 2061) ──────────────────────────────
|
||||
// ── The observer leg of the movement-mode family ──────────────────────────────
|
||||
|
||||
/// Build the `RelayMove` the six observer opcodes decode to — an ordinary relay carrying whatever
|
||||
/// flags word the server wrote (apply/unapply rides that word for five of the six) plus the
|
||||
/// opcode's verb, which matters for root and teleport alone.
|
||||
/// The `RelayMove` the six observer opcodes decode to: the server's flags word, which carries
|
||||
/// apply and unapply for five of them, plus the verb.
|
||||
fn observed(flags: u32, position: [f32; 3], verb: RelayVerb) -> super::relay::RelayMove {
|
||||
super::relay::RelayMove {
|
||||
wire_ms: 1000,
|
||||
@@ -1213,8 +1083,7 @@ fn observed(flags: u32, position: [f32; 3], verb: RelayVerb) -> super::relay::Re
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one relayed move through the real arrival path (`apply_move` — the seam the relay and the
|
||||
/// queue drain both go through), starting from `before`.
|
||||
/// Runs one relayed move through `apply_move` from `before`.
|
||||
fn apply_observed(before: RemoteMotion, mv: &super::relay::RelayMove) -> RemoteMotion {
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
let mv = mv.clone();
|
||||
@@ -1233,16 +1102,8 @@ fn apply_observed(before: RemoteMotion, mv: &super::relay::RelayMove) -> RemoteM
|
||||
.expect("the one-shot apply runs")
|
||||
}
|
||||
|
||||
/// **A watched player's root actually stops them** (decision 2061) — the reported "he keeps sliding
|
||||
/// after the root lands".
|
||||
///
|
||||
/// The mechanism is not ours: the rooted player's OWN client wipes its direction bits when it
|
||||
/// applies `SetRoot 0x7c7340` (`& 0xffe07f00`), acks with that wiped word, and vmangos stores the
|
||||
/// ack verbatim (`HandleMoverRelocation`: `pMover->m_movementInfo = movementInfo`, forcing
|
||||
/// `MOVEFLAG_ROOT` back on) before broadcasting it to observers as `MSG_MOVE_ROOT`. So the correct
|
||||
/// receiver is the plain one — fold the whole word — and the ONLY thing that was wrong was that we
|
||||
/// never parsed the packet. This pins the consequence: after it, nothing the integration gate tests
|
||||
/// survives, which is what stops the dead-reckon.
|
||||
/// The rooted client wipes its direction bits (`0x7c7340`, `& 0xffe07f00`) and acks the wiped
|
||||
/// word, which vmangos stores (`MovementHandler.cpp:1067`) and relays as `MSG_MOVE_ROOT`.
|
||||
#[test]
|
||||
fn an_observed_root_lands_the_wiped_word_and_stops_the_dead_reckon() {
|
||||
let walking = motion(move_flags::FORWARD, 0.0);
|
||||
@@ -1252,7 +1113,7 @@ fn an_observed_root_lands_the_wiped_word_and_stops_the_dead_reckon() {
|
||||
"precondition: this mover is being stepped"
|
||||
);
|
||||
|
||||
// What vmangos actually broadcasts: ROOT set, the direction bits gone.
|
||||
// What vmangos broadcasts: ROOT set, the direction bits gone.
|
||||
let rooted = apply_observed(
|
||||
walking,
|
||||
&observed(move_flags::ROOT, [0.0; 3], RelayVerb::Root(true)),
|
||||
@@ -1270,12 +1131,8 @@ fn an_observed_root_lands_the_wiped_word_and_stops_the_dead_reckon() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **Levitate reaches an observer** (decision 2061, with 1706's three-at-once): `SPELL_AURA_HOVER`,
|
||||
/// `_FEATHER_FALL` and `_WATER_WALK` are granted together, each broadcast on its own observer
|
||||
/// opcode, and each carries the *whole* `m_movementInfo` flags word — so the last one to arrive
|
||||
/// holds all three bits. The extrapolator's ground resolve reads exactly this word (unioned with
|
||||
/// the `SMSG_SPLINE_MOVE_*` component) for its hover offset and water plane, so landing the word IS
|
||||
/// landing the effect.
|
||||
/// Levitate grants hover, feather fall and water walk together, each on its own observer opcode
|
||||
/// carrying the whole flags word, so the last to arrive holds all three bits.
|
||||
#[test]
|
||||
fn an_observed_levitate_lands_all_three_granted_bits() {
|
||||
let trio = move_flags::HOVER | move_flags::SAFE_FALL | move_flags::WATER_WALKING;
|
||||
@@ -1285,24 +1142,14 @@ fn an_observed_levitate_lands_all_three_granted_bits() {
|
||||
"hover + feather fall + water walk, from one broadcast word"
|
||||
);
|
||||
|
||||
// …and the un-levitate is the same word with the bits gone — no opcode says "unapply".
|
||||
// The unapply is the same word without the bits.
|
||||
let landed = apply_observed(floating, &observed(0, [0.0; 3], RelayVerb::Pose));
|
||||
assert_eq!(landed.flags, 0, "the revoke is the absence of the bits");
|
||||
}
|
||||
|
||||
/// **The teleport is the ONE relay the pre-fire reconcile skips — and the heartbeat is not**
|
||||
/// (decision 2064, correcting 0601/0603).
|
||||
///
|
||||
/// The queued node's tag `0x26`, which `0x619030` (facing) and `0x619090` (position) both bail on,
|
||||
/// is the teleport's: `push 0x26` occurs at exactly two addresses in the movement region
|
||||
/// (`0x6186bd`, `0x618736`), both inside functions reached only from the teleport arms — and
|
||||
/// `0x602fb0`, one of the two callers, sends `push 0xc7` (`MSG_MOVE_TELEPORT_ACK`) on its other
|
||||
/// branch. This client had it attributed to the heartbeat since 0601 and blended the wrong one.
|
||||
///
|
||||
/// Both halves are asserted, because getting either backwards is a distinct visible bug: blending
|
||||
/// toward a blink drags the mover — swept capsule and all — across the 20 yards it exists to skip,
|
||||
/// and *not* blending a heartbeat leaves a watched player's straight run snapping at 2 Hz, which is
|
||||
/// the whole reason the blends exist. And a teleport is excluded from the *blend*, never the apply.
|
||||
/// Tag `0x26`, skipped by `0x619030` and `0x619090`, is the teleport's: `push 0x26` occurs only at
|
||||
/// `0x6186bd` and `0x618736`, reached only from the teleport arms (`0x602fb0` sends
|
||||
/// `MSG_MOVE_TELEPORT_ACK` on its other branch).
|
||||
#[test]
|
||||
fn the_teleport_is_the_only_relay_the_reconcile_skips() {
|
||||
let dest = [20.0, 5.0, 3.0];
|
||||
@@ -1328,15 +1175,12 @@ fn the_teleport_is_the_only_relay_the_reconcile_skips() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The root opcode outranks the flags word it arrived with** (decision 2064). After the masked
|
||||
/// merge the client runs `SetRoot 0x7c7340` — `or 0x1000`, then the one-shot motion wipe
|
||||
/// `& 0xffe07f00` — unconditionally, so a `MSG_MOVE_ROOT` roots the mover even if the word it
|
||||
/// carried still had direction bits in it. vmangos always sends an already-wiped word, which is
|
||||
/// exactly why this is worth pinning: nothing in a live run would catch it going wrong.
|
||||
/// After the merge the client runs `SetRoot` (`0x7c7340`) unconditionally, so the opcode roots
|
||||
/// the mover even when the word disagrees; vmangos always sends a wiped word, so no live run
|
||||
/// would catch this.
|
||||
#[test]
|
||||
fn the_root_opcode_wins_over_a_word_that_disagrees_with_it() {
|
||||
// A deliberately contradictory packet: the opcode says root, the word says "running forward,
|
||||
// not rooted". The reference roots them anyway.
|
||||
// The opcode says root, the word says running forward.
|
||||
let liar = observed(move_flags::FORWARD, [0.0; 3], RelayVerb::Root(true));
|
||||
let rooted = apply_observed(motion(move_flags::FORWARD, 0.0), &liar);
|
||||
assert_ne!(rooted.flags & move_flags::ROOT, 0, "the opcode set the bit");
|
||||
@@ -1346,8 +1190,7 @@ fn the_root_opcode_wins_over_a_word_that_disagrees_with_it() {
|
||||
"and the one-shot wipe took the direction bits with it — this is what stops the slide"
|
||||
);
|
||||
|
||||
// The unroot clears the bit and invents no movement: `ClearRoot 0x7c7370` is `and ~0x1000`,
|
||||
// not the wipe run backwards.
|
||||
// `ClearRoot` (`0x7c7370`) is `and ~0x1000`.
|
||||
let freed = apply_observed(rooted, &observed(0, [0.0; 3], RelayVerb::Root(false)));
|
||||
assert_eq!(freed.flags, 0, "cleared, and still not moving");
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,6 @@
|
||||
//! The bridge's own session handlers (in the net handler table since 2326, moved out of the
|
||||
//! drain's session arm file) — the connection edges (the login stages, character select,
|
||||
//! entering the world, logout, the disconnect teardown), our own teleport/worldport snaps and
|
||||
//! control edges, the server clock, the login reputation store, and the player's login-scoped
|
||||
//! stores the bridge defines (the home bind, the proficiencies). Registered from
|
||||
//! [`super::NetPlugin`], ahead of every window plugin, so the teardown runs before the windows'
|
||||
//! session-end listeners — each a second handler on `Disconnected`.
|
||||
//! The bridge's session handlers: the connection edges, our own teleport and worldport, the
|
||||
//! server clocks, reputations and the player's login-scoped stores. Registered ahead of every
|
||||
//! window plugin, so the teardown runs before the windows' own `Disconnected` listeners.
|
||||
|
||||
use benilla_protocol::messages::Character;
|
||||
use benilla_protocol::JumpInfo;
|
||||
@@ -26,7 +22,7 @@ use super::{
|
||||
SelfGuid, ServerTime, ServerWallClock, TeleportMessage, WorldportMessage,
|
||||
};
|
||||
|
||||
/// Register the session handlers — called from [`super::NetPlugin`].
|
||||
/// Registers the session handlers.
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::LoginStage, on_login_edge)
|
||||
@@ -57,8 +53,7 @@ pub(super) fn register(app: &mut App) {
|
||||
.net_handler(K::Pong, on_pong);
|
||||
}
|
||||
|
||||
/// The edges the bridge publishes as messages — the login screen's, the character screen's,
|
||||
/// the controller's — and the two resources they park in.
|
||||
/// The edges the bridge publishes as messages, and the two resources they park in.
|
||||
#[derive(SystemParam)]
|
||||
pub(crate) struct Edges<'w> {
|
||||
teleports: MessageWriter<'w, TeleportMessage>,
|
||||
@@ -80,8 +75,7 @@ pub(crate) struct Edges<'w> {
|
||||
transfer: ResMut<'w, PendingTransfer>,
|
||||
}
|
||||
|
||||
/// The bridge's own state the session edges write: the guid index and our guid, the status,
|
||||
/// the ask-once caches the teardown clears, the clocks, the reputations and the player's stores.
|
||||
/// The bridge state the session edges write.
|
||||
#[derive(SystemParam)]
|
||||
pub(crate) struct Bridge<'w, 's> {
|
||||
commands: Commands<'w, 's>,
|
||||
@@ -171,8 +165,7 @@ fn on_logged_out(In(ev): In<SessionEvent>, mut e: Edges, mut b: Bridge) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The teardown — the bridge's own half of the session end. Every window that dies with the
|
||||
/// socket has its own listener on this kind, registered after this one and so run after it.
|
||||
/// The bridge's half of the session end; each window's own listener runs after it.
|
||||
fn on_disconnected(In(ev): In<SessionEvent>, mut e: Edges, mut b: Bridge) {
|
||||
if let SessionEvent::Disconnected { reason, end } = ev {
|
||||
disconnected(
|
||||
@@ -191,9 +184,8 @@ fn on_disconnected(In(ev): In<SessionEvent>, mut e: Edges, mut b: Bridge) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The three server-authored mover edges the controller both *applies* and *answers*: a
|
||||
/// teleport snap, a knockback launch (decision 1702) and possession's control half (B211),
|
||||
/// forwarded whole and unjudged — only the controller can act on them.
|
||||
/// Teleport, knockback and client control, forwarded unjudged to the controller, which applies
|
||||
/// and answers them.
|
||||
fn on_mover_edge(In(ev): In<SessionEvent>, mut e: Edges, b: Bridge) {
|
||||
match ev {
|
||||
SessionEvent::Teleport {
|
||||
@@ -235,8 +227,7 @@ fn on_worldport(
|
||||
needs_ack,
|
||||
} = ev
|
||||
{
|
||||
// Every streamed roster member's object is about to be purged — the same deactivation
|
||||
// the reference runs one object at a time (decision 1640).
|
||||
// Every streamed roster member is about to be purged; the reference deactivates each.
|
||||
crate::ui_party::net::roster_deactivated(&mut group, &b.index, &b.stores, &b.net);
|
||||
worldport(
|
||||
map_id,
|
||||
@@ -288,8 +279,7 @@ fn on_reputations(In(ev): In<SessionEvent>, mut b: Bridge) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The chat line reads the deltas against the store, so it runs BEFORE the overwrite — after
|
||||
/// it, every delta is zero. One handler for both legs, for that order.
|
||||
/// The chat line reads the deltas against the store, so it must run before the overwrite.
|
||||
fn on_reputation_delta(
|
||||
In(ev): In<SessionEvent>,
|
||||
mut b: Bridge,
|
||||
@@ -332,24 +322,22 @@ fn on_packet_dropped(In(ev): In<SessionEvent>, mut b: Bridge) {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The pong never gets here** — the read thread measures it against the ping clock the
|
||||
/// instant it lands and stops it, the way the reference's `OnData 0x537b10` hands `SMSG_PONG`
|
||||
/// to `HandlePong 0x537d60` inline instead of queueing it (`net::io`). Reaching this handler
|
||||
/// means that bypass was undone and every latency reading is a client frame too slow again,
|
||||
/// which is B346 exactly — so it says so out loud rather than measuring here and hiding it.
|
||||
/// The read thread consumes the pong inline, as the reference's `OnData` (`0x537b10`) hands
|
||||
/// `SMSG_PONG` to `HandlePong` (`0x537d60`); reaching here means that bypass is gone and every
|
||||
/// latency reading is a frame late, so it warns.
|
||||
fn on_pong(In(ev): In<SessionEvent>) {
|
||||
if let SessionEvent::Pong { sequence } = ev {
|
||||
warn!("net: pong seq={sequence} reached the drain — the read thread's RTT bypass is gone (B346)");
|
||||
}
|
||||
}
|
||||
|
||||
/// The pre-logon handshake reached a new stage (decision 0539) — the login screen's dialog reads it.
|
||||
/// The pre-logon handshake reached a new stage.
|
||||
fn login_stage(stage: benilla_protocol::LoginStage, out: &mut MessageWriter<LoginStageMessage>) {
|
||||
out.write(LoginStageMessage { stage });
|
||||
}
|
||||
|
||||
/// A login attempt failed before the roster (decision 0539): the IO thread is back at its pre-logon
|
||||
/// park, and [`crate::login`]'s policy decides what happens next.
|
||||
/// A login failed before the roster; the IO thread is back at its pre-logon park and
|
||||
/// [`crate::login`] decides what follows.
|
||||
fn login_failed(
|
||||
refusal: Option<benilla_protocol::LoginRefusal>,
|
||||
reason: String,
|
||||
@@ -365,8 +353,7 @@ fn login_failed(
|
||||
});
|
||||
}
|
||||
|
||||
/// The verdict on a character create/delete (`SMSG_CHAR_CREATE`/`SMSG_CHAR_DELETE`) — the glue
|
||||
/// screen turns the code into its own refusal string.
|
||||
/// `SMSG_CHAR_CREATE`/`SMSG_CHAR_DELETE`: the glue screen turns the code into its string.
|
||||
fn char_action_result(
|
||||
action: benilla_protocol::CharAction,
|
||||
code: u8,
|
||||
@@ -375,8 +362,7 @@ fn char_action_result(
|
||||
out.write(CharActionResultMessage { action, code });
|
||||
}
|
||||
|
||||
/// The account's character roster (`SMSG_CHAR_ENUM`): the world socket is authenticated and parked
|
||||
/// at character select — surface the list (+ the connected realm's identity) to the glue screen.
|
||||
/// `SMSG_CHAR_ENUM`: the roster and the connected realm, for character select.
|
||||
fn character_list(
|
||||
characters: Vec<Character>,
|
||||
realm: Option<benilla_protocol::RealmInfo>,
|
||||
@@ -387,30 +373,21 @@ fn character_list(
|
||||
"net: character select — {} character(s) on the account",
|
||||
characters.len()
|
||||
);
|
||||
// A roster in hand IS a live link: clear the last failure so the select banner drops its
|
||||
// "Server down" note. Without this, the logout path sticks it on permanently — the relist
|
||||
// cycle synthesizes `Disconnected("logged out")` for the world teardown (decision 0065's
|
||||
// path), and nothing else clears `last_reason` until the next world entry.
|
||||
// A roster means a live link: clear the failure the logout's synthesized `Disconnected`
|
||||
// left, or the select screen keeps its "Server down" note until the next world entry.
|
||||
status.last_reason = None;
|
||||
char_lists.write(CharListMessage { characters, realm });
|
||||
}
|
||||
|
||||
/// The server refused the character we picked (`SMSG_CHARACTER_LOGIN_FAILED`) — the entry
|
||||
/// announced a moment ago is void. `crate::char_select` takes the screen back and says why.
|
||||
/// `SMSG_CHARACTER_LOGIN_FAILED`: the announced entry is void; `crate::char_select` says why.
|
||||
fn character_login_failed(result: u8, out: &mut MessageWriter<CharacterLoginFailedMessage>) {
|
||||
warn!("net: character login refused (result {result:#04x})");
|
||||
out.write(CharacterLoginFailedMessage { result });
|
||||
}
|
||||
|
||||
/// A cinematic sequence was triggered (`SMSG_TRIGGER_CINEMATIC`) — hand it to
|
||||
/// [`crate::cinematic`], which plays it and owns the ack.
|
||||
///
|
||||
/// **The ack no longer goes out from here, and that is the load-bearing part.** While a cinematic
|
||||
/// runs unacked, vmangos re-anchors object visibility to the flying camera
|
||||
/// (`Player::UpdateCinematic`) and everything around the body despawns until relog (decision 0196)
|
||||
/// — so the ack must still happen, at the *end* of playback rather than instantly. The cinematic
|
||||
/// plugin sends it on a natural end, on an ESC skip, and immediately for a trigger it cannot
|
||||
/// resolve to a shot, so no path drops it.
|
||||
/// `SMSG_TRIGGER_CINEMATIC`, handed to [`crate::cinematic`], which sends the ack at the end of
|
||||
/// playback. While a cinematic runs unacked, vmangos anchors visibility to the camera
|
||||
/// (`Player::UpdateCinematic`), so every path there must still ack.
|
||||
fn cinematic_triggered(
|
||||
cinematic_id: u32,
|
||||
triggered: &mut MessageWriter<CinematicTriggeredMessage>,
|
||||
@@ -419,8 +396,7 @@ fn cinematic_triggered(
|
||||
triggered.write(CinematicTriggeredMessage { cinematic_id });
|
||||
}
|
||||
|
||||
/// We are in the world (the IO thread's first in-world event): record our guid, flip the status,
|
||||
/// and seed the name cache with our own name.
|
||||
/// The first in-world event: records our guid and seeds the name cache with our own name.
|
||||
fn connected(
|
||||
guid: u64,
|
||||
name: String,
|
||||
@@ -437,17 +413,13 @@ fn connected(
|
||||
status.connected = true;
|
||||
status.last_reason = None;
|
||||
info!("net: in world as {name} (guid {guid})");
|
||||
// **The reference's world-session wipe, first** (`0x555740`'s `0x5557ad` arm): the player-name
|
||||
// and pet-name stores are cleared at every world entry, because a guid names one character and
|
||||
// a pet number one spawn, and nothing on the wire says either has been handed to somebody else
|
||||
// since we last looked (decision 2223 — a wiped server's new character wearing a deleted one's
|
||||
// name, B386). Creature templates are keyed by an entry that means the same thing forever and
|
||||
// survive this, exactly as they survive the process.
|
||||
// The reference clears player and pet names at every world entry (`0x555740`, arm
|
||||
// `0x5557ad`), since a guid may name a new character; creature templates survive.
|
||||
names.clear_world_session();
|
||||
// Our own name came with the login — seed the cache so "player" never queries.
|
||||
// Our own name came with the login, so "player" never queries.
|
||||
names.insert_player(guid, name, None);
|
||||
// Seated before the world-entry UI load reads it (2175), and overwritten every login so a
|
||||
// server that answers nothing cannot inherit the previous one's verdict.
|
||||
// Set before the world-entry UI load reads it, and every login, so a silent server inherits
|
||||
// no earlier answer.
|
||||
addon_reply.0 = addon_info;
|
||||
entered_world.write(EnteredWorldMessage {
|
||||
billing_time_rested,
|
||||
@@ -455,18 +427,15 @@ fn connected(
|
||||
});
|
||||
}
|
||||
|
||||
/// The server confirmed our logout (`SMSG_LOGOUT_COMPLETE`) — back to character select.
|
||||
/// `SMSG_LOGOUT_COMPLETE`: back to character select.
|
||||
fn logged_out(
|
||||
commands: &mut Commands,
|
||||
index: &mut GuidIndex,
|
||||
self_guid: &mut SelfGuid,
|
||||
logged_out: &mut MessageWriter<LoggedOutMessage>,
|
||||
) {
|
||||
// A deliberate logout ends this *character's* session, not just the socket: unlike
|
||||
// the disconnect teardown below (which keeps the self avatar as the local puppet for
|
||||
// a seamless same-char reconnect, decision 0065), the avatar goes too — the next
|
||||
// login may be a different character. Clearing `SelfGuid` first makes the follow-up
|
||||
// Disconnected teardown total.
|
||||
// A logout ends the character's session, so the avatar goes too, unlike a reconnectable
|
||||
// disconnect; clearing `SelfGuid` first makes the following teardown total.
|
||||
info!("net: logged out — back to character select");
|
||||
if let Some(guid) = self_guid.0.take() {
|
||||
if let Some(e) = index.0.remove(&guid) {
|
||||
@@ -476,8 +445,7 @@ fn logged_out(
|
||||
logged_out.write(LoggedOutMessage);
|
||||
}
|
||||
|
||||
/// The session ended (socket closed / handshake failure): tear down the streamed world and clear
|
||||
/// every session-scoped cache.
|
||||
/// The session ended: tears down the streamed world and clears every session-scoped cache.
|
||||
fn disconnected(
|
||||
reason: String,
|
||||
end: benilla_protocol::SessionEnd,
|
||||
@@ -491,32 +459,18 @@ fn disconnected(
|
||||
pending_transfer: &mut PendingTransfer,
|
||||
disconnects: &mut MessageWriter<DisconnectedMessage>,
|
||||
) {
|
||||
// The reconnect-policy feed first (decision 0539): [`crate::login`] reads it as "the IO thread
|
||||
// is back at its pre-logon park".
|
||||
// Is the session over, or is this the pause inside one? Settled once, here, and carried on the
|
||||
// message to every other reader (decision 1262).
|
||||
// Whether the session is over is settled once, here, and carried to every reader.
|
||||
let msg = DisconnectedMessage::new(reason.clone(), end);
|
||||
let over = msg.session_over;
|
||||
disconnects.write(msg);
|
||||
warn!("net: {reason} — tearing down the streamed world");
|
||||
// An announced-but-unfinished far teleport died with the socket.
|
||||
// An unfinished far teleport dies with the socket.
|
||||
pending_transfer.0 = None;
|
||||
status.connected = false;
|
||||
// The RTT ring is NOT cleared here. It belongs to the connection, and the read thread wipes
|
||||
// it as it re-enters its cycle loop (`net::io`) — same instant, one thread, no race with a
|
||||
// reconnect that has already begun measuring.
|
||||
// Teardown (decision 0065): despawn every streamed entity except the self avatar —
|
||||
// it stays the local puppet (controller + camera keep working); the reconnect's
|
||||
// re-create refreshes it in place. Immediate despawn, not `DespawnFade`: a
|
||||
// connection loss is not a world event, and index-less fading entities would race
|
||||
// the reconnect's re-creates. Entities already mid-fade left the index earlier and
|
||||
// finish fading on their own.
|
||||
//
|
||||
// **Unless the session is over** (decision 1262), and then the avatar goes too, exactly as
|
||||
// [`logged_out`] takes it: 0065 spares it *for the reconnect*, and with no reconnect coming
|
||||
// that spared body is a puppet with no server behind it. Keeping it was the free camera —
|
||||
// `SelfGuid` set, no entity, an entry that never finished — so the fact that decides whether
|
||||
// anything reconnects has to be the same fact that decides whether the body stays.
|
||||
// The RTT ring is not cleared here: the read thread wipes it as it re-enters its cycle loop.
|
||||
// Every streamed entity despawns at once, not faded, so none races the reconnect's creates.
|
||||
// The self avatar stays as the local puppet for the reconnect to refresh, unless the session
|
||||
// is over, and then it goes as on logout.
|
||||
let keep = if over { None } else { self_guid.0 };
|
||||
if over {
|
||||
self_guid.0 = None;
|
||||
@@ -529,20 +483,16 @@ fn disconnected(
|
||||
false
|
||||
});
|
||||
status.last_reason = Some(reason);
|
||||
// In-flight name queries died with the socket; let the next resolve re-ask.
|
||||
// In-flight name queries died with the socket.
|
||||
names.clear_pending();
|
||||
items.clear_session();
|
||||
// The cooldown list is session-scoped — the next login may be a different character — and
|
||||
// had been missing from this sweep since it was built (decision 2116). `SMSG_INITIAL_SPELLS` carries every cooldown
|
||||
// still running at every world entry and `seed_initial` APPENDS, so a list that outlives the
|
||||
// socket answers the old session's records: a second login on the same character reads its
|
||||
// own stale copy over the wire's fresh remainder, and a login on a different character
|
||||
// inherits cooldowns that were never theirs.
|
||||
// `SMSG_INITIAL_SPELLS` resends every running cooldown at world entry and `seed_initial`
|
||||
// appends, so the list must not outlive the session.
|
||||
cooldowns.clear_session();
|
||||
}
|
||||
|
||||
/// A teleport ack request (`MSG_MOVE_TELEPORT_ACK`) — only our own matters (the ack resumes our
|
||||
/// movement); the controller consumes the message.
|
||||
/// `MSG_MOVE_TELEPORT_ACK`: only our own is forwarded to the controller, whose ack resumes our
|
||||
/// movement.
|
||||
fn teleport(
|
||||
guid: u64,
|
||||
counter: u32,
|
||||
@@ -551,7 +501,6 @@ fn teleport(
|
||||
self_guid: &SelfGuid,
|
||||
teleports: &mut MessageWriter<TeleportMessage>,
|
||||
) {
|
||||
// Only our own teleports matter to the controller (the ack resumes our movement).
|
||||
if self_guid.0 == Some(guid) {
|
||||
teleports.write(TeleportMessage {
|
||||
guid,
|
||||
@@ -562,14 +511,9 @@ fn teleport(
|
||||
}
|
||||
}
|
||||
|
||||
/// **A knockback the server aimed at our mover** (`SMSG_MOVE_KNOCK_BACK`, decision 1702) — a
|
||||
/// ballistic launch the controlling client flies itself, not a spline and not a teleport. Forwarded
|
||||
/// to the controller, which owns the take-off, the arc, and the ack the launch owes.
|
||||
///
|
||||
/// The guid guard is the same one every self-addressed movement edge here carries. The reference
|
||||
/// registers this opcode for the **controller** only; the observer's knockback arrives on a
|
||||
/// different opcode with a different handler (`MSG_MOVE_KNOCK_BACK`, `0x603bb0` rather than
|
||||
/// `0x603f90`), so a knockback naming somebody else is not ours to fly.
|
||||
/// `SMSG_MOVE_KNOCK_BACK`: a ballistic launch our controller flies and acks. The reference
|
||||
/// handles it for the controller only (`0x603f90`); observers get `MSG_MOVE_KNOCK_BACK`
|
||||
/// (`0x603bb0`), so one naming another unit is dropped.
|
||||
fn knock_back(
|
||||
guid: u64,
|
||||
counter: u32,
|
||||
@@ -586,8 +530,8 @@ fn knock_back(
|
||||
}
|
||||
}
|
||||
|
||||
/// The far-teleport preamble (`SMSG_TRANSFER_PENDING`): latch it for the coming worldport —
|
||||
/// its transport block decides whether NEW_WORLD's coordinates are boat-local (decision 0455).
|
||||
/// `SMSG_TRANSFER_PENDING`: latched for the worldport; its transport block makes `NEW_WORLD`'s
|
||||
/// coordinates boat-local.
|
||||
fn transfer_pending(map_id: u32, transport_entry: Option<u32>, pending: &mut PendingTransfer) {
|
||||
match transport_entry {
|
||||
Some(entry) => info!("net: transfer pending → map {map_id} riding transport {entry}"),
|
||||
@@ -599,21 +543,15 @@ fn transfer_pending(map_id: u32, transport_entry: Option<u32>, pending: &mut Pen
|
||||
});
|
||||
}
|
||||
|
||||
/// `SMSG_TRANSFER_ABORTED`: the announced transfer won't happen — clear the latch.
|
||||
/// `SMSG_TRANSFER_ABORTED`: clears the latch.
|
||||
fn transfer_aborted(reason: u8, pending: &mut PendingTransfer) {
|
||||
warn!("net: transfer aborted (reason {reason})");
|
||||
pending.0 = None;
|
||||
}
|
||||
|
||||
/// A cross-map transfer (`SMSG_NEW_WORLD` / `SMSG_LOGIN_VERIFY_WORLD`): the new map streams a
|
||||
/// fresh object set — drop everything we were tracking, then hand the app the destination.
|
||||
///
|
||||
/// One exception to the purge (decision 0455): an armed transport whose timetable touches the
|
||||
/// destination map is **spared**, entity and index entry both. Transports are client-simulated
|
||||
/// global objects on one continuous two-continent clock — a spared boat sails straight through
|
||||
/// the seam (the `CurrentMap` flip itself flips which legs render), keeping the ride attachment
|
||||
/// and the deck collider valid the whole way; the server's post-ack re-create then refreshes its
|
||||
/// anchor in place. Boats whose paths never reach the new map despawn like everything else.
|
||||
/// `SMSG_NEW_WORLD`/`SMSG_LOGIN_VERIFY_WORLD`: drops every tracked object, then hands the app
|
||||
/// the destination. A transport whose timetable touches the new map is spared, so a ridden boat
|
||||
/// sails through the map change and the server's re-create refreshes it in place.
|
||||
fn worldport(
|
||||
map_id: u32,
|
||||
position: [f32; 3],
|
||||
@@ -627,8 +565,7 @@ fn worldport(
|
||||
) {
|
||||
index.0.retain(|guid, e| {
|
||||
if transports.get(*e).is_ok_and(|t| t.touches_map(map_id)) {
|
||||
// info, not debug: rare (worldports only) and load-bearing — the crossing's whole
|
||||
// mechanism hangs on this line firing for the ridden boat.
|
||||
// info, not debug: rare, and the crossing depends on it firing for the ridden boat.
|
||||
info!("worldport: sparing transport {guid:#x} (its path touches map {map_id})");
|
||||
return true;
|
||||
}
|
||||
@@ -637,8 +574,7 @@ fn worldport(
|
||||
});
|
||||
let announced = pending.0.take();
|
||||
if let Some(p) = announced {
|
||||
// vmangos pairs every NEW_WORLD with a same-map TRANSFER_PENDING; a mismatch means we
|
||||
// mis-latched (or the server changed its mind) — worth a line, not a failure.
|
||||
// vmangos pairs every NEW_WORLD with a same-map TRANSFER_PENDING; a mismatch only warns.
|
||||
if p.map_id != map_id {
|
||||
warn!(
|
||||
"worldport: NEW_WORLD map {map_id} ≠ announced transfer map {} — using {map_id}",
|
||||
@@ -656,7 +592,7 @@ fn worldport(
|
||||
});
|
||||
}
|
||||
|
||||
/// The server game clock (`SMSG_LOGIN_SETTIMESPEED`) — drives the day/night lighting.
|
||||
/// `SMSG_LOGIN_SETTIMESPEED`: the game clock that drives day/night lighting.
|
||||
fn time_speed(
|
||||
hours: u8,
|
||||
minutes: u8,
|
||||
@@ -670,13 +606,8 @@ fn time_speed(
|
||||
server_time.0 = Some(GameTime::new(hours, minutes, day_serial, timescale));
|
||||
}
|
||||
|
||||
/// The server **wall** clock (`SMSG_QUERY_TIME_RESPONSE`, answering the world-enter
|
||||
/// `CMSG_QUERY_TIME`) — the epoch the absolute descriptor stamps are dated in, and so the origin of
|
||||
/// every countdown drawn from one (today: the timed-quest timer, decision 1150). Nothing to do with
|
||||
/// [`time_speed`] above, which is the in-game day/night clock.
|
||||
///
|
||||
/// Logged once per session at info, with the skew against our own clock: that number is exactly
|
||||
/// what a "the countdown is wrong by a constant" report would be about, and it costs one line.
|
||||
/// `SMSG_QUERY_TIME_RESPONSE`: the server wall clock that absolute descriptor stamps (the
|
||||
/// timed-quest timer) are dated in; its skew against ours is logged once per session.
|
||||
fn server_unix_time(unix_time: u32, clock: &mut ServerWallClock) {
|
||||
if clock.0.is_none() {
|
||||
let local = std::time::SystemTime::now()
|
||||
@@ -691,22 +622,14 @@ fn server_unix_time(unix_time: u32, clock: &mut ServerWallClock) {
|
||||
clock.sample(unix_time);
|
||||
}
|
||||
|
||||
/// The login reputation store (`SMSG_INITIALIZE_FACTIONS`).
|
||||
/// `SMSG_INITIALIZE_FACTIONS`: the login reputation store.
|
||||
fn reputations(standings: Vec<(u8, i32)>, reputations: &mut Reputations) {
|
||||
info!("net: reputation store ({} slots)", standings.len());
|
||||
reputations.0 = standings;
|
||||
}
|
||||
|
||||
/// A mid-session standing delta (`SMSG_SET_FACTION_STANDING`): overwrite the changed slots,
|
||||
/// growing the store for a list id past the login snapshot (flags default 0 — the delta carries
|
||||
/// none), and **auto-reveal** each one.
|
||||
///
|
||||
/// The auto-reveal is the client's own (the `0x124` handler `0x4d5760`):
|
||||
/// gaining reputation with a faction makes it visible, unless the slot carries `HIDDEN` — which is
|
||||
/// exactly what that bit is for, and is why it is not a list gate. The server pushes an
|
||||
/// `SMSG_SET_FACTION_VISIBLE` for the same slot in most cases (vmangos `SetOneFactionReputation`
|
||||
/// calls `SetVisible`), so this is usually belt to that braces; it matters when the reveal and the
|
||||
/// standing arrive in the other order, and it is what the client does regardless.
|
||||
/// `SMSG_SET_FACTION_STANDING`: overwrites the changed slots, growing the store with flags 0,
|
||||
/// and makes each visible unless it is `HIDDEN`, as the reference's handler does (`0x4d5760`).
|
||||
fn reputation_delta(
|
||||
standings: Vec<(u32, i32)>,
|
||||
reputations: &mut Reputations,
|
||||
@@ -725,18 +648,12 @@ fn reputation_delta(
|
||||
reputations.0[i].0 |= flag::VISIBLE;
|
||||
}
|
||||
}
|
||||
// A standing change is a questgiver-status input (`SatisfyQuestReputation`, and the reaction
|
||||
// gate): the reference sweeps from this handler too (0654).
|
||||
// Standing feeds questgiver status (`SatisfyQuestReputation`); the reference re-asks here too.
|
||||
quest.bump_reask();
|
||||
}
|
||||
|
||||
/// A faction became visible (`SMSG_SET_FACTION_VISIBLE`): lift `FACTION_FLAG_VISIBLE` on that slot
|
||||
/// and nothing else.
|
||||
///
|
||||
/// The server pushes this the first time the player meets a faction, and it carries **no
|
||||
/// standing** — the slot's standing was already correct and stays untouched. Dropping it is the
|
||||
/// silent failure it exists to prevent: the pane keys row membership off this bit, so a faction met
|
||||
/// mid-session would keep accruing reputation the player could never see.
|
||||
/// `SMSG_SET_FACTION_VISIBLE`: sets `FACTION_FLAG_VISIBLE` on the slot and nothing else; it
|
||||
/// carries no standing, and the reputation pane lists a faction by this bit.
|
||||
fn reputation_visible(list_id: u32, reputations: &mut Reputations) {
|
||||
let Some(i) = reputation_slot(list_id, "SMSG_SET_FACTION_VISIBLE") else {
|
||||
return;
|
||||
@@ -747,11 +664,8 @@ fn reputation_visible(list_id: u32, reputations: &mut Reputations) {
|
||||
reputations.0[i].0 |= benilla_formats::faction_flags::VISIBLE;
|
||||
}
|
||||
|
||||
/// A wire `repListId` as a store index, or `None` (logged) when it is not one. The list is
|
||||
/// positional in a `FACTION_LIST_LEN`-entry array (vmangos `MAX_FACTION_COUNT` 64), so a slot
|
||||
/// past it is not a faction — and resizing the store to it was the one wire value that could
|
||||
/// abort the process instead of dropping a packet (`0xFFFF_FFFF` → a 34 GB resize; decision
|
||||
/// 2265 §B1).
|
||||
/// A wire `repListId` as a store index, or `None` (logged) past the `FACTION_LIST_LEN` array
|
||||
/// (vmangos `MAX_FACTION_COUNT` 64); unchecked, `0xFFFF_FFFF` would resize the store to 34 GB.
|
||||
fn reputation_slot(list_id: u32, opcode: &str) -> Option<usize> {
|
||||
let i = usize::try_from(list_id).ok()?;
|
||||
if i >= benilla_protocol::messages::FACTION_LIST_LEN {
|
||||
@@ -764,9 +678,8 @@ fn reputation_slot(list_id: u32, opcode: &str) -> Option<usize> {
|
||||
Some(i)
|
||||
}
|
||||
|
||||
/// The dropped-packet tally (the wire-coverage instrument): count it, and announce each opcode's
|
||||
/// FIRST drop at info — visible in any log without the panel open, and one line per opcode per
|
||||
/// run, so it can never flood.
|
||||
/// The dropped-packet tally: counts each drop and logs an opcode's first drop at info, one line
|
||||
/// per opcode per run.
|
||||
fn packet_dropped(opcode: u16, unparseable: bool, dropped: &mut DroppedOpcodes) {
|
||||
let tally = dropped.0.entry(opcode).or_default();
|
||||
if tally.unknown + tally.unparseable == 0 {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
//! The bridge's own world feed (in the net handler table since 2325, moved out of the drain's
|
||||
//! world arm file) — what the server pushes about the *world* rather than about an entity or a
|
||||
//! window, forwarded by the bridge because what receives it is not an app subsystem: the weather
|
||||
//! change goes to the world crate's weather as a [`WeatherMessage`], and the world-state table the
|
||||
//! UI's `$<n>w` tokens read is a cache the bridge initialises ([`WorldStates`]). Registered from
|
||||
//! [`super::NetPlugin`].
|
||||
//! The bridge's world feed: weather goes to the world crate as a [`WeatherMessage`], world states
|
||||
//! to the [`WorldStates`] table the UI's `$<n>w` tokens read.
|
||||
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use benilla_world::weather::WeatherMessage;
|
||||
@@ -12,7 +8,7 @@ use bevy::prelude::*;
|
||||
use super::NetHandlerApp;
|
||||
use crate::world_state::WorldStates;
|
||||
|
||||
/// Register the two handlers — called from [`super::NetPlugin`].
|
||||
/// Registers the two handlers.
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::Weather, on_weather)
|
||||
@@ -41,7 +37,7 @@ fn on_world_states(In(ev): In<SessionEvent>, mut states: ResMut<WorldStates>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_WEATHER` — the zone's weather change (`instant` on zone entry, a ramp otherwise).
|
||||
/// `SMSG_WEATHER`: the zone's weather change, `instant` on zone entry, a ramp otherwise.
|
||||
fn weather(
|
||||
weather_type: u32,
|
||||
grade: f32,
|
||||
@@ -57,13 +53,9 @@ fn weather(
|
||||
});
|
||||
}
|
||||
|
||||
/// `SMSG_INIT_WORLD_STATES` / `SMSG_UPDATE_WORLD_STATE` — both wires funnel into the one setter,
|
||||
/// as the reference's own handler does.
|
||||
///
|
||||
/// An **init clears the table first** and records its `(map, zone)` as the world-state UI's display
|
||||
/// filter — the reference's `0x4c5650`, which runs before the pair loop (rationale on
|
||||
/// [`crate::world_state`]). The order below
|
||||
/// is that handler's: clear + scope, then the packet's pairs.
|
||||
/// `SMSG_INIT_WORLD_STATES` and `SMSG_UPDATE_WORLD_STATE` share one setter, as in the reference.
|
||||
/// An init first clears the table and records `(map, zone)` as the UI's display filter, then
|
||||
/// writes its pairs (reference: `0x4c5650`).
|
||||
fn world_states(
|
||||
scope: Option<(u32, u32)>,
|
||||
states: Vec<(u32, u32)>,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,9 @@
|
||||
//! The **dressing room** booth (decision 1060) — the ref's `DressUpFrame`/`DressUpModel`: the
|
||||
//! player's own character wearing an item they do *not* own, spun by the window's rotate buttons.
|
||||
//!
|
||||
//! ## Why this booth cannot be the paper doll's
|
||||
//!
|
||||
//! The paper doll and the inspect pane mirror a *live entity's* spawned children
|
||||
//! ([`super::sync_body_booth`]) — which is exactly right for "show me what is standing in the
|
||||
//! world", and exactly wrong here: nobody in the world is wearing the previewed item. So the
|
||||
//! dressing room takes the **tuple-driven** path the glue screens already use — the shared
|
||||
//! assembly in [`crate::entities::attach`] builds the parts from a spec
|
||||
//! (body displayId + appearance dials + a 19-slot equipment array), and this module bakes them.
|
||||
//!
|
||||
//! The spec's equipment is the player's own visible items with the tried-on ones substituted in
|
||||
//! ([`crate::ui_dressup`] composes it). One consequence worth naming: the preview dresses by the
|
||||
//! **select-screen** law (weapons drawn in the hands, `0x47a0c0`), not by the
|
||||
//! world's sheath state — the reference's `DressUpModel` is a `<PlayerModel>` subclass showing the
|
||||
//! character posed for inspection, and that is the pose the shared assembly produces.
|
||||
//!
|
||||
//! ## The light is the reference's own
|
||||
//!
|
||||
//! `DressUpModel` (`0x495c00`) subclasses the very `CharacterModelBase` ctor (`0x505680`) the
|
||||
//! character window's `<PlayerModel>` uses — same single directional light, same ambient
|
||||
//! (decision 0638). So this bake lights through [`BoothLight::pane`], the same rig as the paper
|
||||
//! doll, with no glow ([`benilla_world::ffx_glow::FfxGlow::UI_PANE`]) for the same reason.
|
||||
//! The dressing-room booth, the reference's `DressUpFrame`/`DressUpModel`: the player's own
|
||||
//! character wearing items they do not own. Nobody in the world wears the preview, so it is built
|
||||
//! from a spec by the shared assembly ([`crate::entities::attach`]), not mirrored from a live
|
||||
//! entity, and it dresses by the select-screen law (weapons in hand, `0x47a0c0`). `DressUpModel`
|
||||
//! (`0x495c00`) shares the `CharacterModelBase` ctor (`0x505680`) with the character window's
|
||||
//! `<PlayerModel>`, so it lights through [`BoothLight::pane`] with no glow, as the paper doll does.
|
||||
|
||||
use benilla_protocol::CharEnumItem;
|
||||
use bevy::camera::visibility::RenderLayers;
|
||||
@@ -39,18 +21,14 @@ use super::{
|
||||
PreviewEffects, PreviewPart, PreviewRider, BOOTH_SETTLE_FRAMES, DRESSUP_LAYER, PAPERDOLL_SIZE,
|
||||
};
|
||||
|
||||
/// The dressing-room booth slot token (its key in [`PortraitImages`] / [`Booths`]).
|
||||
/// The dressing-room booth's key in [`PortraitImages`] and [`Booths`].
|
||||
pub(crate) const DRESSUP_SLOT: &str = "dressup";
|
||||
|
||||
/// The character the dressing room shows: the player's own body + appearance, wearing the
|
||||
/// equipment array [`crate::ui_dressup`] composed (their visible items, with each tried-on item
|
||||
/// substituted into its slot).
|
||||
///
|
||||
/// Its `PartialEq` is the re-assembly trigger — a try-on, a reset, or the player equipping
|
||||
/// something while the window is open all show up as a different look.
|
||||
/// The player's own body and appearance wearing [`crate::ui_dressup`]'s equipment array (their
|
||||
/// visible items with the tried-on ones substituted); a change in it re-assembles the booth.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) struct DressUpLook {
|
||||
/// The player's own body display id (the ref's `SetUnit("player")`).
|
||||
/// The player's own body display id (the reference's `SetUnit("player")`).
|
||||
pub(crate) display_id: u32,
|
||||
pub(crate) race: u8,
|
||||
pub(crate) sex: u8,
|
||||
@@ -59,18 +37,15 @@ pub(crate) struct DressUpLook {
|
||||
pub(crate) hair_style: u8,
|
||||
pub(crate) hair_color: u8,
|
||||
pub(crate) facial_hair: u8,
|
||||
/// Worn **ItemDisplayInfo** ids by equipment slot, the `SMSG_CHAR_ENUM` shape the shared
|
||||
/// assembly reads (helm 0 · shoulder 2 · … · main hand 15 · off hand 16 · ranged 17 · tabard 18).
|
||||
/// `ItemDisplayInfo` ids by equipment slot, in the `SMSG_CHAR_ENUM` shape (helm 0, main hand
|
||||
/// 15, off hand 16, ranged 17, tabard 18).
|
||||
pub(crate) equipment: [CharEnumItem; 19],
|
||||
/// The player's own guild tabard (decision 1704) — so a tried-on Guild Tabard previews *their*
|
||||
/// crest, not the blank default. Part of the look's `PartialEq`, so an identity that lands
|
||||
/// while the window is open re-assembles the booth exactly as a try-on does.
|
||||
/// The player's own guild crest, so a tried-on Guild Tabard shows it, not the blank default.
|
||||
pub(crate) emblem: Option<benilla_formats::GuildEmblem>,
|
||||
}
|
||||
|
||||
/// The dressing room's live input: who is standing in it (`None` = the window is closed / has
|
||||
/// nothing to show, which empties the booth) and the pane's bake **yaw** in radians — the ref's
|
||||
/// `Model:SetRotation`, written by the window's rotate buttons exactly as the paper doll's is.
|
||||
/// The dressing room's input: the look (`None` empties the booth) and the yaw in radians, the
|
||||
/// reference's `Model:SetRotation` driven by the rotate buttons.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct DressUpPreview {
|
||||
pub(crate) look: Option<DressUpLook>,
|
||||
@@ -81,15 +56,14 @@ impl Default for DressUpPreview {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
look: None,
|
||||
// The ref's `Model_OnLoad` default facing (`UIParent.lua:1422`), same as the paper doll's.
|
||||
// `Model_OnLoad`'s default facing (`UIParent.lua:1422`).
|
||||
yaw: 0.61,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The assembled dressing-room look — the entities-side builder's output, the [`super::GluePreviewBake`]
|
||||
/// twin. `revision` bumps on every real change (a fresh assembly, or a clear to `look: None`); the
|
||||
/// booth re-bakes only when it moves, and a bare yaw change never touches it.
|
||||
/// The assembled dressing-room parts. `revision` bumps on a fresh assembly or a clear; the booth
|
||||
/// re-bakes only when it moves, never on a bare yaw change.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct DressUpBake {
|
||||
pub(crate) look: Option<DressUpLook>,
|
||||
@@ -102,9 +76,8 @@ pub(crate) struct DressUpBake {
|
||||
pub(crate) revision: u64,
|
||||
}
|
||||
|
||||
/// Stand the dressing-room booth up beside the two body panes (called from [`super::setup_booths`]).
|
||||
/// Same off-screen pipeline as those — [`PAPERDOLL_SIZE`]² target, decode but no glow (decision
|
||||
/// 0638) — on its own layer, framed per-bake, and **transparent** (see the clear below).
|
||||
/// Stand the dressing-room booth up: a [`PAPERDOLL_SIZE`]² target with no glow on its own layer,
|
||||
/// framed per bake, and transparent.
|
||||
pub(super) fn spawn_dressup_booth(
|
||||
commands: &mut Commands,
|
||||
images: &mut Assets<Image>,
|
||||
@@ -124,25 +97,15 @@ pub(super) fn spawn_dressup_booth(
|
||||
super::booth_view_shape(),
|
||||
Camera {
|
||||
order: -100 + DRESSUP_LAYER as isize,
|
||||
// **Transparent**, unlike the paper doll's opaque near-black slab (decision 1069). The
|
||||
// reference's `<DressUpModel>` is a widget that draws only its model: the dark room
|
||||
// behind the character is the window's own `DressUpBackground-<Race>` art, and an
|
||||
// opaque bake hid it completely — the pane's rect covers all but a 32 px strip of it.
|
||||
//
|
||||
// 1070 lowered this quad to BACKGROUND, which is what un-covered the Reset/Close
|
||||
// buttons; it cannot un-cover the race art, because that art is on the WINDOW (level 0)
|
||||
// and the level term outranks the layer (0884). Only compositing can. The glue booth
|
||||
// has done exactly this since 0818 — FfxGlow's combine carries the scene alpha through.
|
||||
//
|
||||
// The other three panes keep their opaque near-black: nothing is behind them to reveal,
|
||||
// and their look is the director's approved one.
|
||||
// Transparent: `<DressUpModel>` draws only its model, and the room behind it is the
|
||||
// window's `DressUpBackground-<Race>` art at a lower frame level, which only
|
||||
// compositing can show through.
|
||||
clear_color: ClearColorConfig::Custom(Color::NONE),
|
||||
..default()
|
||||
},
|
||||
bevy::camera::RenderTarget::Image(image.clone().into()),
|
||||
benilla_world::ffx_glow::FfxGlow::UI_PANE,
|
||||
// Placeholder — `sync_dressup_booth` overwrites transform + projection from the body's
|
||||
// bounds on the first bake, exactly as the paper doll's does.
|
||||
// Placeholder: `sync_dressup_booth` frames it from the body's bounds on the first bake.
|
||||
Projection::from(PerspectiveProjection {
|
||||
fov: super::PORTRAIT_FOV,
|
||||
near: 0.02,
|
||||
@@ -177,13 +140,8 @@ pub(super) fn spawn_dressup_booth(
|
||||
);
|
||||
}
|
||||
|
||||
/// Bake the assembled dressing-room look, and spin it to the pane's yaw.
|
||||
///
|
||||
/// The [`super::sync_body_booth`] law over tuple-driven parts: re-light onto the pane rig, pose a
|
||||
/// fresh instance, seat the riders and effects, frame it full-body, arm the wake window. What
|
||||
/// differs from the paper doll is only where the parts come from (a bake resource, not a live
|
||||
/// unit's children) and the hand grip — the assembly holds the weapons, so the hands close on them
|
||||
/// (`CloseHand 0x479660`).
|
||||
/// Bake the assembled look as [`super::sync_body_booth`] does, and spin it to the pane's yaw. The
|
||||
/// assembly holds the weapons, so the hands close on them (`CloseHand` `0x479660`).
|
||||
pub(super) fn sync_dressup_booth(
|
||||
mut commands: Commands,
|
||||
preview: Res<DressUpPreview>,
|
||||
@@ -198,9 +156,7 @@ pub(super) fn sync_dressup_booth(
|
||||
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
|
||||
mut env_cache: Local<Option<bool>>,
|
||||
mut last: Local<Option<(u64, f32)>>,
|
||||
// Whether a bake is currently STANDING on the stage — the empty arm's "is there anything to
|
||||
// tear down?" gate. `Booth::baked` can't answer it here: that field keys the mirrored booths'
|
||||
// look, and this one is revision-keyed.
|
||||
// Whether a bake stands on the stage; `Booth::baked` keys the mirrored booths, not this one.
|
||||
mut staged: Local<bool>,
|
||||
) {
|
||||
if super::test_mode(&mut env_cache) {
|
||||
@@ -209,8 +165,7 @@ pub(super) fn sync_dressup_booth(
|
||||
let Some(booth) = booths.0.get_mut(DRESSUP_SLOT) else {
|
||||
return;
|
||||
};
|
||||
// The destination pane's aspect, latched while it is on screen (decision 1069) — the dressing
|
||||
// room's is 316×351, so baking square made every character 11% too tall.
|
||||
// The pane's aspect, latched while on screen: the dressing room's is 316×351, not square.
|
||||
let aspect = panes.0.get(DRESSUP_SLOT).copied().unwrap_or(booth.aspect);
|
||||
let (last_rev, last_yaw) = last.unwrap_or((u64::MAX, f32::NAN));
|
||||
let rebake = last_rev != bake.revision || booth.aspect != aspect;
|
||||
@@ -220,11 +175,8 @@ pub(super) fn sync_dressup_booth(
|
||||
booth.aspect = aspect;
|
||||
|
||||
if rebake {
|
||||
// Nothing to show (the window is closed, or the assembly cleared) → empty the stage and
|
||||
// let the camera sleep once it has rendered the emptied frame (decision 0540). Only when
|
||||
// something WAS standing here: at startup this arm runs with an empty stage already, and
|
||||
// arming the wake there paid four full booth passes to re-render nothing (caught in the
|
||||
// 1060 probe's own `[booth] t=0.00 dressup active=true wake=4` line).
|
||||
// Nothing to show: empty the stage, and wake only if something stood there, so an
|
||||
// already-empty stage at startup costs no booth passes.
|
||||
if bake.parts.is_empty() {
|
||||
if *staged {
|
||||
commands.entity(booth.root).despawn_related::<Children>();
|
||||
@@ -232,8 +184,7 @@ pub(super) fn sync_dressup_booth(
|
||||
booth.wake = BOOTH_SETTLE_FRAMES;
|
||||
booth.live = false;
|
||||
booth.pending.clear();
|
||||
// The despawn reaped meshes and anchors; the rig state on the ROOT needs its
|
||||
// own strip ([`super::clear_booth_rig`]).
|
||||
// The despawn reaped meshes and anchors; the rig state on the root needs its own.
|
||||
super::clear_booth_rig(&mut commands, booth.root);
|
||||
booth.rigged = false;
|
||||
booth.parked = false;
|
||||
@@ -242,8 +193,8 @@ pub(super) fn sync_dressup_booth(
|
||||
*last = Some((bake.revision, preview.yaw));
|
||||
return;
|
||||
}
|
||||
// The rig + framing come from the display cache — the same readiness the assembly gated
|
||||
// on, so both are ready together. If somehow not, leave `last` alone and retry next frame.
|
||||
// Rig and framing come from the display cache the assembly gated on; if not ready,
|
||||
// leave `last` alone and retry next frame.
|
||||
let Some(creatures) = creatures.as_deref() else {
|
||||
return;
|
||||
};
|
||||
@@ -263,7 +214,7 @@ pub(super) fn sync_dressup_booth(
|
||||
skinned: p.skinned_mesh.clone(),
|
||||
static_mesh: p.static_mesh.clone(),
|
||||
material: relight(&p.material),
|
||||
// `None` — the same known gap as the glue preview's (decision 0807).
|
||||
// Not built, as in the glue preview.
|
||||
alpha_anim: None,
|
||||
twins: BoothTwins::default(),
|
||||
mat_anim: false,
|
||||
@@ -292,7 +243,7 @@ pub(super) fn sync_dressup_booth(
|
||||
twins: BoothTwins::default(),
|
||||
})
|
||||
.collect();
|
||||
// Never latch a world-lane material into a pane (the [`super::light`] law): retry instead.
|
||||
// Never latch a world-lane material into a pane ([`super::light`]): retry instead.
|
||||
if booth_light.pane.take_unready() {
|
||||
booth.wake = booth.wake.max(BOOTH_SETTLE_FRAMES);
|
||||
return;
|
||||
@@ -309,8 +260,7 @@ pub(super) fn sync_dressup_booth(
|
||||
.as_ref()
|
||||
.map(|ibp| (rig.skeleton, ibp, rig.animations)),
|
||||
anim_data.as_deref().map(|a| &a.0),
|
||||
// Stand LOOPING, like the paper doll beside it — the reference's `<DressUpModel>` is a
|
||||
// live-rendering widget (0822 §4) and the director called the pose (decision 1069).
|
||||
// Stand looping, like the paper doll: `<DressUpModel>` renders live.
|
||||
BoothMotion::Loop,
|
||||
bake.grip,
|
||||
&booth_billboards,
|
||||
@@ -332,20 +282,17 @@ pub(super) fn sync_dressup_booth(
|
||||
.collect::<Vec<_>>(),
|
||||
BoothInstance::default(),
|
||||
);
|
||||
// The bake animates, so its camera can't sleep — `gate_booth_cameras` runs it every frame
|
||||
// the window is drawing this pane, and none once it closes.
|
||||
// The turn's node bookkeeping named the player this bake just replaced ([`Turn::rebaked`]).
|
||||
// The bake animates, so `gate_booth_cameras` runs its camera every frame the pane draws.
|
||||
booth.turn.rebaked();
|
||||
booth.live = true;
|
||||
// A fresh bake is animated by construction; the park state is the new rig's.
|
||||
// A fresh bake is animated; the park state is the new rig's.
|
||||
booth.rigged = booth_rig.rigged();
|
||||
booth_rig.finish(&mut commands);
|
||||
booth.parked = false;
|
||||
*staged = true;
|
||||
aim(&mut cams, DRESSUP_SLOT, &body_frame(&anchors, aspect));
|
||||
// `WOW_BOOTH_LOG=1` — one line per committed bake, the same instrument the mirrored booths
|
||||
// carry (`super::log_bake`, whose signature is the mirrored part types'). It is what
|
||||
// separates "the pane is black because nothing baked" from "…because the camera is wrong".
|
||||
// `WOW_BOOTH_LOG=1`: one line per committed bake, as `super::log_bake` for the mirrored
|
||||
// booths.
|
||||
if super::booth_log() {
|
||||
eprintln!(
|
||||
"[booth] dressup bake parts={} riders={} billboards={} fx={} rev={} aspect={aspect:.3}",
|
||||
@@ -366,14 +313,9 @@ pub(super) fn sync_dressup_booth(
|
||||
.chain(booth_billboards.iter().map(|b| &b.material)),
|
||||
);
|
||||
}
|
||||
// The yaw (the ref's `Model:SetRotation`) — applied on a fresh bake and on every spin, never on
|
||||
// an idle frame. A spin is a content edge too (decision 0540).
|
||||
//
|
||||
// And the other half of `SetRotation`: the turn-in-place shuffle
|
||||
// ([`super::booth::drive_booth_turn`], 1559). The dressing room wires the same held-arrow
|
||||
// `OnUpdate` the character window does (the stock `Model_OnUpdate`, 1969), so it steps its feet
|
||||
// the same way. Keyed on the yaw alone — this block also runs for a re-bake, which is a
|
||||
// `RefreshUnit` in the reference and does not turn the model.
|
||||
// The yaw, `Model:SetRotation`, applied on a fresh bake and on every spin. A spin also steps
|
||||
// the feet ([`super::booth::drive_booth_turn`]), as the stock `Model_OnUpdate` held-arrow
|
||||
// turn does; keyed on the yaw alone, since a re-bake is a `RefreshUnit` and does not turn.
|
||||
if booth.turn.faced != Some(preview.yaw) {
|
||||
if let Some(prev) = booth.turn.faced {
|
||||
booth.turn.spun = Some(super::booth::turn_shuffle(prev, preview.yaw));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,6 @@
|
||||
//! The booths' **light rigs** — the two fixed light buffers a booth bake can be lit by, the
|
||||
//! per-light material-twin cache, and the reference values behind each.
|
||||
//!
|
||||
//! A booth model is drawn with the world's own materials, cloned with only their light storage
|
||||
//! swapped ([`material_variant`]) — so a bake never drifts from what is standing in the world, and
|
||||
//! never inherits the world's time of day. Which light it swaps to is a *fidelity* question with two
|
||||
//! different answers ([`BoothLight`]): the round unit-frame portraits keep our neutral
|
||||
//! [`studio_light`], while the body panes — transcriptions of 1.12 `<PlayerModel>` widgets —
|
||||
//! carry the reference widget's own light, [`model_pane_light`] (decision 0638).
|
||||
//! The booths' light rigs: two fixed light buffers, the per-light material-twin cache, and the
|
||||
//! reference values behind each. A booth draws with the world's own materials, cloned with only
|
||||
//! the light storage swapped ([`material_variant`]), so it never inherits the time of day.
|
||||
|
||||
use benilla_world::lighting::LightBlob;
|
||||
use std::collections::HashMap;
|
||||
@@ -15,39 +9,29 @@ use bevy::prelude::*;
|
||||
|
||||
use benilla_assets::materials::WowModelMaterial;
|
||||
|
||||
/// One booth light: its own copy of the shared-light storage buffer (the canonical
|
||||
/// [`benilla_world::lighting::LIGHT_HEADER_ROWS`]-row std430 layout, lit lanes packed by the scene's own
|
||||
/// packer), written ONCE at startup — so a booth reads the same at noon, midnight, or in a fog bank
|
||||
/// (the world buffer would render a night portrait pitch black). `variants` caches, per world
|
||||
/// material, its booth twin: an exact clone with only `light_buf` swapped — zero drift from the
|
||||
/// world-built material (same texture/blend/flags), different light.
|
||||
/// One booth light: its own copy of the shared-light storage buffer, written once at startup so a
|
||||
/// booth reads the same at noon, midnight or in fog. `variants` caches each world material's twin,
|
||||
/// an exact clone with only `light_buf` swapped.
|
||||
#[derive(Default)]
|
||||
pub(super) struct BoothRig {
|
||||
pub(super) buffer: Option<bevy::render::render_resource::Buffer>,
|
||||
variants: HashMap<AssetId<WowModelMaterial>, Handle<WowModelMaterial>>,
|
||||
/// Set by [`Self::variant`] when it could NOT build a real booth twin because the source
|
||||
/// material was not resident yet — see [`Self::take_unready`].
|
||||
/// Set by [`Self::variant`] when the source material was not resident yet.
|
||||
unready: bool,
|
||||
}
|
||||
|
||||
impl BoothRig {
|
||||
/// The booth twin of a world-built material: same everything, this rig's light buffer. Cached
|
||||
/// per source material so twins dedup exactly like their sources.
|
||||
///
|
||||
/// **A caller that is about to COMMIT a bake must consult [`Self::take_unready`] first.** When
|
||||
/// the source material is not resident this returns the world material itself, which is bound
|
||||
/// to the WORLD light buffer — and per this module's own header that is what "would render a
|
||||
/// night portrait pitch black". Latching one into a booth is never right: the bake is stored
|
||||
/// in a `MeshMaterial3d` and the booth then sleeps (0540), so a single unlucky frame freezes a
|
||||
/// world-lit — or unlit — pane until something else forces a re-bake.
|
||||
/// The booth twin of a world-built material, cached per source. When the source is not
|
||||
/// resident this returns the world material itself, on the world light buffer: a caller about
|
||||
/// to commit a bake must check [`Self::take_unready`] first, since a sleeping booth would keep
|
||||
/// that wrongly lit pane until something forces a re-bake.
|
||||
pub(super) fn variant(
|
||||
&mut self,
|
||||
world: &Handle<WowModelMaterial>,
|
||||
materials: &mut Assets<WowModelMaterial>,
|
||||
) -> Handle<WowModelMaterial> {
|
||||
let Some(buffer) = self.buffer.clone() else {
|
||||
// No booth buffer at all (headless tests) — there is nothing to wait FOR, so this
|
||||
// stays a plain fallback rather than an unready signal.
|
||||
// No booth buffer (headless tests): nothing to wait for, so a plain fallback.
|
||||
if super::booth_log() {
|
||||
eprintln!("[booth] variant NO-BUFFER -> world lane (unlit in a booth)");
|
||||
}
|
||||
@@ -68,38 +52,29 @@ impl BoothRig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the "a twin could not be built" flag accumulated since the last call. A bake site
|
||||
/// checks this after collecting its parts and, if set, **abandons the bake for this frame**
|
||||
/// without touching `Booth::baked` — the parts-key compare then re-fires next frame and the
|
||||
/// bake retries once the material lands. This is the same law `Booth::pending` already applies
|
||||
/// to not-yet-resident textures, moved one level up to the material itself.
|
||||
/// Take the "a twin could not be built" flag since the last call. When set, the bake site
|
||||
/// abandons this frame's bake without touching `Booth::baked`, so it retries once the
|
||||
/// material lands.
|
||||
pub(super) fn take_unready(&mut self) -> bool {
|
||||
std::mem::take(&mut self.unready)
|
||||
}
|
||||
}
|
||||
|
||||
/// The booths' two lights — they are **not** the same law, because their references aren't:
|
||||
/// The booths' two lights, each with its own variant cache since a twin points at one buffer:
|
||||
///
|
||||
/// - [`Self::studio`] lights the round unit-frame **portraits**. The reference bakes those through
|
||||
/// its own portrait render (`SetPortraitTexture`), not through a UI model widget, so this stays
|
||||
/// our fixed neutral front-lit studio ([`studio_light`]) — the director-approved look.
|
||||
/// - [`Self::pane`] lights the **body panes** — the character window's paper doll and the inspect
|
||||
/// window's twin. Those transcribe a `<PlayerModel>` widget, and the reference gives every one of
|
||||
/// those exactly one light, from its own constructor ([`model_pane_light`]).
|
||||
///
|
||||
/// Two buffers means two variant caches: a material twin points at exactly ONE light buffer, so a
|
||||
/// twin built for the portraits can never be handed to a body pane.
|
||||
/// - [`Self::studio`] lights the round unit-frame portraits. Deviation: a fixed neutral
|
||||
/// front-lit studio ([`studio_light`]) replaces the portrait bake's hard-coded rig (`0x525b10`:
|
||||
/// ambient 0.45 grey, white directional), because the studio is the look chosen for them.
|
||||
/// - [`Self::pane`] lights the body panes, transcriptions of `<PlayerModel>`, with the one light
|
||||
/// the reference widget's constructor sets ([`model_pane_light`]).
|
||||
#[derive(Resource, Default)]
|
||||
pub(super) struct BoothLight {
|
||||
pub(super) studio: BoothRig,
|
||||
pub(super) pane: BoothRig,
|
||||
}
|
||||
|
||||
/// Reap the booth twins whose world source material died (`AssetEvent::Removed` — e.g. the
|
||||
/// map-scope teardown, `world_map::MapChange`). A twin is its own asset pinned only by this
|
||||
/// cache, so without the reap every world material ever baked through a booth would survive
|
||||
/// the teardown forever (the teleport leak, multiplied per rig). A live bake keeps its
|
||||
/// twin through its own `MeshMaterial3d` clone; only the dedup entry drops.
|
||||
/// Reap the booth twins whose world source died (`AssetEvent::Removed`, e.g. a map teardown); a
|
||||
/// twin is pinned only by this cache, so it would otherwise outlive the teardown.
|
||||
pub(super) fn reap_dead_variants(
|
||||
mut events: MessageReader<AssetEvent<WowModelMaterial>>,
|
||||
mut booths: ResMut<BoothLight>,
|
||||
@@ -112,34 +87,23 @@ pub(super) fn reap_dead_variants(
|
||||
}
|
||||
}
|
||||
|
||||
/// Which lane a [`material_variant`] twin draws on — the two axes the twin can differ from its
|
||||
/// world source on, as one closed choice rather than a bool nobody can read at the call site.
|
||||
/// Which shade lane and fog policy a [`material_variant`] twin draws with.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum VariantLane {
|
||||
/// The world's own shade lane, the batch's authored fog policy kept — the portrait booths,
|
||||
/// which render a model exactly as the world would but under a frozen light.
|
||||
/// The world's shade lane and fog policy, under a frozen light: the portrait booths.
|
||||
World,
|
||||
/// The scene's authored M2 light rig ([`benilla_world::model_render::ShadeSel::Rig`] — the
|
||||
/// probe-slot SH eval + the buffer's point table, decisions 0429/0435) **with fog forced
|
||||
/// OFF**: the glue CHARACTER model takes no fog in the reference (its fill callback
|
||||
/// `0x470ce0` stages none, its collector's fog stays zeroed), and so does a `<Model>` pane
|
||||
/// that never armed any.
|
||||
/// The M2 light rig ([`benilla_world::model_render::ShadeSel::Rig`]) with fog forced off: the
|
||||
/// glue character model takes no fog (its fill callback `0x470ce0` stages none), nor does a
|
||||
/// `<Model>` pane that never armed any.
|
||||
RigUnfogged,
|
||||
/// The rig lane with the batch's **authored** fog policy kept — a `<Model>` pane whose Lua
|
||||
/// armed fog (decision 2027). The per-material UNFOGGED bit (`0x02`) then does its own work,
|
||||
/// which is the reference's own per-batch fork: a fogged pane still draws its UNFOGGED
|
||||
/// materials unfogged (`0x70bb24`). The glue background scene takes this lane too.
|
||||
/// The rig lane with the authored fog policy: a `<Model>` pane whose Lua armed fog, and the
|
||||
/// glue background scene. Unfogged materials (bit `0x02`) still draw unfogged (`0x70bb24`).
|
||||
RigFogged,
|
||||
}
|
||||
|
||||
/// The twin of a world-built material against `buffer`, cached in `variants` — same
|
||||
/// texture/blend/flags, only the light storage swapped, plus whatever [`VariantLane`] asks for.
|
||||
/// [`BoothRig::variant`] is this on [`VariantLane::World`] against one of the two fixed booth
|
||||
/// buffers; the create scene and the UI model tiles pass their own buffers on a rig lane.
|
||||
///
|
||||
/// **`None` means the source material is not resident yet** — there is no twin to hand back, and
|
||||
/// the world material is NOT an acceptable substitute in a booth (wrong light buffer; see
|
||||
/// [`BoothRig::variant`]). Callers wait and retry rather than baking something wrong.
|
||||
/// The twin of a world-built material against `buffer`, cached in `variants`: only the light
|
||||
/// storage swapped, plus what `lane` asks for. `None` means the source is not resident yet; the
|
||||
/// world material is no substitute (wrong light buffer), so callers retry.
|
||||
pub(crate) fn material_variant(
|
||||
variants: &mut HashMap<AssetId<WowModelMaterial>, Handle<WowModelMaterial>>,
|
||||
buffer: &bevy::render::render_resource::Buffer,
|
||||
@@ -150,8 +114,7 @@ pub(crate) fn material_variant(
|
||||
if let Some(twin) = variants.get(&world.id()) {
|
||||
return Some(twin.clone());
|
||||
}
|
||||
// A blend/zfill twin is cloned before the world ever binds it — realize the parked value
|
||||
// first (`model_render::lazy`); a no-op for a material already in the store.
|
||||
// A blend/zfill twin can be cloned before the world binds it: realize the parked value first.
|
||||
benilla_world::model_render::lazy::realize(materials, world.id());
|
||||
let mat = materials.get(world)?;
|
||||
let mut twin = mat.clone();
|
||||
@@ -160,10 +123,8 @@ pub(crate) fn material_variant(
|
||||
twin.extension.sun_scale.x = benilla_world::model_render::ShadeSel::Rig.selector();
|
||||
}
|
||||
if lane == VariantLane::RigUnfogged {
|
||||
// Force fog OFF while preserving every pipeline marker `specialize` keys on (bits 0-3
|
||||
// AND the 0528 multiply markers, bits 7-8) — the mask is owned by `model_render`, next
|
||||
// to the packer. A hand-rolled `as u8 & 0x0f` here once dropped the multiply markers
|
||||
// and alpha-blended the char-select weapon sheen into a white blade.
|
||||
// Fog off, keeping every pipeline marker `specialize` keys on (bits 0-3 and the multiply
|
||||
// markers, bits 7-8); the mask belongs to `model_render`, next to the packer.
|
||||
twin.extension.clutter_fade.z = benilla_world::model_render::replace_fog_policy(
|
||||
twin.extension.clutter_fade.z,
|
||||
benilla_formats::FogPolicy::Off,
|
||||
@@ -174,77 +135,44 @@ pub(crate) fn material_variant(
|
||||
Some(handle)
|
||||
}
|
||||
|
||||
/// The **model pane's** light rows — the reference's own, for the `<PlayerModel>` panes (the
|
||||
/// character window's paper doll, the inspect window's twin). Byte-VERIFIED from the 1.12 client
|
||||
/// this session (decision 0638), because nothing in FrameXML sets it and the widget default decides:
|
||||
/// The `<PlayerModel>` panes' light, the widget default (no FrameXML sets one):
|
||||
///
|
||||
/// - `"PlayerModel"` registers factory `0x495bd0` (widget-type table, `0x49597a`), which allocates
|
||||
/// `CharacterModelBase` (`0x3f8` B, the source-path string at `0x84351c`) and runs the ctor
|
||||
/// `0x505680`. `DressUpModel`/`TabardModel` are subclasses of the same base — same light.
|
||||
/// - That ctor configures the widget's embedded `CGLight` at `+0x324`: **directional**
|
||||
/// (`0x71b620(light, 0)` @`0x5056c9`), **direction `(0, 1, 0)` — the direction the light
|
||||
/// PROPAGATES** (`0x71b6a0` @`0x505761` writes `CGLight+0x24`, which `0x71bce0` negates — three
|
||||
/// `fchs` at `71be7c`/`71be81`/`71be86` — before building the SH basis; 0638 read the field as
|
||||
/// a to-light vector, 2034 corrects it — it overwrites an earlier `(0, −0.7071, −0.7071)` set at
|
||||
/// `0x5056e9`, dead code), **diffuse
|
||||
/// `(0.8, 0.8, 0.64)`** (`+0x3c`, @`0x505702`), **ambient `(0.7, 0.7, 0.7)`** (`+0x30`,
|
||||
/// @`0x50572e`), then **enables** it (`0x71b780(light, 1)` @`0x50576a`).
|
||||
/// - It is the widget's ONLY light: the per-frame fill callback `0x76d680` (registered on the
|
||||
/// model at `[model+0x3bc]` by `0x76cd30`) stages exactly this one, gated on its enable flag; the
|
||||
/// widget's private scene container holds no others (a paper doll has no background scene M2
|
||||
/// authoring lights, unlike the glue screens — create's `SetCharCustomizeBackground 0x4707b0`
|
||||
/// loads one), and `CSimpleModel::SetLight` (`0x76cf30`) has exactly one caller in the whole
|
||||
/// binary — the Lua `Model:SetLight` binding, which no 1.12 FrameXML file ever calls.
|
||||
/// - No `×2.5` exterior-intensity node on this path (widgets carry no lighting node, and the ×2.5
|
||||
/// node-apply `0x6a7300` is world-only), so row 19's dial stays at the studio's `0.4` =
|
||||
/// intensity 1.0.
|
||||
/// - `"PlayerModel"` registers factory `0x495bd0` (table at `0x49597a`), which allocates
|
||||
/// `CharacterModelBase` (`0x3f8` bytes) and runs the ctor `0x505680`; `DressUpModel` and
|
||||
/// `TabardModel` subclass the same base.
|
||||
/// - The ctor sets the embedded `CGLight` at `+0x324`: directional (`0x71b620` at `0x5056c9`),
|
||||
/// propagation direction `(0, 1, 0)` (`0x71b6a0` at `0x505761` writes `+0x24`, which `0x71bce0`
|
||||
/// negates at `0x71be7c` before the SH basis; it overwrites a dead `(0, -0.7071, -0.7071)` from
|
||||
/// `0x5056e9`), diffuse `(0.8, 0.8, 0.64)` (`+0x3c`, `0x505702`), ambient `(0.7, 0.7, 0.7)`
|
||||
/// (`+0x30`, `0x50572e`), enabled (`0x71b780` at `0x50576a`).
|
||||
/// - It is the widget's only light: the fill callback `0x76d680` (set at `[model+0x3bc]` by
|
||||
/// `0x76cd30`) stages just this one, and `CSimpleModel::SetLight` (`0x76cf30`) is reached only
|
||||
/// from Lua `Model:SetLight`, which no 1.12 FrameXML calls.
|
||||
/// - No ×2.5 exterior node on this path (`0x6a7300` is world-only): intensity 1.0.
|
||||
///
|
||||
/// **Why this reads so much darker than the studio light, and why that is the point.** The
|
||||
/// reference's light is a pure SIDE light: it travels toward WoW `(0, 1, 0)`, the model's own left
|
||||
/// — Bevy `(−1, 0, 0)` under [`wow_to_bevy`] — so it falls on the figure from its right, square
|
||||
/// across the camera axis, since the body rig puts the eye on `−Z` looking at the model's front
|
||||
/// ([`framing::body_frame`]). Everything the pane actually shows the viewer is therefore lit by
|
||||
/// **ambient alone** (`0.7`), with the diffuse only grazing the figure's screen-left side (its own
|
||||
/// right; 0638 had it grazing the screen-right side, the same grazing light from the opposite
|
||||
/// side, which is why the A/B that approved 0638's look could not tell the two apart — 2034). The
|
||||
/// studio light aims its diffuse from the camera's own
|
||||
/// side, so the same surfaces got `0.58 + 0.85·(N·L≈1) ≈ 1.4` — roughly twice the reference, which
|
||||
/// is exactly the "too brightly lit" the director reported.
|
||||
/// The light travels toward the model's own left (Bevy `(-1, 0, 0)` under [`wow_to_bevy`]),
|
||||
/// square across the body rig's view axis ([`framing::body_frame`]), so what the pane shows is
|
||||
/// lit by ambient alone and the diffuse only grazes the figure's screen-left side.
|
||||
pub(super) fn model_pane_light() -> LightBlob {
|
||||
// The builder takes the light's PROPAGATION direction (it negates it back into a to-light
|
||||
// vector for the SH fold) — and the ctor's `(0,1,0)` IS the propagation direction
|
||||
// (`CGLight+0x24`, negated by `0x71bce0` before the SH basis), so it goes in as written.
|
||||
// The builder takes the propagation direction, which is what the ctor's `(0,1,0)` is.
|
||||
let sun_dir = benilla_assets::coords::wow_to_bevy([0.0, 1.0, 0.0]);
|
||||
LightBlob::model(
|
||||
[0.7, 0.7, 0.7], // CharacterModelBase ctor, CGLight+0x30
|
||||
[0.8, 0.8, 0.64], // CharacterModelBase ctor, CGLight+0x3c
|
||||
sun_dir,
|
||||
)
|
||||
.dial(0.4) // STALE (see `studio_light` — no shader reads 19.w)
|
||||
.dial(0.4) // row 19.w, which no shader reads
|
||||
}
|
||||
|
||||
/// The fixed studio-light rows (the shared-light std430 layout): neutral warm-white ambient +
|
||||
/// diffuse, the sun travelling from the camera's three-quarter side INTO the scene (so the face the
|
||||
/// portrait shows is the lit one), fog OFF (row 4 w=0), point lights off (row 12.w).
|
||||
///
|
||||
/// This lights the round unit-frame **portraits** only. The body panes have a reference law of
|
||||
/// their own — [`model_pane_light`].
|
||||
///
|
||||
/// The lit-lane rows (0-2, the SH block, the sun DC) come from the SAME packer the scene light
|
||||
/// uses ([`LightBlob`]) — this function used to hand-copy the layout
|
||||
/// and rendered black portraits the day 0354 moved the lit lanes onto rows it never wrote. Only
|
||||
/// the studio *values* live here; the layout lives in one place.
|
||||
/// The round portraits' studio light: neutral warm-white ambient and diffuse, the sun from the
|
||||
/// camera's three-quarter side so the face shown is lit, fog and point lights off. The layout comes
|
||||
/// from the scene's own packer ([`LightBlob`]); only the values live here.
|
||||
pub(super) fn studio_light() -> LightBlob {
|
||||
// −sun_dir is the to-light vector: toward the camera side (−Z, a bit of −X from the yaw, up).
|
||||
let sun_dir = Vec3::new(0.25, -0.45, 0.85).normalize();
|
||||
// Fog stays off and the SIDN night lane stays 0 — the builder's off-world defaults.
|
||||
// 19.w — STALE: this was the retired exterior-intensity A/B dial ("0.4 brings the untagged
|
||||
// booth parts from the lit 2.5 rung to intensity 1.0"), but NO shader reads 19.w today. Under
|
||||
// the 0753 trace law the exterior lane commits the sun at ×1 regardless, which is the very
|
||||
// level this dial once aimed for — the drift resolved itself; stated until a portrait-light
|
||||
// pass confirms the booth look.
|
||||
// Fog and the SIDN night lane stay at the builder's off-world defaults; no shader reads 19.w.
|
||||
LightBlob::model(
|
||||
[0.58, 0.56, 0.54], // studio ambient — neutral warm-white
|
||||
[0.58, 0.56, 0.54], // studio ambient, neutral warm-white
|
||||
[0.85, 0.82, 0.78], // studio diffuse
|
||||
sun_dir,
|
||||
)
|
||||
@@ -255,12 +183,8 @@ pub(super) fn studio_light() -> LightBlob {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The reference `<PlayerModel>` light, pinned at the values the 1.12 `CharacterModelBase`
|
||||
/// constructor writes (decision 0638) — and, more importantly, pinned at the *consequence*:
|
||||
/// its light is square across the body rig's view axis, so what the pane shows the viewer is
|
||||
/// ambient-lit only. A swapped axis here is the bug 0638 replaced (a frontal studio key, ~2×
|
||||
/// the reference on every surface the director can see); a flipped sign is 0638's own reading
|
||||
/// of `CGLight+0x24` as a to-light vector, which 2034 corrected to the propagation direction.
|
||||
/// The `CharacterModelBase` ctor's values, and the consequence: the light runs square across
|
||||
/// the view axis, so the pane's visible face is ambient-lit only.
|
||||
#[test]
|
||||
fn model_pane_light_is_the_reference_widget_light_across_the_view_axis() {
|
||||
let blob = model_pane_light();
|
||||
@@ -268,9 +192,7 @@ mod tests {
|
||||
assert_eq!(rows[0][..3], [0.7, 0.7, 0.7], "ambient (CGLight+0x30)");
|
||||
assert_eq!(rows[1][..3], [0.8, 0.8, 0.64], "diffuse (CGLight+0x3c)");
|
||||
|
||||
// Row 2 is the light's PROPAGATION direction, and the ctor's (0,1,0) IS that direction
|
||||
// (`0x71bce0` negates `CGLight+0x24` before the SH basis — 2034); the to-light is its
|
||||
// negation.
|
||||
// Row 2 is the propagation direction; the to-light is its negation.
|
||||
let sun = Vec3::from_slice(&rows[2][..3]);
|
||||
assert!(
|
||||
sun.abs_diff_eq(benilla_assets::coords::wow_to_bevy([0.0, 1.0, 0.0]), 1e-6),
|
||||
@@ -281,21 +203,17 @@ mod tests {
|
||||
to_light.abs_diff_eq(Vec3::new(1.0, 0.0, 0.0), 1e-6),
|
||||
"to-light = -wow_to_bevy(0,1,0): {to_light:?}"
|
||||
);
|
||||
// The body rig puts the eye on −Z looking at +Z (`framing::body_frame`), so a surface facing
|
||||
// the camera has normal −Z: N·L = 0, i.e. NO diffuse on anything the pane shows front-on.
|
||||
// The eye is on -Z looking at +Z, so a face toward the camera gets no diffuse.
|
||||
let facing_camera = Vec3::NEG_Z;
|
||||
assert!(
|
||||
facing_camera.dot(to_light).abs() < 1e-6,
|
||||
"the reference light grazes the pane's visible face, it does not key it"
|
||||
);
|
||||
// …while the figure's screen-LEFT side (the camera's right is −X in world, so +X is its
|
||||
// left) is the one that catches it — the model's own right, the side the light travels
|
||||
// away from.
|
||||
// +X, the figure's screen-left and its own right, catches it.
|
||||
assert!(Vec3::X.dot(to_light) > 0.99, "lit from the viewer's left");
|
||||
}
|
||||
|
||||
/// The studio light — the round portraits' — is the OPPOSITE arrangement on purpose: its key
|
||||
/// comes from the camera's own side, which is why the two rigs must not share a buffer.
|
||||
/// The studio light keys from the camera's side, the opposite of the pane light.
|
||||
#[test]
|
||||
fn studio_light_keys_from_the_camera_side() {
|
||||
let blob = studio_light();
|
||||
|
||||
+368
-1351
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,6 @@
|
||||
//! The `WOW_PORTRAIT_TEST` debug bake — the booth pipeline's server-less eyeball harness
|
||||
//! (`WOW_PORTRAIT_TEST=<Model\\Path.mdx>` + optional `WOW_PORTRAIT_TEST_SKIN=<blp>`): bake that
|
||||
//! model into every slot (portraits + the paper doll's body framing) and own the booths (the live
|
||||
//! syncs and the 0540 demand gate both stand down). Split from `mod.rs` — the harness concern,
|
||||
//! not the live bake path.
|
||||
//! The `WOW_PORTRAIT_TEST=<Model\\Path.mdx>` debug bake (optional
|
||||
//! `WOW_PORTRAIT_TEST_SKIN=<blp>`): bakes that model into every booth slot with no server and owns
|
||||
//! the booths, so the live syncs and the demand gate stand down.
|
||||
|
||||
use benilla_assets::M2Model;
|
||||
use bevy::prelude::*;
|
||||
@@ -12,8 +10,7 @@ use super::framing::{body_frame, frame, head_anchor, PortraitAnchors};
|
||||
use super::{aim, test_mode, BoothCam, BoothLight, Booths, PaperDollBooth, PAPERDOLL_SLOT, SLOTS};
|
||||
use benilla_assets::m2_url;
|
||||
|
||||
/// The debug bake driver: when `WOW_PORTRAIT_TEST` is set, bake the named model into every slot once
|
||||
/// it loads, and own the booths (the live sync yields). See [`bake_test`].
|
||||
/// When `WOW_PORTRAIT_TEST` is set, bake the named model into every slot once it loads.
|
||||
pub(super) fn sync_test_portraits(
|
||||
mut commands: Commands,
|
||||
booths: Res<Booths>,
|
||||
@@ -53,10 +50,8 @@ pub(super) fn sync_test_portraits(
|
||||
}
|
||||
}
|
||||
|
||||
/// The debug bake: load the env model once, then spawn its submeshes (real WowModelMaterial, untextured
|
||||
/// → the muted fallback) into every slot and frame each camera. A pipeline eyeball only — no skins, no
|
||||
/// cache. Returns `true` once the model has loaded + a light buffer exists and it's baked (the caller
|
||||
/// then stops re-baking).
|
||||
/// Load the env model once, spawn its submeshes into every slot and frame each camera; `true` once
|
||||
/// baked. All submeshes, no geoset filter and no cache: an untextured part gets the muted fallback.
|
||||
fn bake_test(
|
||||
commands: &mut Commands,
|
||||
palettes: &mut benilla_world::rig_palette::RigPalettes,
|
||||
@@ -75,21 +70,19 @@ fn bake_test(
|
||||
let handle = test_handle
|
||||
.get_or_insert_with(|| asset_server.load(m2_url(path)))
|
||||
.clone();
|
||||
// Bake only once the asset lands and the studio-light buffer exists (the material needs it).
|
||||
// Bake only once the asset lands and the light buffers exist.
|
||||
let Some(model) = m2s.get(&handle) else {
|
||||
return false;
|
||||
};
|
||||
// The portraits' studio light and the body pane's own (decision 0638) — the harness bakes each
|
||||
// slot against the light that slot really uses, so the eyeball shows what ships.
|
||||
// Each slot bakes against the light it really uses: the portraits' studio, the pane's own.
|
||||
let (Some(studio), Some(pane)) = (
|
||||
booth_light.studio.buffer.clone(),
|
||||
booth_light.pane.buffer.clone(),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
// Optional real skin for the test bake (WOW_PORTRAIT_TEST_SKIN=<blp path>) — an untextured model
|
||||
// reads dark brown by design (the muted fallback is a gamma-dark albedo), so brightness parity
|
||||
// with the world is only judgeable textured.
|
||||
// An untextured model reads dark brown (the muted fallback), so brightness is only judgeable
|
||||
// with a real skin.
|
||||
let skin: Option<Handle<Image>> = std::env::var("WOW_PORTRAIT_TEST_SKIN")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
@@ -99,7 +92,7 @@ fn bake_test(
|
||||
p.replace('\\', "/").to_ascii_lowercase()
|
||||
))
|
||||
});
|
||||
// The test model's render forms, built NOW (decision 0834) — a dev harness bake, one model.
|
||||
// Built now: a harness bake of one model.
|
||||
forms.ensure_now_rigged(&handle, &model.submeshes, mesh_assets);
|
||||
let built = forms.slices(&handle);
|
||||
let (stat_forms, skin_forms) = (built.stat, built.skin.unwrap_or(&[]));
|
||||
@@ -116,8 +109,8 @@ fn bake_test(
|
||||
.get(pi)
|
||||
.map(|(h, _)| h.clone())
|
||||
.unwrap_or_default(),
|
||||
// The booth look: the slot's own light buffer, sky lane (never ground-shaded),
|
||||
// frozen at t = 0 — so no UV scroll and the tint seeded at its first key.
|
||||
// The slot's light buffer, sky lane, frozen at t = 0: no UV scroll, the tint
|
||||
// at its first key.
|
||||
material: mats.off_world(
|
||||
s,
|
||||
s.texture.clone().or_else(|| skin.clone()),
|
||||
@@ -125,8 +118,7 @@ fn bake_test(
|
||||
light,
|
||||
false,
|
||||
),
|
||||
// The harness parses submeshes straight off the model, so the authored alpha is
|
||||
// right here — an eyeball bake should show the batch dimming the artist wrote.
|
||||
// Straight off the model, so the authored alpha animation is at hand.
|
||||
alpha_anim: s.alpha_anim.clone(),
|
||||
twins: BoothTwins::default(),
|
||||
mat_anim: false,
|
||||
@@ -184,16 +176,13 @@ fn bake_test(
|
||||
catalog,
|
||||
BoothMotion::Frozen,
|
||||
[false, false], // the WOW_PORTRAIT_TEST bake dresses no weapons
|
||||
&[], // …nor an eye-glow
|
||||
&[], // nor an eye-glow
|
||||
BoothInstance::default(),
|
||||
)
|
||||
.finish(commands);
|
||||
aim(cams, token, &rig);
|
||||
}
|
||||
// Also drive the paper-doll booth from the same model, so `WOW_PORTRAIT_TEST` eyeballs the
|
||||
// full-body framing (feet/crown crop) server-less. Same all-submesh caveat as the portraits
|
||||
// (no geoset filter — a character bakes stacked hair, 0118); the live pane mirrors the filtered
|
||||
// player. Spun to the default yaw so the still reads three-quarter like the pane's default.
|
||||
// The paper-doll booth too, to check the full-body framing, spun to the pane's default yaw.
|
||||
if let Some(booth) = booths.0.get(PAPERDOLL_SLOT) {
|
||||
commands.entity(booth.root).despawn_related::<Children>();
|
||||
spawn_booth_model(
|
||||
@@ -201,7 +190,7 @@ fn bake_test(
|
||||
palettes,
|
||||
booth.root,
|
||||
booth.layer.clone(),
|
||||
&pane_parts, // the pane's own light, not the portraits' studio (decision 0638)
|
||||
&pane_parts, // the pane's own light, not the portraits' studio
|
||||
&[],
|
||||
Some((
|
||||
&model.skeleton,
|
||||
@@ -210,12 +199,12 @@ fn bake_test(
|
||||
)),
|
||||
catalog,
|
||||
BoothMotion::Frozen,
|
||||
[false, false], // the paper-doll still sheaths its weapons — no in-hand grip
|
||||
&[], // eye-glow in the paper doll is the same follow-up (see above)
|
||||
[false, false], // the harness bake holds no weapons, so no in-hand grip
|
||||
&[], // no eye-glow
|
||||
BoothInstance::default(),
|
||||
)
|
||||
.finish(commands);
|
||||
// The eyeball harness has no live UI publishing a pane, so it bakes square.
|
||||
// No live UI publishes a pane aspect here, so it bakes square.
|
||||
aim(cams, PAPERDOLL_SLOT, &body_frame(&anchors, 1.0));
|
||||
commands
|
||||
.entity(booth.root)
|
||||
@@ -226,33 +215,21 @@ fn bake_test(
|
||||
true
|
||||
}
|
||||
|
||||
/// `WOW_BOOTH_DUMP=<token>:<path>:<secs>`: once `secs` of app time have elapsed, screenshot the
|
||||
/// named booth's render target (e.g. `paperdoll`) to `path`. A probe run can then look at the
|
||||
/// pane a live session would see under the character window — without a UI click path (the
|
||||
/// first-login black-pane hunt). One shot per run; inert without the env.
|
||||
/// `WOW_BOOTH_DUMP=<token>:<path>:<secs>`: after `secs` of app time, save the named booth's render
|
||||
/// target (e.g. `paperdoll`) to `path` as a PNG, once; inert without the env.
|
||||
///
|
||||
/// **It must WAKE the booth before it shoots, and that is the whole subtlety.** Bevy's
|
||||
/// `Screenshot::image` does not read the target texture's existing contents: it substitutes a
|
||||
/// fresh `screenshot-capture-rendertarget` as that target's output attachment and hands back
|
||||
/// whatever is *rendered into it during the capture frame* (`bevy_render`'s
|
||||
/// `prepare_screenshots`). Under the 0540 demand gate a settled booth's camera is inactive and
|
||||
/// its target simply "keeps the last render" — so shooting it while it sleeps renders nothing
|
||||
/// into the substituted attachment and the PNG comes back a uniform `RGBA(0,0,0,0)`, which is
|
||||
/// exactly the `Image::new_fill` pattern in `new_target_image` and exactly what this
|
||||
/// instrument produced for its entire first life (every leg of the B106 hunt, control included,
|
||||
/// byte-identical). So: arm `Booth::wake` first, hold it armed, and take the shot a few frames
|
||||
/// later while the camera is still rendering. A dump that ever comes back uniformly transparent
|
||||
/// again means the wake is not reaching the gate — treat it as a broken instrument, not a black
|
||||
/// pane.
|
||||
/// It must wake the booth first: `Screenshot::image` returns only what renders during the capture
|
||||
/// frame (`bevy_render`'s `prepare_screenshots`), and a settled booth's camera is inactive, so a
|
||||
/// sleeping shot comes back uniform `RGBA(0,0,0,0)`. A uniformly transparent dump means the wake
|
||||
/// is not reaching the gate, not a black pane.
|
||||
pub(super) fn dump_booth_target(
|
||||
mut commands: Commands,
|
||||
mut booths: ResMut<Booths>,
|
||||
time: Res<Time<bevy::time::Real>>,
|
||||
mut phase: Local<u32>,
|
||||
) {
|
||||
/// Frames to hold the booth awake before taking the shot: the gate reads `wake` and flips
|
||||
/// `Camera::is_active` in the same frame, so one would do — a small margin covers the
|
||||
/// command-applied camera flip and the render-app extract behind it.
|
||||
/// Frames awake before the shot: one would do, the margin covers the deferred camera flip and
|
||||
/// the render-app extract.
|
||||
const WAKE_LEAD: u32 = 3;
|
||||
const DONE: u32 = u32::MAX;
|
||||
|
||||
@@ -276,7 +253,7 @@ pub(super) fn dump_booth_target(
|
||||
*phase = DONE;
|
||||
return;
|
||||
};
|
||||
// Hold the gate open across the lead AND the capture frame itself.
|
||||
// Hold the gate open across the lead and the capture frame.
|
||||
booth.wake = booth.wake.max(super::BOOTH_SETTLE_FRAMES);
|
||||
if *phase < WAKE_LEAD {
|
||||
if *phase == 0 {
|
||||
@@ -306,18 +283,9 @@ pub(super) fn dump_booth_target(
|
||||
});
|
||||
}
|
||||
|
||||
/// Turn a booth render-target readback into something the PNG encoder can write — and, more to the
|
||||
/// point, into what the **screen** shows.
|
||||
///
|
||||
/// The targets are `Rgba16Float` holding **un-encoded** values (`super::new_target_image`): the
|
||||
/// display encode is the UI arc's, applied when the glue/paper-doll tree samples the target
|
||||
/// (`crate::ui_gamma`), and a readback bypasses that lane entirely. So it happens here. Without it
|
||||
/// the PNG is ~2.2× dark — which is precisely what this instrument produced for its whole first life
|
||||
/// (it relabeled the un-encoded 8-bit bytes as sRGB and saved them verbatim), and a dump that reads
|
||||
/// darker than the screen is a debugging instrument that lies about the thing it exists to show.
|
||||
///
|
||||
/// `None` on an unexpected format, so a future target-format change is a loud warning rather than a
|
||||
/// garbled PNG.
|
||||
/// Encode a booth readback as the screen shows it. The targets are `Rgba16Float` holding
|
||||
/// un-encoded values (the display encode happens where the UI samples them, `crate::ui_gamma`), so
|
||||
/// a readback needs the sRGB curve here or the PNG is about 2.2× dark. `None` on any other format.
|
||||
pub(crate) fn encode_target_readback(shot: &Image) -> Option<Image> {
|
||||
use bevy::asset::RenderAssetUsages;
|
||||
use bevy::render::render_resource::{TextureDimension, TextureFormat};
|
||||
@@ -330,8 +298,8 @@ pub(crate) fn encode_target_readback(shot: &Image) -> Option<Image> {
|
||||
for texel in src.as_chunks::<8>().0 {
|
||||
for (c, half_pair) in texel.as_chunks::<2>().0.iter().enumerate() {
|
||||
let v = half::f16::from_le_bytes([half_pair[0], half_pair[1]]).to_f32();
|
||||
// The sRGB transfer function for colour (channel 3 is plain coverage, never encoded) —
|
||||
// the same curve the swapchain's `…Srgb` write applies to the live frame.
|
||||
// The sRGB transfer function, as the swapchain's `Srgb` write applies; channel 3 is
|
||||
// coverage, never encoded.
|
||||
let encoded = match c {
|
||||
3 => v,
|
||||
_ if v <= 0.003_130_8 => v * 12.92,
|
||||
@@ -369,8 +337,7 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// The dump encodes colour and passes alpha through — the screen's transfer function, applied
|
||||
/// where the readback bypassed the UI lane that would have applied it.
|
||||
/// The dump encodes colour with the screen's transfer function and passes alpha through.
|
||||
#[test]
|
||||
fn the_booth_dump_encodes_colour_and_leaves_alpha_alone() {
|
||||
let texel = |v: f32| half::f16::from_f32(v).to_le_bytes().to_vec();
|
||||
@@ -384,11 +351,9 @@ mod tests {
|
||||
let out = encode_target_readback(&readback(TextureFormat::Rgba16Float, data))
|
||||
.expect("float readback encodes");
|
||||
let bytes = out.data.as_ref().expect("encoded bytes");
|
||||
// 0.5 linear is display 188 — NOT 128. That gap is the whole reason this exists: for its
|
||||
// first life the instrument wrote the 128.
|
||||
// 0.5 linear is display 188, not 128.
|
||||
assert_eq!(bytes[0], 188);
|
||||
// The old 8-bit linear target's second code lands on 13 — the bottom of the ladder B126
|
||||
// measured, and the reason a float target has ~4× the levels down here.
|
||||
// An 8-bit linear target's second code lands on 13: a float target has more levels here.
|
||||
assert_eq!(bytes[1], 13);
|
||||
assert_eq!(bytes[2], 0);
|
||||
// Alpha is coverage, never encoded.
|
||||
|
||||
@@ -160,7 +160,7 @@ impl ProbeShield {
|
||||
}
|
||||
|
||||
/// Whether `user` is a probe account — `probe` followed by digits, the identity every unattended
|
||||
/// run logs in with (the `probe` skill). Nothing else is ours to
|
||||
/// run logs in with (`docs/CONTRIBUTING.md`, "Running it unattended"). Nothing else is ours to
|
||||
/// modify: a player's account is theirs, and a plain test account is not a probe.
|
||||
fn is_probe_account(user: &str) -> bool {
|
||||
// Byte-wise (a &str slice at 5 would panic mid-char on non-ASCII input; bytes can't).
|
||||
|
||||
@@ -106,7 +106,8 @@ pub(crate) fn env_login() -> bool {
|
||||
/// `WOW_CAPTURE` and `WOW_RIG` are folded in because they *cannot* be a person: a capture authors
|
||||
/// the camera and a rig drives the body, so a run that sets either has already said what it is and
|
||||
/// cannot forget to. Everything else declares — `scripts/smoke.sh`, `cine.sh`, `summon-live.sh`
|
||||
/// and the ad-hoc probe recipe in the `probe` skill all pass `WOW_UNATTENDED=1`.
|
||||
/// and an ad-hoc probe run (`docs/CONTRIBUTING.md`, "Running it unattended") all pass
|
||||
/// `WOW_UNATTENDED=1`.
|
||||
///
|
||||
/// Read by whatever may act *instead of* a person: the lost-session verdict (decision 1262 — the
|
||||
/// session-loss readers never call this directly, [`crate::net::DisconnectedMessage::new`] asks
|
||||
|
||||
@@ -1,43 +1,20 @@
|
||||
//! Route M2 animation event tags (`crate::creature_anim::AnimSoundEvent`) to audio — the
|
||||
//! anim-driven trigger surface (decision 0070 slice 3).
|
||||
//! Routes M2 animation event tags ([`AnimSoundEvent`]) to audio.
|
||||
//!
|
||||
//! Routed here: `$SND`/`$DSO` (one-shot kit `data` at the model), `$DSL`/`$DSE` (an ambient loop
|
||||
//! — **registered into, and released from, the emitter pool** [`super::emitter_pool`], which owns
|
||||
//! everything about how it sounds; the two dispatchers' arms differ and both are modelled below),
|
||||
//! `$CSD` — the **character emote clips' embedded voice** (HumanMale EmoteLaugh 70 carries
|
||||
//! `$CSD 6923` = the SoundEntries kit literally named `HumanMaleEmoteLaugh`; Cry 77 → 6921,
|
||||
//! Chicken 78 → 6919, Applaud 80 → 4× `ClapSounds` 6576 — probe-verified on the real 5875 M2 +
|
||||
//! SoundEntries; the client's `$CSD` handler `0x623c10` → `0x459230` plays the event payload as
|
||||
//! a literal SoundEntries id, byte-confirming the routing) — and the **gathering/work pair**
|
||||
//! (decision 0562):
|
||||
//! - `$SND`/`$DSO`: a one-shot kit at the fired key's point.
|
||||
//! - `$DSL`/`$DSE`: register and release an ambient loop in [`super::emitter_pool`].
|
||||
//! - `$CSD`: an emote clip's voice; the payload is a literal SoundEntries id (`0x623c10` →
|
||||
//! `0x459230`), e.g. HumanMale EmoteLaugh's `$CSD 6923` `HumanMaleEmoteLaugh`.
|
||||
//! - `$TRD` (`0x62faa0`): the held spell's SpellVisual field-14 strike sound (the mining pick,
|
||||
//! the smithing hammer). The held spell is the unit's cast hold, cached client-side from the
|
||||
//! local GO interaction (`0x6ec220` → `[CGUnit+0xc8c]`).
|
||||
//! - `$ESD` (`0x6239f0`): the unit's `UNIT_NPC_EMOTESTATE` → `Emotes.dbc` `EventSoundID`, gated on
|
||||
//! `EmoteSpecProc == 2`.
|
||||
//!
|
||||
//! - **`$TRD`** (`0x62faa0`): the in-flight spell's `SpellVisual` **field-14 strike sound**,
|
||||
//! positioned — **the mining pick clang** (visual 93 → 1143 "Mining Impact") and the crafting
|
||||
//! hammer (the smithing visuals carry the same field), fired at the work anims' 0.666 s
|
||||
//! impact keyframe. Fully client-side: the in-flight spell is the unit's cast hold (the
|
||||
//! client caches it from the local GO interaction, `0x6ec220` → `[CGUnit+0xc8c]`), so no
|
||||
//! server state is involved.
|
||||
//! - **`$ESD`** (`0x6239f0`): the unit's `UNIT_NPC_EMOTESTATE` → `Emotes.dbc` `EventSoundID`,
|
||||
//! gated on `EmoteSpecProc == 2`, positioned at the unit — wire-driven work-state sounds
|
||||
//! (a chopwood camp worker's state 234 → 3202; state 233 carries a second mining kit 3782,
|
||||
//! which no vmangos path ever sets for a player — verified at its source).
|
||||
//!
|
||||
//! `$CST`/`$CSL`/`$CSR` are still NOT routed, but **the reason we gave was wrong** and is
|
||||
//! corrected here. The handler `0x60c940` does only
|
||||
//! 3D-**reposition** the already-playing cast handle — that part held — but it repositions it to
|
||||
//! the **event's own point** (`0x600143 mov edx,[ebx+0x10]` → `0x60c960`/`0x60c990` → `0x61ceb0`),
|
||||
//! i.e. to the casting hand, not to the caster. benilla's kit player tracks a looping cast sound
|
||||
//! to the caster's origin, so the role is *not* covered: we are a body-width out for the whole
|
||||
//! cast. Left unbuilt rather than half-built, because what the reference does when the
|
||||
//! GUID-tracked follow and this reposition disagree is not settled here — decision 1915's open.
|
||||
//!
|
||||
//! `$FD1..$FD9`/`$FDX` have no sound route at all yet (they are the CreatureSoundData fidget
|
||||
//! family). When one lands it wants the **unit origin + 2.0 z** (`0x6232c0` → `0x6230a0`), not
|
||||
//! the fired key's point — the same shape `$TRD` takes below.
|
||||
//!
|
||||
//! The footstep family and the CreatureSoundData-driven tags (`$FD*` fidgets, `$AH*` attacks,
|
||||
//! `$CSS` swings) are routed by their own consumers as those land (slice-3 tasks); unrecognized
|
||||
//! tags are trace-logged so the stream is observable without spam.
|
||||
//! `$CST`/`$CSL`/`$CSR` sound nothing here: their handler `0x60c940` launches the unit's queued
|
||||
//! missiles from the event's point (`0x60c991` → `0x61ceb0`), and the only sound is each
|
||||
//! missile's own flight loop, which the launch in `crate::entities::missile` starts. The `$FD*`
|
||||
//! fidgets are routed by [`super::creature`], `$AH*` and `$CSS` by [`super::combat`]; any other
|
||||
//! tag is trace-logged.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -51,26 +28,22 @@ use super::emote::EmoteSounds;
|
||||
use super::kit::{play_kit, KitRef, SoundCategory, SoundKits};
|
||||
use super::{AudioListener, SoundConfig, SoundOutput};
|
||||
|
||||
/// `$TRD`'s own z-bias — `0x62fb3f fadd [0x7ff9d8]`, the constant `1.0`. Distinct from the
|
||||
/// footstep foley's `2.0` and from the per-attachment table, and it is added to the unit's own
|
||||
/// position because this arm is handed no point at all.
|
||||
/// `$TRD`'s height above the unit's origin (`0x62fb3f fadd [0x7ff9d8]`); the arm is handed no
|
||||
/// point.
|
||||
const TRD_HEIGHT: f32 = 1.0;
|
||||
|
||||
/// The attachment the emote voice is born at — `0x623c3a push 0x11`.
|
||||
/// The attachment the emote voice plays at (`0x623c3a push 0x11`).
|
||||
const CSD_ATTACH: u16 = 17;
|
||||
|
||||
pub(super) fn route_anim_events(
|
||||
mut events: MessageReader<AnimSoundEvent>,
|
||||
// GlobalTransform: `$SND` tags can fire from parented visuals (a mount child's model),
|
||||
// whose local Transform is not a world position (0441 fold-back).
|
||||
// GlobalTransform: `$SND` can fire from a parented visual (a mount's model).
|
||||
transforms: Query<&GlobalTransform, Without<Camera3d>>,
|
||||
units: Query<(Option<&ObjectStore>, Option<&CastHold>)>,
|
||||
// Which dispatcher this entity's events arrive on. A family-A GameObject carries
|
||||
// [`crate::go_anim::GoAnim`] — the same population the reference registers `0x5f3e20` for —
|
||||
// and that dispatcher's DS-family arms differ from the placed-M2 handler `0x6951e0`'s.
|
||||
// A family-A GameObject's events take the reference's GameObject dispatcher `0x5f3e20`,
|
||||
// whose `$DS*` arms differ from the placed-M2 handler `0x6951e0`'s.
|
||||
go_lane: Query<(), With<crate::go_anim::GoAnim>>,
|
||||
// The attachment reads `$CSD` needs (`0x623b90`) — the same pure position read the overhead
|
||||
// anchor makes, spawning nothing.
|
||||
// The attachment read `$CSD` needs (`0x623b90`).
|
||||
attach: crate::entities::AttachPoints,
|
||||
kits: Option<ResMut<SoundKits>>,
|
||||
assets: Option<Res<WorldAssets>>,
|
||||
@@ -80,15 +53,10 @@ pub(super) fn route_anim_events(
|
||||
mut out: NonSendMut<SoundOutput>,
|
||||
config: Res<SoundConfig>,
|
||||
listener: Res<AudioListener>,
|
||||
// Kit ids already complained about. A failing kit here is a PER-EVENT failure on a stream that
|
||||
// fires at doodad rates, so warning every time is a log flood, not a diagnostic: one live run
|
||||
// past Darnassus produced 420 identical lines for `NightElfLantern01`'s `$DSL(33764)`, an id
|
||||
// that is simply not in 5875's `SoundEntries` (32401 is the corpus's only other one). The
|
||||
// reference does nothing audible for an id it cannot resolve, so this is data, not an error —
|
||||
// but it is still worth saying once, because a kit that goes missing for any OTHER reason is a
|
||||
// real bug and silence would hide it.
|
||||
// Kit ids already warned about, once each: the stream fires at doodad rates, and a few ids
|
||||
// (`NightElfLantern01`'s `$DSL(33764)`) are absent from SoundEntries, which the reference
|
||||
// plays as silence.
|
||||
mut complained: Local<std::collections::HashSet<u32>>,
|
||||
// The ambient emitter pool — `$DSL`/`$DSE`'s whole destination.
|
||||
mut pool: ResMut<AmbientEmitterPool>,
|
||||
) {
|
||||
if events.is_empty() {
|
||||
@@ -103,8 +71,7 @@ pub(super) fn route_anim_events(
|
||||
kit: u32,
|
||||
ev: &AnimSoundEvent,
|
||||
complained: &mut std::collections::HashSet<u32>| {
|
||||
// The fired key's own point where the arm is byte-proven to pass it (below), else the
|
||||
// model root — which is what every arm here used unconditionally before 1904.
|
||||
// The fired key's point where the reference's arm passes it, else the model root.
|
||||
let pos = ev
|
||||
.pos
|
||||
.or_else(|| transforms.get(ev.entity).map(|t| t.translation()).ok());
|
||||
@@ -125,53 +92,22 @@ pub(super) fn route_anim_events(
|
||||
};
|
||||
for ev in events.read() {
|
||||
match &ev.ident {
|
||||
// `$DSL` — the DOODAD SOUND **LOOP** (`0x69521d`). A persistent handle with a
|
||||
// lifecycle, one per doodad (`[CMapDoodadDef+0x168]`): crossing the marker again
|
||||
// **repositions** the existing registration (`0x462000`) and never restarts it; a
|
||||
// DIFFERENT id releases the old one (`0x461f80`) and registers the new (`0x461d80`). So
|
||||
// there is no wrap retrigger at all — which the shipped audio already implied, since
|
||||
// `NightElfStreetLampLoop` is 4.000 s on a 3.333 s sequence and `CampFireSmallLoop` is
|
||||
// 2.967 s on the same, mismatched in both directions.
|
||||
//
|
||||
// **It always loops, and consults NO flag.** Looping is an entry-point constant on the
|
||||
// reference's registration pool: `0x7a54d0` builds mode `0x1002` (`HW3D|LOOP_NORMAL`)
|
||||
// and calls `SetLoopCount(stream, -1)`, against `0x7a5490`'s `0x1000` for the one-shot
|
||||
// path. This corrects the interim that shipped with the first half of B345: the kit's
|
||||
// 0x200 bit has exactly ONE reader image-wide (`0x458840`), whose two callers are the
|
||||
// GameObject display-slot lane select and the spell-visual lane — it reaches neither
|
||||
// mode word and is a LANE SELECT, not a loop flag. (0x400, which correlated perfectly
|
||||
// with the four non-sustaining `$DSL` kits in the shipped data, is `random pitch`; the
|
||||
// correlation was authoring practice — you do not detune a sustained hum.) `force_loop`
|
||||
// here is therefore the faithful shape, not a workaround: 25 of the 60 kits a `$DSL`
|
||||
// names omit 0x200 and every one of them loops in the real client.
|
||||
// `$DSL` — the DOODAD SOUND **LOOP** (`0x69521d`). It does not start a sound. It
|
||||
// **registers this doodad's position** as one emitter of its SoundEntries id in the
|
||||
// pool at `0xb06dd8` (`0x461d80`), and re-crossing the marker only *repositions* that
|
||||
// registration (`0x462000`) — which is why there is no wrap retrigger, and why
|
||||
// `NightElfStreetLampLoop` (4.000 s of sample on a 3.333 s sequence) is not chopped
|
||||
// every cycle. Whether anything is audible, from where, and how many at once are the
|
||||
// pool pump's questions, not this scanner's: see [`super::emitter_pool`].
|
||||
// `$DSL` (`0x69521d`) starts no sound: it registers the doodad as one emitter of its
|
||||
// id in the pool at `0xb06dd8` (`0x461d80`). Re-crossing the marker only repositions
|
||||
// the registration (`0x462000`), so a wrap never retriggers; a different id releases
|
||||
// the old one (`0x461f80`) and registers the new. The pool always loops, whatever
|
||||
// the kit's flags (`0x7a54d0`); the `0x200` bit is a lane select (`0x458840`).
|
||||
b"$DSL" if ev.data != 0 => {
|
||||
// **The emitter's point is the marker's, not the model's** (decision 1904). Both
|
||||
// handlers take the kernel's `eventWorldPos` as an argument and pass it straight
|
||||
// into the pool: the placed-M2 lane `0x6951e0` is `fn(fourcc, data, &worldPos, …)`
|
||||
// with `[ebp+0x10]` the `C3Vector*` it hands to `0x461d80`/`0x462000`, and the
|
||||
// GameObject lane `0x5f3fe5` does the same with its own `p3`. It is not a detail
|
||||
// here: 149 of the 244 shipped `$DSL` records sit off their model's origin, out to
|
||||
// **67.6 yd** on `Maraudon_Waterfall01.m2` — a waterfall whose roar was landing at
|
||||
// the model's pivot instead of the water.
|
||||
// The emitter sits at the marker, not the model: both lanes pass the event's world
|
||||
// point to the pool (`0x6951e0` via `[ebp+0x10]`, the GameObject arm `0x5f3fe5`
|
||||
// its `p3`). `$DSL` markers sit up to 67.6 yd off their model's origin.
|
||||
if let Some(at) = ev
|
||||
.pos
|
||||
.or_else(|| transforms.get(ev.entity).ok().map(|t| t.translation()))
|
||||
{
|
||||
// **The two lanes' `$DSL` arms differ, and only here**. The placed-M2 handler
|
||||
// `0x6951e0` compares the id and swaps; the GameObject dispatcher's arm
|
||||
// `0x5f3fe5` does **not compare it at all** — a live handle is only
|
||||
// repositioned, whatever id the marker names. Onyxia's lava trap is the case
|
||||
// that makes it observable: `ONYZIASLAIRLAVATRAP.M2` (208 spawns in the lair)
|
||||
// authors `$DSL(8681)` on its chained Stand variation and `$DSL(8682)` on
|
||||
// Custom0, so on the GameObject lane the Custom0 hum never displaces the
|
||||
// Stand one.
|
||||
// The placed-M2 arm `0x6951e0` swaps on a new id; the GameObject arm `0x5f3fe5`
|
||||
// never compares it and only repositions a live handle, so Onyxia's lava trap
|
||||
// keeps its Stand `$DSL(8681)` over Custom0's `$DSL(8682)`.
|
||||
if go_lane.contains(ev.entity) {
|
||||
super::emitter_pool::register_keeping_first(
|
||||
&mut pool, ev.entity, ev.data, at, listener,
|
||||
@@ -181,43 +117,23 @@ pub(super) fn route_anim_events(
|
||||
}
|
||||
}
|
||||
}
|
||||
// `$DSE` — the doodad sound **STOP** token (`0x45534424`): it releases the doodad's
|
||||
// registration (`0x461f80`), and its `data` is 0 on all 16
|
||||
// shipped models. Without it a `$DSL` started at a keyframe never ends — which is
|
||||
// exactly the elevator and machinery family (`GnomereganElevatorLoop`, `SubwayLoop`,
|
||||
// the Undercity and Thunder Bluff lifts, the zeppelin), where the loop is authored to
|
||||
// run for one leg of the animation and stop. Releasing a record is not stopping a
|
||||
// sound: the id keeps sounding while any *other* doodad still names it.
|
||||
// …and `$DSE` has **no arm on the GameObject dispatcher at all** — the token falls to
|
||||
// `0x5f4004 ret`. Seven shipped GO display models author one anyway (the Maraudon
|
||||
// corrupted plants on their Destroy clip, the Blackrock door mechanism on Closed, the
|
||||
// Maraudon teleporter on Open); on that lane what actually drops the registration is
|
||||
// the state-machine dispatch those very clips are armed BY
|
||||
// ([`crate::go_anim::GoStateDispatch`]), so honouring the authored intent and
|
||||
// honouring the bytes agree — but only one of them is the mechanism.
|
||||
// `$DSE` releases the doodad's registration (`0x461f80`), ending a lift's or a
|
||||
// machine's loop; the id keeps sounding while another doodad names it. The GameObject
|
||||
// dispatcher has no `$DSE` arm (`0x5f4004 ret`): there the registration drops with
|
||||
// the state dispatch ([`crate::go_anim::GoStateDispatch`]).
|
||||
b"$DSE" if !go_lane.contains(ev.entity) => {
|
||||
super::emitter_pool::release(&mut pool, ev.entity);
|
||||
}
|
||||
// `$SND`/`$DSO` positioned at the fired key: both proven lanes hand the arm the
|
||||
// kernel's point and it reaches `0x458870(id, pos, -1, 1.0f)` unchanged — the placed-M2
|
||||
// handler at `0x695205`, the GameObject one at `0x5f3fe0 → 0x5f3f60`.
|
||||
//
|
||||
// **`$CSD` is deliberately still at the model root.** It has no arm on either of those
|
||||
// dispatchers: it is the CGUnit lane's (`0x623c10` → `0x459230`), and whether *that*
|
||||
// dispatcher's arms take the event point or the unit's own is the one piece of this
|
||||
// mechanism not yet read from the binary — open, not assumed. It matters: every player
|
||||
// model authors six `$CSD` records, all on the head.
|
||||
// `$SND`/`$DSO` at the fired key: both lanes pass the event's point unchanged to
|
||||
// `0x458870(id, pos, -1, 1.0f)` (placed-M2 `0x695205`, GameObject `0x5f3fe0` →
|
||||
// `0x5f3f60`).
|
||||
b"$SND" | b"$DSO" if ev.data != 0 => {
|
||||
ring(&mut kits, &mut out, ev.data, ev, &mut complained);
|
||||
}
|
||||
// **`$CSD` is at ATTACHMENT 17, never the fired key's point.** The dispatcher hands
|
||||
// `0x623c10` the *data* and no position (`0x5ffeed`); it asks `0x623b90` for
|
||||
// attachment `0x11`, falling back to `GetPosition + zBias[17] = 2.0`. The corpus makes
|
||||
// this the sharpest correction in the table: every player model authors **six** `$CSD`
|
||||
// records, one per emote clip, all on the head — and not one of them decides where the
|
||||
// voice plays. (The reference then GUID-binds the handle so it follows the unit,
|
||||
// `0x7a57e0`/`[unit+0xb28]`; ours is a one-shot at the onset point, which is the same
|
||||
// sound for a body that is not walking away mid-laugh — noted in 1915.)
|
||||
// `$CSD` plays at attachment 17, never the fired key: the CGUnit dispatcher hands
|
||||
// `0x623c10` no position (`0x5ffeed`), and it reads attachment `0x11` (`0x623b90`),
|
||||
// falling back to the origin + 2.0 z. The reference then binds the handle to follow the
|
||||
// unit (`0x7a57e0`, `[unit+0xb28]`); this is a one-shot at the onset point.
|
||||
b"$CSD" if ev.data != 0 => {
|
||||
let root = transforms
|
||||
.get(ev.entity)
|
||||
@@ -242,8 +158,8 @@ pub(super) fn route_anim_events(
|
||||
.then(|| emotes.state_event_sound(state))
|
||||
.flatten()
|
||||
{
|
||||
// **EVENT POINT** — `0x5fff5a` pushes the dispatcher's point and `0x623a1e`
|
||||
// hands it straight to `0x458870` (that `edx` is the point, not the payload).
|
||||
// At the event's point: `0x5fff5a` pushes it and `0x623a1e` hands it to
|
||||
// `0x458870`.
|
||||
ring(&mut kits, &mut out, kit, ev, &mut complained);
|
||||
}
|
||||
}
|
||||
@@ -254,10 +170,8 @@ pub(super) fn route_anim_events(
|
||||
let hold = units.get(ev.entity).ok().and_then(|(_, h)| h);
|
||||
let kit = hold.and_then(|h| held_strike_sound(spells, &visuals.0, h.spell_id));
|
||||
if let Some(kit) = kit {
|
||||
// **UNIT ORIGIN + 1.0 z** — the dispatcher hands `0x62faa0` nothing at all
|
||||
// (`0x5ffedb mov ecx,esi; call`), and it re-derives: `0x62fb39 call [edx+0x14]`
|
||||
// GetPosition, `0x62fb3f fadd [0x7ff9d8]` = +1.0, then `0x458870`. The mining
|
||||
// pick's clang comes from the miner, raised, not from the pick's own keyframe.
|
||||
// At the unit's origin + 1.0 z: `0x62faa0` is handed no point (`0x5ffedb`) and
|
||||
// reads the position itself (`0x62fb39`) before `0x458870`.
|
||||
let at = transforms
|
||||
.get(ev.entity)
|
||||
.map(|t| t.translation() + Vec3::Y * TRD_HEIGHT)
|
||||
|
||||
@@ -1,26 +1,10 @@
|
||||
//! The cinematic **narration** — the race intro's voice-over, and the handle that lets an ESC cut
|
||||
//! it off mid-sentence.
|
||||
//! The cinematic narration: the race intro's voice-over, held by a handle so a skip cuts it off.
|
||||
//!
|
||||
//! Every race fly-by names a `SoundEntries.dbc` id on its `CinematicCamera` row, and all eight are
|
||||
//! the same thing: a single streamed `Sound\CinematicVoices\<Race>Narration.mp3` (kit type 31,
|
||||
//! "DwarfFlyByNarration" and its siblings). The two non-intro rows — `PalantirOfAzora` and
|
||||
//! `Scry_cam` — name id 0 and play nothing.
|
||||
//!
|
||||
//! **Why this lives in the sound module rather than in [`crate::cinematic`].** Starting a stream
|
||||
//! and holding its handle is sound-internal (`pick_stream`, the mixer, the category amp are all
|
||||
//! `pub(super)` here), and the handle is the whole point: a dropped kira handle keeps playing, so
|
||||
//! a skipped cinematic whose narration was merely forgotten would keep narrating over the game for
|
||||
//! another minute. The reference has exactly the same problem and the same answer — it keeps the
|
||||
//! cinematic's sound in a dedicated global (`[0xb4e274]`) and *releases* it on **three** paths,
|
||||
//! which are all of its references besides the write at `0x48ef3e`: `0x48efef` on the shot
|
||||
//! advance — and therefore on the ordinary end, since every shipped sequence is a single shot —
|
||||
//! `0x48f055` on the stop/ESC, and `0x490b8d` in the local-abort teardown. The first was missing
|
||||
//! from this list; the behaviour was not (a shot change and an ended cinematic both stop the voice
|
||||
//! below, which is the same three edges).
|
||||
//!
|
||||
//! So this module watches [`Cinematic`]'s published shot and follows it: a new shot starts its
|
||||
//! narration, and the shot going away stops it. That keeps the coupling one-way — the cinematic
|
||||
//! plugin never learns what a mixer is.
|
||||
//! Each race fly-by's `CinematicCamera` row names one streamed `<Race>Narration.mp3` kit (type
|
||||
//! 31); the two non-intro rows name id 0. A dropped kira handle keeps playing, so the handle is
|
||||
//! held here. The reference keeps the narration in `[0xb4e274]` (written at `0x48ef3e`) and
|
||||
//! releases it on shot advance (`0x48efef`), stop/ESC (`0x48f055`) and local abort (`0x490b8d`);
|
||||
//! this module follows [`Cinematic`]'s published shot the same way.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -31,30 +15,22 @@ use super::mixer::{self, StreamingSoundHandle};
|
||||
use super::{kit::SoundKits, SoundOutput};
|
||||
use crate::cinematic::Cinematic;
|
||||
|
||||
/// A cut-off narration is **cut**, with no fade at all: every release on this path
|
||||
/// (`0x48efef` shot-advance, `0x48f055` stop/ESC, `0x490b8d` local abort) is a plain release of
|
||||
/// `[0xb4e274]`, and there is **no audio fade anywhere in the cinematic** — the 0.25 s fade the
|
||||
/// reference schedules at both edges (`0x4c0d10`, `[0x804550]`) is a *screen* fade, which is what
|
||||
/// decision 1695 deferred. benilla's 250 ms declick was a guess at a fade the reference does not
|
||||
/// have.
|
||||
/// A stopped narration is cut with no fade: every release of `[0xb4e274]` is plain. The 0.25 s
|
||||
/// fade the reference schedules at both edges (`0x4c0d10`, `[0x804550]`) is a screen fade.
|
||||
const CUT_FADE_MS: u64 = 0;
|
||||
|
||||
/// The narration channel: which shot it belongs to, and its live handle.
|
||||
#[derive(Default)]
|
||||
pub(super) struct CinematicVoice {
|
||||
/// `(run, shot index)` of the shot whose narration is playing — the identity that decides
|
||||
/// whether the current shot's audio is already running. Keyed on the **run** rather than the
|
||||
/// sequence id: re-triggering the sequence already on screen restarts the picture at `t = 0`,
|
||||
/// and a sequence-keyed identity would have matched and left the voice running from wherever
|
||||
/// it had reached.
|
||||
/// `(run, shot index)` of the playing narration. Keyed on the run, not the sequence id:
|
||||
/// re-triggering the sequence on screen restarts the picture and must restart the voice.
|
||||
shot: Option<(u64, usize)>,
|
||||
handle: Option<StreamingSoundHandle<kira::sound::FromFileError>>,
|
||||
}
|
||||
|
||||
impl CinematicVoice {
|
||||
/// Is this owner holding a voice the device is actually mixing? The budget's question — see
|
||||
/// [`drive_narration`]. `Stopped` covers a stream that reached its end on its own, which is
|
||||
/// the ordinary case for a narration shorter than its shot.
|
||||
/// Whether the device is still mixing this voice; a narration shorter than its shot ends
|
||||
/// `Stopped` on its own.
|
||||
fn is_live(&self) -> bool {
|
||||
self.handle
|
||||
.as_ref()
|
||||
@@ -70,12 +46,9 @@ impl CinematicVoice {
|
||||
}
|
||||
|
||||
pub(super) fn plugin(app: &mut App) {
|
||||
// In `Present`, after the cinematic driver in `Input` has settled this frame's shot: the
|
||||
// narration follows the picture rather than racing it.
|
||||
// Gated on the kit catalog: `SoundKits` is inserted only once the install's kits have
|
||||
// loaded, and a run with no install (an instrument leg pointed at the wrong data path, 2211)
|
||||
// reached this system's bare `ResMut<SoundKits>` and panicked the app instead of playing
|
||||
// nothing. The narration needs a catalog to play from; without one there is nothing to do.
|
||||
// In `Present`, after the cinematic driver in `Input` has settled this frame's shot. Gated on
|
||||
// `SoundKits`, which exists only once the install's kits load: without it the bare `ResMut`
|
||||
// panics.
|
||||
app.add_systems(
|
||||
Update,
|
||||
drive_narration
|
||||
@@ -84,17 +57,8 @@ pub(super) fn plugin(app: &mut App) {
|
||||
);
|
||||
}
|
||||
|
||||
/// The narration channel, plus its line in the voice budget.
|
||||
///
|
||||
/// The **budget** half is why this wrapper exists. `SoundOutput::live_voices()` — the number
|
||||
/// [`super::kit::SOFTWARE_CHANNELS`] bounds, and which gates real play decisions, not just a
|
||||
/// readout — summed the kit channels, the zone streams and the glue streams. The narration is a
|
||||
/// *third* long-lived stream owner and it was in none of them, so for the whole length of a
|
||||
/// cinematic the client believed it had one more free voice than it did. `SoundOutput`'s own doc
|
||||
/// already states the law this now follows: "each owner **rewrites its own** every frame from its
|
||||
/// own live handles", because a shared counter with two writers drifts the first time a fade is
|
||||
/// interrupted. So the count is written here, once, from this owner's handle — after the body
|
||||
/// below has settled it, never from a stale read.
|
||||
/// The narration channel, plus its line in the voice budget: each stream owner rewrites its own
|
||||
/// count in `SoundOutput` every frame from its live handle, after the body has settled it.
|
||||
fn drive_narration(
|
||||
cine: Option<Res<Cinematic>>,
|
||||
voice: Local<CinematicVoice>,
|
||||
@@ -116,14 +80,13 @@ fn narrate(
|
||||
) {
|
||||
let playing = cine.as_deref().and_then(Cinematic::playing_shot);
|
||||
let Some((run, index, sound_id)) = playing else {
|
||||
// No cinematic (or it just ended/was skipped) — cut whatever was narrating.
|
||||
// No cinematic, or it just ended or was skipped: cut whatever was narrating.
|
||||
voice.stop();
|
||||
return;
|
||||
};
|
||||
if voice.shot == Some((run, index)) {
|
||||
return;
|
||||
}
|
||||
// A new shot: the previous one's narration, if any, does not carry over.
|
||||
voice.stop();
|
||||
voice.shot = Some((run, index));
|
||||
if sound_id == 0 {
|
||||
@@ -148,29 +111,11 @@ fn narrate(
|
||||
return;
|
||||
}
|
||||
};
|
||||
// **No category slider applies to this channel at all**. The narration is opened at
|
||||
// `0x48ef29` → `0x458a40` → `0x45ce60` with behaviour
|
||||
// flags `0x15`: `or eax,4` (`0x45ce9e`) and `or eax,0x10` (`0x45cea8`), while the music bit
|
||||
// `or eax,2` at `0x45ceb2` is **skipped**. Bit `0x10` is read at `0x7a5dc0`
|
||||
// (`test al,0x10; jne 0x7a5e1a`) and takes the channel around the category multiply entirely —
|
||||
// so Sound, Music and Ambience sliders all leave it alone, and its gain is the kit volume flat
|
||||
// (`__ftol(0.69 · 1.0 · 255)` = 175/255 on the shipped narrations).
|
||||
//
|
||||
// It is **not** exempt from the master, though: only bit `0x2` bypasses the
|
||||
// `MasterSoundEffects` gate at `0x7a529c`, and this channel does not set it — so unchecking
|
||||
// "Enable Sound Effects" silences the narration outright. [`SoundConfig::enabled`] is that
|
||||
// checkbox here.
|
||||
//
|
||||
// The old reading — the Sfx category, "the same one every NPC voice line takes" — was a
|
||||
// reasonable guess at where a player's expectation points, and it was wrong: this channel has
|
||||
// no category.
|
||||
// **The kit volume only** — the `MasterSoundEffects` gate is carried by the master track, not
|
||||
// baked in here. `apply_master_volume` drives master to 0 whenever `enabled` is off (or the
|
||||
// dev mute is on) and back up when it returns, so folding `config.enabled` into a one-shot
|
||||
// starting amp was redundant in the silencing direction and *wrong* in the other: a player who
|
||||
// started a cinematic with sound off and turned it back on mid-narration got everything else
|
||||
// back and a narration that stayed silent for the rest of the shot. The reference re-reads its
|
||||
// gate (`0x7a529c`) every tick; the master track is where benilla re-reads it.
|
||||
// No category slider applies: the narration opens (`0x48ef29` → `0x458a40` → `0x45ce60`)
|
||||
// with flags `0x15`, and bit `0x10` skips the category multiply at `0x7a5dc0`, so its gain is
|
||||
// the kit volume flat. Only bit `0x2` bypasses the `MasterSoundEffects` gate (`0x7a529c`),
|
||||
// so "Enable Sound Effects" still silences it; that gate rides the master track, which
|
||||
// re-reads `SoundConfig::enabled` every frame, never this starting amp.
|
||||
let amp = kit_vol;
|
||||
match mixer_ref.play_stream(data.volume(mixer::amp_to_db(amp))) {
|
||||
Ok(h) => {
|
||||
|
||||
@@ -1,129 +1,20 @@
|
||||
//! Combat audio (decisions 0075 + 0525): the melee swing's sounds — the whoosh and exertion on
|
||||
//! their own event tags, the CONTACT family on the victim dispatch ([`SwingImpact`]).
|
||||
//! Melee combat audio. `SMSG_ATTACKERSTATEUPDATE` arrives as a [`SwingMessage`], then the attack
|
||||
//! clip's M2 events fire mid-swing through [`AnimSoundEvent`] and [`SwingImpact`].
|
||||
//!
|
||||
//! The trigger chain: `SMSG_ATTACKERSTATEUPDATE` → [`SwingMessage`] (net bridge; also drives the
|
||||
//! swing *animation*, decision 0073) → the attack sequence's M2 events fire mid-swing through
|
||||
//! [`AnimSoundEvent`]. Two tags route directly from that stream:
|
||||
//! - Exertion, the attacker's vocal: packet-driven at swing start (`0x6246a0` → `0x624786`),
|
||||
//! only when victimState is nonzero (`0x62476a`), crit bit as class, rolled unless a crit.
|
||||
//! - `$CSS`, the whoosh: victimState alone picks it ([`whiffed`], `0x624ca0`), the miss whoosh by
|
||||
//! handedness or `WeaponSwingSounds2` by weapon weight ([`swing_weight`]).
|
||||
//! - Contact, at the `$AH0-3`/`$CAH` crossing ([`SwingImpact`]): first the attacker's
|
||||
//! weapon-sound block (`0x6247d0`: the natural-weapon `CustomAttack[n]` column, else the
|
||||
//! landed impact for the victim's slot), then the victim dispatch (`0x624530`: the parry or
|
||||
//! block clang, the deflect and absorb stubs, the injury vocal). `$CAH` drives the victim's
|
||||
//! injury (`0x624865`), never the attacker's exertion.
|
||||
//!
|
||||
//! - **`$CSS`** — the swing whoosh, in **both** of its forms. Nothing contacted (miss/dodge/
|
||||
//! evade) gets kits 7080/7081 `Combat Miss 1H/2H` by weapon handedness — exactly the two ids
|
||||
//! the client caches by name at startup (`0x4575b0`, `_DONOTRENAME_` kits). Anything
|
||||
//! else gets the *connecting* swing's `WeaponSwingSounds2` whoosh by weapon weight; see "The
|
||||
//! connecting swing" below.
|
||||
//! - **`$CAH`** — **not** the attacker's exertion, which is what this module used to think.
|
||||
//! `$CAH` drives the **victim's** injury vocal (`0x624865 je 0x624902` → `0x6249bb call
|
||||
//! 0x624530`), which benilla already fires off that crossing; there is no `call [reg+0x88]`
|
||||
//! anywhere in `0x6247d0`, so the tag never reaches the exertion columns at all.
|
||||
//!
|
||||
//! The **attacker's exertion** is packet-driven at swing start, and is now wired that way
|
||||
//! (`SMSG_ATTACKERSTATEUPDATE` → `0x6246a0` → `0x624786` → `0x623b10`). Read off the bytes:
|
||||
//!
|
||||
//! - `class = ([hitrec+0x10] >> 7) & 1` — the crit bit, so `Exertion` / `ExertionCritical`.
|
||||
//! - `0x62476a`: gated on **`victimState != 0`**, and only on that. (The earlier note here also
|
||||
//! claimed a victim-health gate; `0x6246a0` contains no such test. Its other two bails —
|
||||
//! `[[attacker+0x110]+0x40] <= 0` at `0x6246b1` and `hitInfo & 0x10000` at `0x6246c2` — sit
|
||||
//! above the swing *animation* select as well, so they belong to that subsystem, not here.)
|
||||
//! - `force = 0` (`0x62477e`), so the **class chance roll applies**: threshold 70 for a creature
|
||||
//! and 35 for a player on class 0, 100 on class 1. A crit always grunts; an ordinary swing
|
||||
//! grunts ~70 % of the time for a creature and ~36 % for a player.
|
||||
//!
|
||||
//! This moves *when* a grunt is heard — swing start rather than mid-clip — and thins ordinary
|
||||
//! swings out, which the tag-driven version never did.
|
||||
//!
|
||||
//! The **contact family** consumes [`SwingImpact`] — the victim dispatch `0x624530` + the
|
||||
//! `0x6247d0` weapon-sound block, fired at the swing clip's **`$AH0–3`/`$CAH`** crossing, or at
|
||||
//! receive for an unresolved attacker (decision 0529; previously keyed on `$HIT`, a CEffect-only
|
||||
//! tag many creature attack clips never author — ogre.m2 authors it in 1 of its ~14 attack
|
||||
//! variations, so ogre hits were near-silent):
|
||||
//!
|
||||
//! It is **two blocks in the client's order**, not one pick (decision 0899):
|
||||
//!
|
||||
//! 1. `0x6247d0`'s own weapon-sound block, at the attacker — a **natural-weapon** swing
|
||||
//! (`$AHn` fired the dispatch) plays the attacker's `CreatureSoundData.CustomAttack[n]`
|
||||
//! column INSTEAD of the generic weapon impact (the `SWINGNOHITSOUND` latch, `0x6247d0`);
|
||||
//! otherwise a landed hit plays the `WeaponImpactSounds` impact/crit slot for the victim's
|
||||
//! material (`CreatureImpactType`).
|
||||
//! 2. `0x624530`'s **victimState-keyed clang**, at the victim — parry (`0x6245bb` →
|
||||
//! `0x623640(sel=0)`) takes the attacker row's parry slot (metal/wood by the victim's
|
||||
//! weapon), block (`0x6245d8` → `sel=1`) the shield slot. This block is reached on **every**
|
||||
//! impact tag: the `$AHn` digit block "has zero effect on the victim dispatch", so a beast's
|
||||
//! bite and the parry clang both sound.
|
||||
//!
|
||||
//! Plus the victim's injury vocal (`Injury`/`InjuryCritical`/`InjuryCrushing`) on a damaging,
|
||||
//! undefended hit — the ordinary one **rolled** at the victim's own class-2 threshold, 60 for a
|
||||
//! creature and 30 for a player, while a crit and a crushing blow always sound (decision 2073).
|
||||
//!
|
||||
//! A `text_only` flush (supersede/attack-stop) drops its sounds — only the floating number
|
||||
//! flushes (decision 0149's flush law, inherited from the shared dispatch).
|
||||
//!
|
||||
//! ## The connecting swing — the other half of `$CSS` (decision 1567)
|
||||
//!
|
||||
//! `$CSS` is **two** sounds, not one, and `0x624ca0` picks between them off the victimState
|
||||
//! alone: `{0 unaffected, 2 dodge, 6 evade}` take the by-handedness miss whoosh, and **every
|
||||
//! other outcome — a landed hit, a parry, a block, an immune, a deflect — takes
|
||||
//! `WeaponSwingSounds2` on the capped bus 6** ([`whiffed`]). benilla voiced only the first half
|
||||
//! until 1567, so every landed melee swing in the game was missing a sound.
|
||||
//!
|
||||
//! What blocked it was `0x623870`, the `swingType` source, read now: it is **not a heuristic**.
|
||||
//! The function asks the swinging hand (`hitInfo & LEFTSWING` picks which) for its item, requires
|
||||
//! item class 2, and returns `ItemSubClass[(2, subclass)]` field 9 — `WeaponSwingSize`, a shipped
|
||||
//! DBC column ([`swing_weight`]). The shipped weapon rows put daggers and fist weapons at Light,
|
||||
//! every two-hander plus polearms, staves and spears at Heavy, and the rest at Medium; an **empty
|
||||
//! hand returns Light** (`0x623892` writes 0 and returns *true*) and a **non-weapon in hand
|
||||
//! returns false**, i.e. silence. The kit is then `cache[critical + weight*2]` over the six
|
||||
//! `WeaponSwingSounds2` rows — 233..238, `mWooshSmall/Medium/Large` — at volume 0.5 when the hit
|
||||
//! flags carry `HITINFO_MISS` and 1.0 otherwise (`0x457f74`/`0x457f7d`).
|
||||
//!
|
||||
//! One benilla behaviour changed beyond the new sound: immune and deflect used to take the *miss*
|
||||
//! whoosh here, because this branch borrowed [`no_contact`] — the impact family's wider question.
|
||||
//! `0x624ca0` reads neither the hit flags nor those two states, so they now whoosh like the
|
||||
//! contacts they are.
|
||||
//!
|
||||
//! ## The victim dispatch's tail, read out (1567)
|
||||
//!
|
||||
//! `0x624530` past the clang is a four-way ladder, and two of its arms were silent here:
|
||||
//!
|
||||
//! - **deflect** (victimState 8) plays a fixed `(DONOTRENAME)ShieldWoodImpact`, id 3262
|
||||
//! ([`DEFLECT_KIT`]) — not a weapon-row slot.
|
||||
//! - **absorb, resist or immune** (`hitInfo & 0x60`, or victimState 7) plays
|
||||
//! `(DONOTRENAME)AbsorbGetHit`, id 3334 ([`ABSORB_KIT`]), **instead of** the wound vocal —
|
||||
//! it returns before reaching it.
|
||||
//! - otherwise the wound vocal, keyed crushing → critical → (`MISS` → nothing) → injury.
|
||||
//!
|
||||
//! Both stub kits come out of the same startup name cache as the miss whooshes (`0x4575b0`), and
|
||||
//! both emit at the victim two yards up. One reading is *not* taken literally: see the wound
|
||||
//! vocal's own note on `HITINFO_AFFECTS_VICTIM` and what vmangos does with it on a parry.
|
||||
//!
|
||||
//! ## The material law, and where the ten slots actually come from (1567)
|
||||
//!
|
||||
//! Three of this module's INTERIM readings were one table: **`Material.dbc`'s `Flags` column**.
|
||||
//!
|
||||
//! - **metal vs wood** is `Flags & 1` (`0x457e80` → `0x5d9a50`), not `material != WOOD`. Leather,
|
||||
//! cloth and liquid are non-metal too — and so is the id 0 a creature with no virtual-item
|
||||
//! info carries, which the old test called metal.
|
||||
//! - **the victim's slot** is `[vt+0x90]`, two implementations naming disjoint halves of the ten:
|
||||
//! a creature's `CreatureSoundData` column remapped `{0, 8, 7, 9}` (flesh/stone/wood/ethereal),
|
||||
//! or a **player's chest armor** (`0x62fb70`: plate → plate, chain → chain, else flesh). The
|
||||
//! chain and plate columns are reachable only through a player victim, and only the local one —
|
||||
//! the same self-only reach as the foley. benilla sent every player to flesh, which is why a
|
||||
//! plate warrior's own hits sounded like a punch on meat.
|
||||
//! - **the clang's slot family follows the defending item's class**, not the victimState
|
||||
//! (`0x457de6`/`0x457dfd`): class 2 the parry pair, class 4 the shield pair, material picking
|
||||
//! metal or wood inside it. The item is `0x625400(sel)` — a parry prefers a mainhand weapon and
|
||||
//! falls through to the offhand, a block goes straight to the offhand — and **no defending item
|
||||
//! means no clang**, so an unarmed parry is silent. benilla read the mainhand for both and
|
||||
//! hardcoded the metal shield.
|
||||
//!
|
||||
//! One more byte detail: the clang alone is emitted two yards up (`0x457e29`); the generic weapon
|
||||
//! impact beside it passes its position straight through.
|
||||
//!
|
||||
//! INTERIM readings: a defended outcome suppresses block 1's generic weapon impact
|
||||
//! (the tail's latch test `0x624936` carries no victimState gate in the trace, so whether it
|
||||
//! also plays under a clang is unpinned);
|
||||
//! the natural-weapon column is gated on
|
||||
//! contact like the weapon impact (whether the digit block also plays on a whiff is unpinned).
|
||||
//! `$CPP`/`$CST` are pinned NON-audio (decision 0279): `$CPP` is the victim defense-anim
|
||||
//! dispatch, `$CST` re-pings the attached combat-kit list — neither belongs to this module.
|
||||
//! Metal vs wood is `Material.dbc`'s `Flags & 1` throughout (`0x457e80`). Untraced: whether the
|
||||
//! generic impact also plays under a clang (`0x624936`) and whether the natural-weapon column
|
||||
//! plays on a whiff; benilla plays neither. A `text_only` flush plays nothing. `$CPP` and `$CST`
|
||||
//! are not audio.
|
||||
|
||||
use bevy::ecs::entity::EntityHashMap;
|
||||
use bevy::prelude::*;
|
||||
@@ -156,54 +47,38 @@ const VICTIM_EVADE: u32 = 6;
|
||||
const VICTIM_IMMUNE: u32 = 7;
|
||||
const VICTIM_DEFLECT: u32 = 8;
|
||||
|
||||
/// `HitInfo` bit 2 — the **offhand** swing (vmangos `HITINFO_LEFTSWING`), and the reference's own
|
||||
/// hand selector: `0x624c36` derives `slot = (hitInfo >> 2) & 1` and hands it to `0x623870`, which
|
||||
/// asks that hand for its item.
|
||||
/// `HITINFO_LEFTSWING`, the offhand swing; the reference picks the hand by it
|
||||
/// (`0x624c36`: `(hitInfo >> 2) & 1`).
|
||||
const HITINFO_LEFTSWING: u32 = 0x4;
|
||||
|
||||
/// The two `_DONOTRENAME_` whoosh kits the client caches by name at startup (`0x4575b0`);
|
||||
/// byte-verified ids in the 5875 SoundEntries dump.
|
||||
/// The `(DONOTRENAME)Combat Miss 1H/2H` whooshes, cached by name at startup (`0x4575b0`).
|
||||
const COMBAT_MISS_1H: u32 = 7080;
|
||||
const COMBAT_MISS_2H: u32 = 7081;
|
||||
|
||||
/// The two **fixed, name-cached stub kits** of the victim dispatch `0x624530`, resolved from the
|
||||
/// `(DONOTRENAME)` cache the client fills at startup (`0x4575b0`) — the same cache the miss
|
||||
/// whooshes come out of. Both play at the **victim**, two yards up, on the uncapped bus 0 at
|
||||
/// volume 1.0 (`0x458870`), and neither is weapon-row-keyed:
|
||||
///
|
||||
/// - **deflect** (`0x6245f5`, victimState 8 → `0x457f20` → `[0xb05fb8]`) →
|
||||
/// `(DONOTRENAME)ShieldWoodImpact`, id 3262, `WoodenShieldBlock1..3.wav`.
|
||||
/// - **absorb / resist / immune** (`0x62460f` `hitInfo & 0x60`, or `0x624613` victimState 7 →
|
||||
/// `0x458610` → `[0xb05fb4]`) → `(DONOTRENAME)AbsorbGetHit`, id 3334,
|
||||
/// `AbsorbGetHitA/B/C.wav`.
|
||||
///
|
||||
/// Both ids are byte-verified in the shipped `SoundEntries` dump against the exact cache strings
|
||||
/// at `0x835e8c`/`0x835eac`. These were the "unpinned stubs" the module used to leave silent.
|
||||
/// The victim dispatch's fixed stub kits (`0x624530`), from the same startup name cache
|
||||
/// (`0x835e8c`/`0x835eac`), played at the victim two yards up on bus 0 at volume 1.0
|
||||
/// (`0x458870`): deflect (victimState 8, `0x6245f5`) plays `(DONOTRENAME)ShieldWoodImpact`;
|
||||
/// absorb, resist or immune (`0x62460f`, `0x624613`) play `(DONOTRENAME)AbsorbGetHit`.
|
||||
const DEFLECT_KIT: u32 = 3262;
|
||||
const ABSORB_KIT: u32 = 3334;
|
||||
|
||||
/// `HitInfo & (HITINFO_ABSORB | HITINFO_RESIST)` — the reference's own `test al, 0x60`, which
|
||||
/// sends the hit to [`ABSORB_KIT`] instead of the wound vocal.
|
||||
/// `HITINFO_ABSORB | HITINFO_RESIST`, the reference's `test al, 0x60`: [`ABSORB_KIT`] instead of
|
||||
/// the wound vocal.
|
||||
const HITINFO_ABSORB_OR_RESIST: u32 = 0x60;
|
||||
|
||||
/// **The stub emitters' height offset** — `0x457f4a`/`0x45863a` `fadd [0x801628]`, the same flat
|
||||
/// `2.0` the armor foley uses (`crate::sound::footsteps`). WoW Z is Bevy Y at the same scale.
|
||||
/// The weapon impact and the parry/block clang do NOT take it: they pass the caller's position
|
||||
/// straight through, so only the three `0x458870` sites that build a local vector are lifted.
|
||||
/// The stub emitters' lift (`0x457f4a`/`0x45863a`: `fadd [0x801628]`), as the armor foley's; the
|
||||
/// generic weapon impact passes its position through unlifted.
|
||||
const STUB_HEIGHT: f32 = 2.0;
|
||||
|
||||
/// `ItemClass` 2 — a weapon. `0x623870` compares the equipped item's class byte against it
|
||||
/// (`0x6238b4 cmp byte ptr [eax], 2`) and **returns false** on anything else, so a held
|
||||
/// non-weapon swings in silence rather than falling back to a weight.
|
||||
/// `ItemClass` 2, a weapon (`0x6238b4`); any other held item swings in silence.
|
||||
const ITEM_CLASS_WEAPON: u32 = 2;
|
||||
|
||||
/// Weapon subclasses swung two-handed (item weapon subclass ids) — picks the 2H whoosh.
|
||||
/// Weapon subclasses swung two-handed, for the 2H miss whoosh.
|
||||
const TWO_HANDED: [u32; 6] = [1, 5, 6, 8, 10, 17];
|
||||
/// Fist/unarmed subclass — the row a weaponless swing uses (`Unarmed_Generic`).
|
||||
/// The fist subclass, the row a weaponless swing uses.
|
||||
const UNARMED_SUBCLASS: u32 = 13;
|
||||
|
||||
/// `WeaponSwingSounds2.dbc` as the reference's own six-slot cache — the connecting swing's kit
|
||||
/// by `(weight, critical)`. See [`benilla_formats::WeaponSwingCatalog`].
|
||||
/// `WeaponSwingSounds2.dbc`, the connecting swing's kit by `(weight, critical)`.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct WeaponSwings(pub(crate) benilla_formats::WeaponSwingCatalog);
|
||||
|
||||
@@ -237,30 +112,19 @@ fn load_weapon_impacts(mut commands: Commands, assets: Option<Res<WorldAssets>>)
|
||||
}
|
||||
}
|
||||
|
||||
/// The latest swing outcome per attacker — written on the packet, read as the `$CSS` event
|
||||
/// fires over the following frames, overwritten by the next swing. (The contact family does not
|
||||
/// read it: [`SwingImpact`] carries its own consumed record, decision 0529. Neither does the
|
||||
/// exertion vocal any more — it fires from the packet itself, so it never needs the record to
|
||||
/// survive into a later frame.)
|
||||
/// The latest swing per attacker, read when its `$CSS` event fires in a later frame.
|
||||
#[derive(Default)]
|
||||
struct LastSwing(EntityHashMap<SwingMessage>);
|
||||
|
||||
/// The attacker's swinging weapon: `(subclass, metal)`, unarmed when the hand is empty. The
|
||||
/// metal-vs-wood half is the item's own **`Material`** off the wire (decision 0882) — not a
|
||||
/// subclass guess, which the real 5875 data contradicts outright: maces (subclass 4) ship in both
|
||||
/// materials, so a Cudgel is wood where a Mace is metal.
|
||||
///
|
||||
/// **`metal` is `Material.Flags & 1`, not `material != WOOD`** (`0x457e80` → `0x5d9a50`). The
|
||||
/// distinction is not academic: leather, cloth and liquid are all non-metal, and so is the id 0
|
||||
/// a creature with no virtual-item info carries — which the old test called metal. An empty hand
|
||||
/// is non-metal too, matching the reference's null-item path (`0x457e8d` writes the out-param 0).
|
||||
/// The attacker's swinging weapon as `(subclass, metal)`, unarmed for an empty hand. `metal` is
|
||||
/// the item's `Material` with `Flags & 1` (`0x457e80` → `0x5d9a50`), so leather, cloth and the
|
||||
/// unknown id 0 are non-metal, as is an empty hand (`0x457e8d`).
|
||||
fn swing_weapon(
|
||||
wielded: Option<&Wielded>,
|
||||
offhand: bool,
|
||||
materials: Option<&benilla_formats::MaterialCatalog>,
|
||||
) -> (u32, bool) {
|
||||
// `0x625400`/`0x625460` pass `visFlag = 0`, so a disarmed hand's weapon is not here to be
|
||||
// heard either — the punch a disarmed unit throws must not clang like a sword (1863).
|
||||
// `0x625400`/`0x625460` pass `visFlag = 0`: a disarmed hand swings as unarmed.
|
||||
let hand = wielded.and_then(|w| {
|
||||
if offhand {
|
||||
w.armed_off()
|
||||
@@ -283,20 +147,10 @@ fn swing_weapon(
|
||||
}
|
||||
}
|
||||
|
||||
/// The victim's `WeaponImpactSounds` **target slot** — the `[vt+0x90]` virtual, whose two
|
||||
/// implementations name **disjoint halves of the same ten**:
|
||||
///
|
||||
/// - a **creature** (`0x6238f0`) reads its `CreatureSoundData` column, refuses anything `>= 4`,
|
||||
/// and remaps it through the four-entry table at `0x80db94` = `{0, 8, 7, 9}` — flesh, stone,
|
||||
/// wood, ethereal.
|
||||
/// - a **player** (`0x62fb70`) ignores that entirely and reads its **chest armor's `Material`**
|
||||
/// flags: plate → the plate slot, chain → chain, everything else (leather and cloth included)
|
||||
/// → flesh. Handled at the call site, which is where the chest lookup's data lives.
|
||||
///
|
||||
/// So the chain and plate columns of every weapon row are reachable *only* through a player
|
||||
/// victim, and only the local one — the same self-only reach as the foley
|
||||
/// ([`super::worn_chest_material`]). benilla used to send every player to flesh, which is why a
|
||||
/// plate warrior's own hits sounded like a punch on meat.
|
||||
/// A creature victim's `WeaponImpactSounds` slot (`[vt+0x90]`, `0x6238f0`): its
|
||||
/// `CreatureSoundData` column, `>= 4` refused, remapped by `{0, 8, 7, 9}` (`0x80db94`) to flesh,
|
||||
/// stone, wood, ethereal. A player victim instead presents its chest armor (`0x62fb70`, at the
|
||||
/// call site), the only route to the chain and plate slots.
|
||||
fn creature_impact_slot(impact_type: u32) -> usize {
|
||||
match impact_type {
|
||||
1 => impact_slot::STONE,
|
||||
@@ -306,40 +160,22 @@ fn creature_impact_slot(impact_type: u32) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// **Which whoosh a swing gets** — `0x624ca0`, the reference's own two-way split, and the only
|
||||
/// input is the victimState. `{0 unaffected, 2 dodge, 6 evade}` take the *miss* whoosh; **every
|
||||
/// other outcome — a landed hit, a parry, a block, an immune, a deflect — takes the connecting
|
||||
/// swing's `WeaponSwingSounds2` whoosh** on the capped bus 6. The two are alternatives, never
|
||||
/// both: the call site branches on this one test (`0x624ba4 je 0x624c36`).
|
||||
///
|
||||
/// Deliberately NOT [`no_contact`], which is a wider "nothing for the weapon to strike" question
|
||||
/// asked of the *impact* family. That predicate also folds in `HITINFO_MISS` and treats immune
|
||||
/// and deflect as whiffs; `0x624ca0` reads neither the hit flags nor those two states, and
|
||||
/// sorting a deflect onto the miss whoosh was the audible consequence of borrowing it here.
|
||||
/// The `$CSS` split (`0x624ca0`), on victimState alone: unaffected, dodge and evade take the
|
||||
/// miss whoosh, every other outcome the connecting swing, never both (`0x624ba4`). Not
|
||||
/// [`no_contact`]: this reads no hit flags, and immune and deflect connect.
|
||||
fn whiffed(victim_state: u32) -> bool {
|
||||
matches!(victim_state, 0 | VICTIM_DODGE | VICTIM_EVADE)
|
||||
}
|
||||
|
||||
/// The swinging weapon's **weight** — `0x623870`, verbatim, and the whole of the Light/Medium/
|
||||
/// Heavy classification benilla could not build before.
|
||||
///
|
||||
/// It is not a heuristic and never was: the function asks the swinging hand for its item, checks
|
||||
/// the class byte is 2, and returns `ItemSubClass[(2, subclass)].WeaponSwingSize` — a shipped DBC
|
||||
/// column (`[row+0x24]`, field 9) that puts daggers and fist weapons at Light, every two-hander
|
||||
/// plus polearms, staves and spears at Heavy, and the rest at Medium.
|
||||
///
|
||||
/// Three outcomes, each the reference's own:
|
||||
/// - **empty hand** → `Some(0)`, Light. `0x623892` writes 0 into the out-param and returns
|
||||
/// *true*, so an unarmed swing whooshes with the small `mWooshSmall*` samples.
|
||||
/// - **a weapon** → its row's weight, or `None` when the pair has no row.
|
||||
/// - **a non-weapon in hand** → `None`. `0x6238b7` returns false and the caller plays nothing.
|
||||
/// The swinging weapon's weight (`0x623870`): `ItemSubClass.dbc`'s `WeaponSwingSize` (field 9)
|
||||
/// for a weapon, Light for an empty hand (`0x623892`), `None` for a held non-weapon
|
||||
/// (`0x6238b7`), which plays nothing.
|
||||
fn swing_weight(
|
||||
wielded: Option<&Wielded>,
|
||||
offhand: bool,
|
||||
sub_classes: &benilla_formats::ItemSubClassCatalog,
|
||||
) -> Option<u32> {
|
||||
// `0x623870`, `visFlag = 0` again: the disarmed hand whooshes with the small unarmed
|
||||
// samples, by this function's own empty-hand leg (1863).
|
||||
// `visFlag = 0`: a disarmed hand takes the empty-hand leg.
|
||||
match wielded.and_then(|w| {
|
||||
if offhand {
|
||||
w.armed_off()
|
||||
@@ -355,9 +191,8 @@ fn swing_weight(
|
||||
}
|
||||
}
|
||||
|
||||
/// The whiff/no-weapon-contact family: nothing for the weapon to strike. Immune and deflect
|
||||
/// route to positioned stubs in the client (`0x457f20`/`0x458610`, decision 0279) whose kit ids
|
||||
/// are unpinned — grouped here (whoosh, no impact) as the INTERIM stand-in.
|
||||
/// Nothing for the weapon to strike, so no weapon impact or clang; immune and deflect play their
|
||||
/// stub kits instead (`0x457f20`/`0x458610`).
|
||||
fn no_contact(swing: &SwingMessage) -> bool {
|
||||
swing.hit_info & HITINFO_MISS != 0
|
||||
|| matches!(
|
||||
@@ -366,22 +201,17 @@ fn no_contact(swing: &SwingMessage) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// A defended outcome — parry or block. These take their sound from the victim dispatch's
|
||||
/// victimState-keyed clang ([`defense_clang`]), never from the attacker's weapon-sound block.
|
||||
/// Parry or block: the sound is the victim's clang ([`defense_clang`]), not the weapon impact.
|
||||
fn defended(victim_state: u32) -> bool {
|
||||
matches!(victim_state, VICTIM_PARRY | VICTIM_BLOCK)
|
||||
}
|
||||
|
||||
/// The victim's **defending item** — `0x625400(sel)`, `(class, material)`.
|
||||
///
|
||||
/// A **parry** prefers the mainhand when it holds a weapon and otherwise falls through to the
|
||||
/// offhand; a **block** goes straight to the offhand. Either way the item must be a weapon
|
||||
/// (class 2) or armor (class 4) — and `None` here means the reference plays **no clang at all**
|
||||
/// (`0x623690 test eax,eax; je`), which is what an unarmed parry sounds like.
|
||||
/// The victim's defending item as `(class, material)` (`0x625400(sel)`): a parry takes a
|
||||
/// mainhand weapon, else the offhand; a block takes the offhand; it must be class 2 or 4. `None`
|
||||
/// plays no clang (`0x623690`).
|
||||
fn defending_item(wielded: Option<&Wielded>, block: bool) -> Option<(u8, u8)> {
|
||||
let w = wielded?;
|
||||
// Both probes are `0x625400(sel)` with `visFlag = 0` (1863): a disarmed parry finds no
|
||||
// mainhand weapon and falls through, and an unarmed parry rings nothing at all.
|
||||
// `visFlag = 0`: a disarmed mainhand falls through to the offhand.
|
||||
if !block {
|
||||
if let Some((2, _)) = w.armed_main() {
|
||||
return Some((2, w.materials[0]));
|
||||
@@ -391,15 +221,9 @@ fn defending_item(wielded: Option<&Wielded>, block: bool) -> Option<(u8, u8)> {
|
||||
matches!(class, 2 | 4).then_some((class, w.materials[1]))
|
||||
}
|
||||
|
||||
/// `0x624530`'s clang: the attacker's weapon row × the victim's defending item (`0x623640` →
|
||||
/// `0x457dc0`). Crit does not tier it — the parry/shield columns carry the same kit in both
|
||||
/// tables.
|
||||
///
|
||||
/// **The slot family follows the item's own class, not the victimState** (`0x457de6`/`0x457dfd`):
|
||||
/// class 2 takes the parry pair, class 4 the shield pair, and the item's `Material` picks
|
||||
/// metal or wood within it. So a dual-wielder who *blocks* with a weapon rings the parry slot,
|
||||
/// and a wooden shield rings 3262 rather than 3263 — benilla used to read the victim's mainhand
|
||||
/// for both outcomes and hardcode the metal shield, which got the whole block case wrong.
|
||||
/// The clang (`0x623640` → `0x457dc0`): the attacker's weapon row, slot by the defending item's
|
||||
/// class, not the victimState (`0x457de6`/`0x457dfd`): class 2 the parry pair, class 4 the
|
||||
/// shield pair, its `Material` picking metal or wood. Not crit-tiered.
|
||||
fn defense_clang(
|
||||
row: &benilla_formats::WeaponImpactRow,
|
||||
item: Option<(u8, u8)>,
|
||||
@@ -416,9 +240,7 @@ fn defense_clang(
|
||||
row.impact[slot]
|
||||
}
|
||||
|
||||
/// `0x6247d0`'s generic weapon impact for a landed hit: the victim's slot
|
||||
/// ([`creature_impact_slot`] or the player's armor slot) off the attacker's weapon row,
|
||||
/// crit-tiered.
|
||||
/// The generic weapon impact for a landed hit (`0x6247d0`), crit-tiered.
|
||||
fn landed_impact(row: &benilla_formats::WeaponImpactRow, slot: usize, crit: bool) -> u32 {
|
||||
if crit {
|
||||
row.crit[slot]
|
||||
@@ -427,9 +249,7 @@ fn landed_impact(row: &benilla_formats::WeaponImpactRow, slot: usize, crit: bool
|
||||
}
|
||||
}
|
||||
|
||||
/// What a combat sound needs to know about a unit: where it is, what it wields, what it *is*,
|
||||
/// whether it is you — and its descriptor store, which rides along only for the victim's chest
|
||||
/// armor (`0x62fb70`; see [`creature_impact_slot`] on the two halves of `[vt+0x90]`).
|
||||
/// A unit as the combat sounds see it; the store is for a player victim's chest armor.
|
||||
type CombatUnit = (
|
||||
&'static Transform,
|
||||
Option<&'static Wielded>,
|
||||
@@ -438,21 +258,16 @@ type CombatUnit = (
|
||||
Option<&'static crate::net::ObjectStore>,
|
||||
);
|
||||
|
||||
/// The five DBC-backed tables the melee sounds read, bundled so the system stays under Bevy's
|
||||
/// parameter ceiling — and because they are one thing: the melee sound vocabulary. Each is
|
||||
/// independently optional, like every DBC-backed resource here; absent, its own branch goes
|
||||
/// quiet rather than the system failing.
|
||||
/// The attachment a **whiffed** swing's whoosh is born at — `0x624bdd call 0x712cb0(1)`.
|
||||
/// The attachment a whiffed swing's whoosh plays at (`0x624bdd`: `0x712cb0(1)`).
|
||||
const MISS_ATTACH: u16 = 1;
|
||||
|
||||
/// The DBC tables the melee sounds read, each optional: a missing one silences its own branch.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
struct MeleeTables<'w> {
|
||||
impacts: Option<Res<'w, WeaponImpacts>>,
|
||||
swing_sounds: Option<Res<'w, WeaponSwings>>,
|
||||
materials: Option<Res<'w, super::Materials>>,
|
||||
/// One load, two consumers: the tooltip's slot|type line owns this resource
|
||||
/// ([`crate::ui_items::ItemSubClasses`]) and the swing whoosh reads the same rows' weight
|
||||
/// column. A second loader over one DBC is how a schema quietly drifts.
|
||||
/// Owned by the tooltip; the whoosh reads the same rows' weight column.
|
||||
sub_classes: Option<Res<'w, crate::ui_items::ItemSubClasses>>,
|
||||
voices: Option<Res<'w, CreatureVoices>>,
|
||||
}
|
||||
@@ -474,22 +289,16 @@ fn combat_sounds(
|
||||
config: Res<SoundConfig>,
|
||||
listener: Res<AudioListener>,
|
||||
) {
|
||||
// The attacker's exertion vocal is **packet-driven**, not tag-driven (see the module note):
|
||||
// `SMSG_ATTACKERSTATEUPDATE` -> `0x6246a0` -> `0x624786`. Collected here with the swing record
|
||||
// so the vocal fires at swing start, which is where the reference puts it.
|
||||
// Exertion fires from the packet, at swing start (`0x6246a0` → `0x624786`).
|
||||
let mut exertions: Vec<(Entity, bool)> = Vec::new();
|
||||
for s in swings.read() {
|
||||
last.0.insert(s.attacker, *s);
|
||||
// `0x62476a`: the vocal leg is gated on victimState, and ONLY on victimState — read off
|
||||
// the bytes rather than the second-hand note, which also claimed a victim-health gate
|
||||
// that is not in the function. A swing that contacted nothing at all is silent; every
|
||||
// other outcome, hit or parried or blocked, grunts.
|
||||
// `0x62476a`: gated on victimState alone, with no victim-health test.
|
||||
if s.victim_state != 0 {
|
||||
exertions.push((s.attacker, s.hit_info & HITINFO_CRITICAL != 0));
|
||||
}
|
||||
}
|
||||
// Bound the map: entries for despawned attackers die with the entity check below; a cheap
|
||||
// periodic sweep keeps a long session from accumulating dead keys.
|
||||
// Sweep despawned attackers so a long session does not accumulate dead keys.
|
||||
if last.0.len() > 128 {
|
||||
last.0.retain(|e, _| units.contains(*e));
|
||||
}
|
||||
@@ -509,15 +318,10 @@ fn combat_sounds(
|
||||
return;
|
||||
};
|
||||
let listener = listener.pos;
|
||||
// `Material.dbc` decides the metal/wood half of every weapon row and the armor slot a player
|
||||
// victim presents. Absent (a DBC that failed to load), every weapon reads non-metal and every
|
||||
// victim reads flesh — the reference's own answers for an unknown material, so the fallback
|
||||
// is its behaviour rather than a guess of ours.
|
||||
// Without `Material.dbc` every weapon is non-metal and every victim flesh, the reference's
|
||||
// answers for an unknown material.
|
||||
let mats = materials.as_deref().map(|m| &m.0);
|
||||
// Every combat play carries its **voice bus** (decision 1555): the melee-contact family all
|
||||
// contends for bus 10's four voices, the vocals for their own one or two, and the miss whoosh
|
||||
// for nothing at all. A kit refused at the cap is not an error — it is the gate doing its job,
|
||||
// and `play_kit_ext` reports it as an ordinary silent success.
|
||||
// Every play carries its voice bus; a kit refused at the bus cap is a silent success.
|
||||
let play = |kits: &mut SoundKits,
|
||||
out: &mut SoundOutput,
|
||||
kit: u32,
|
||||
@@ -542,15 +346,13 @@ fn combat_sounds(
|
||||
}
|
||||
};
|
||||
|
||||
// The attacker's exertion vocal, at swing start. `force = 0` at `0x62477e`, so the class
|
||||
// chance roll applies: class 0 is 70 for a creature and 35 for a player, class 1
|
||||
// (ExertionCritical) is 100 in both twins — a crit always grunts, an ordinary swing thins out,
|
||||
// and a player grunts about half as often as a creature.
|
||||
// `force = 0` (`0x62477e`), so the class roll applies: class 0 is 70 for a creature and 35
|
||||
// for a player, class 1 (a crit) 100.
|
||||
for (attacker, crit) in exertions {
|
||||
let Ok((tr, _, net, _, _)) = units.get(attacker) else {
|
||||
continue;
|
||||
};
|
||||
// Same `AISOUNDDESC` gate, on the attacker this time — exertion is classes 0/1.
|
||||
// The `AISOUNDDESC` gate on the attacker: exertion is classes 0/1.
|
||||
if net.kind != EntityKind::Player && object_sound_playing(&out, attacker) {
|
||||
continue;
|
||||
}
|
||||
@@ -582,10 +384,7 @@ fn combat_sounds(
|
||||
);
|
||||
}
|
||||
|
||||
// The one tag this module still consumes: the swing whoosh — **both** of them. `0x624ca0`
|
||||
// splits the tag two ways by victimState ([`whiffed`]) and the branches are exclusive: a
|
||||
// swing that touched nothing gets the by-handedness miss whoosh on the uncapped bus 0, and
|
||||
// every other swing gets `WeaponSwingSounds2` by weapon weight on bus 6's cap of 2.
|
||||
// `$CSS`: the miss whoosh on bus 0, or `WeaponSwingSounds2` on bus 6 (cap 2).
|
||||
for ev in events.read() {
|
||||
if ev.ident != *b"$CSS" {
|
||||
continue;
|
||||
@@ -604,10 +403,7 @@ fn combat_sounds(
|
||||
} else {
|
||||
COMBAT_MISS_1H
|
||||
};
|
||||
// **ATTACHMENT 1, not the fired key** — the one place in the whole event-position
|
||||
// table where a *whiff* and a *hit* of the same tag disagree (`0x624baa`:
|
||||
// `0x624bdd call 0x712cb0(1)`, else `GetPosition`). The whoosh comes from the hand,
|
||||
// the impact from where the weapon met something.
|
||||
// Attachment 1, not the event's point: a whiff plays from the hand (`0x624baa`).
|
||||
let at = attach.point(ev.entity, MISS_ATTACH, attacker_tr.translation);
|
||||
play(
|
||||
&mut kits,
|
||||
@@ -622,8 +418,7 @@ fn combat_sounds(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// The connecting swing (`0x624c36`). Both catalogs are optional like every DBC-backed
|
||||
// resource: without them this swing is silent, which is the pre-1567 behaviour.
|
||||
// The connecting swing (`0x624c36`).
|
||||
let (Some(swings), Some(subs)) = (swing_sounds.as_deref(), sub_classes.as_deref()) else {
|
||||
continue;
|
||||
};
|
||||
@@ -631,22 +426,18 @@ fn combat_sounds(
|
||||
continue; // a non-weapon in the hand: `0x623870` returns false and nothing plays
|
||||
};
|
||||
let Some(kit) = swings.0.kit(weight, swing.hit_info & HITINFO_CRITICAL != 0) else {
|
||||
continue; // `0x457f63`'s `swingType >= 3` bail — silence, never a fallback weight
|
||||
continue; // `0x457f63`'s `swingType >= 3` bail: silence, never a fallback weight
|
||||
};
|
||||
play(
|
||||
&mut kits,
|
||||
&mut out,
|
||||
kit,
|
||||
// **EVENT POINT** — `0x624c6e mov ecx,[ebx+0x10]` straight into `0x457f60`, which
|
||||
// pushes it through to `0x458890`. 353 of the 363 shipped `$CSS` records sit off
|
||||
// their model's origin, on a moving bone; Thunderaan's is 26.4 yd out.
|
||||
// The event's own point (`0x624c6e` → `0x457f60`), usually on a moving bone.
|
||||
ev.pos.unwrap_or(attacker_tr.translation),
|
||||
PlayExtras {
|
||||
bus: Bus::WEAPON_SWING,
|
||||
// `0x457f74`/`0x457f7d`: half volume when the hit flags carry `HITINFO_MISS`,
|
||||
// full otherwise. Structurally that arm needs a MISS *with* a connecting
|
||||
// victimState, which vmangos does not produce — carried anyway because it is
|
||||
// what the bytes say, and it costs one field.
|
||||
// Half volume under `HITINFO_MISS` (`0x457f74`/`0x457f7d`); vmangos never
|
||||
// sends a miss with a connecting victimState.
|
||||
volume_mult: Volume(if swing.hit_info & HITINFO_MISS != 0 {
|
||||
0.5
|
||||
} else {
|
||||
@@ -658,7 +449,7 @@ fn combat_sounds(
|
||||
);
|
||||
}
|
||||
|
||||
// The contact family: the weapon-sound block + victim dispatch, at the impact crossing.
|
||||
// The contact family: the weapon-sound block, then the victim dispatch.
|
||||
for imp in contacts.read() {
|
||||
if imp.text_only {
|
||||
continue; // a supersede/stop flush carries only the floating text
|
||||
@@ -666,12 +457,8 @@ fn combat_sounds(
|
||||
let swing = &imp.swing;
|
||||
let attacker = units.get(swing.attacker).ok();
|
||||
let victim = swing.victim.and_then(|v| units.get(v).ok());
|
||||
// **The fired tag's own point** (`edi = [ebx+0x10]`, pushed at `0x6248ef` for the
|
||||
// CustomAttack column and `0x624950` for the generic weapon impact). A big creature's
|
||||
// `$AH1` sits 4.7 yd out on the jaw
|
||||
// (`trex.m2`) and Thunderaan's `$CAH` 26.4 yd out; the attacker's origin was standing in
|
||||
// for both. The receive-time fallback carries no point — no tag fired — so it keeps the
|
||||
// attacker, then the victim, which is the only anchor that packet leaves us.
|
||||
// The fired tag's own point (`0x6248ef`, `0x624950`); the receive-time fallback fired no
|
||||
// tag, so it takes the attacker, then the victim.
|
||||
let Some(pos) = imp
|
||||
.pos
|
||||
.or_else(|| attacker.map(|(t, ..)| t.translation))
|
||||
@@ -681,21 +468,17 @@ fn combat_sounds(
|
||||
};
|
||||
let crit = swing.hit_info & HITINFO_CRITICAL != 0;
|
||||
if !no_contact(swing) {
|
||||
// The attacker's weapon row (`0x625460(attacker, leftswing)`) — shared by both
|
||||
// blocks below. `None` for a wand/thrown: no melee row, nothing to strike with.
|
||||
// The attacker's weapon row (`0x625460`); `None` for a wand or thrown weapon.
|
||||
let offhand = swing.hit_info & HITINFO_LEFTSWING != 0;
|
||||
let (subclass, metal) = swing_weapon(attacker.and_then(|(_, w, ..)| w), offhand, mats);
|
||||
let row = impacts.0.get(subclass, metal);
|
||||
let defended = defended(swing.victim_state);
|
||||
|
||||
// `0x6247d0`'s own weapon-sound block, BEFORE the victim dispatch: the `$AHn` digit
|
||||
// column, else the generic `WeaponImpactSounds` impact behind the SWINGNOHITSOUND
|
||||
// latch. A defended outcome takes its sound from the dispatch below instead
|
||||
// (INTERIM, decision 0899: the tail's latch test `0x624936` carries no victimState
|
||||
// gate in the trace, so whether the generic impact ALSO plays under a clang is
|
||||
// unpinned — we suppress, which is what the game sounds like).
|
||||
// `0x6247d0`'s weapon-sound block, before the victim dispatch: the `$AHn` column,
|
||||
// else the generic impact behind the `SWINGNOHITSOUND` latch. Under a parry or block
|
||||
// benilla plays no generic impact; the reference's side (`0x624936`) is untraced.
|
||||
if let Some(n) = imp.natural {
|
||||
// The attacker's own natural-weapon sound replaces the generic weapon impact.
|
||||
// The natural-weapon sound replaces the generic weapon impact.
|
||||
let vocal = attacker
|
||||
.and_then(|(_, _, net, ..)| net.display_id)
|
||||
.and_then(|d| voices.0.for_display(d))
|
||||
@@ -714,10 +497,7 @@ fn combat_sounds(
|
||||
);
|
||||
} else if !defended {
|
||||
if let Some(row) = row {
|
||||
// A landed hit: the victim's own slot. A PLAYER victim presents its chest
|
||||
// armor (`0x62fb70`) — the only route to the chain and plate columns, and
|
||||
// self-only because the inv-slot array is; everything else takes the
|
||||
// creature column's remap.
|
||||
// A player victim presents its chest armor (`0x62fb70`), self-only.
|
||||
let slot = match (victim, mats) {
|
||||
(Some((_, _, vnet, _, vstore)), Some(mats))
|
||||
if vnet.kind == EntityKind::Player =>
|
||||
@@ -751,24 +531,16 @@ fn combat_sounds(
|
||||
}
|
||||
}
|
||||
|
||||
// `0x624530`'s victimState-keyed clang (`0x6245bb` parry → `0x623640(sel=0)`,
|
||||
// `0x6245d8` block → `sel=1`), emitted at the VICTIM (`vtable+0x14` on `this`).
|
||||
// Reached on EVERY impact tag — the `$AHn` digit block "has zero effect on the
|
||||
// victim dispatch" (every digit reaches it through the shared tail, `0x6249bb`): a
|
||||
// wolf's bite and the parry clang both sound. Decision 0899 — 0525 wrongly let the
|
||||
// natural column swallow this, so every beast you parried was silent.
|
||||
// `0x625400(sel)` on the victim, then `0x457dc0`'s class+material slot pick. No
|
||||
// defending item means no clang — the reference bails before the play — and that is
|
||||
// a `None` here rather than a `continue`, because the wound vocal below still has to
|
||||
// run. (An unarmed parry is silent; it is not a silent hit.)
|
||||
// The clang at the victim (`0x6245bb` parry, `0x6245d8` block), reached on every
|
||||
// impact tag through the shared tail (`0x6249bb`), natural weapons included. No
|
||||
// defending item skips only the clang: the wound vocal below still runs.
|
||||
let defending = defending_item(
|
||||
victim.and_then(|(_, w, ..)| w),
|
||||
swing.victim_state == VICTIM_BLOCK,
|
||||
);
|
||||
if let (true, Some(row), Some(item)) = (defended, row, defending) {
|
||||
let kit = defense_clang(row, Some(item), mats);
|
||||
// The clang alone is lifted two yards (`0x457e29 fadd [0x801628]`); the generic
|
||||
// weapon impact next to it passes its position straight through.
|
||||
// The clang is lifted two yards (`0x457e29`: `fadd [0x801628]`).
|
||||
let at = victim.map(|(t, ..)| t.translation).unwrap_or(pos) + Vec3::Y * STUB_HEIGHT;
|
||||
play(
|
||||
&mut kits,
|
||||
@@ -784,10 +556,7 @@ fn combat_sounds(
|
||||
}
|
||||
}
|
||||
|
||||
// The dispatch's two **fixed stub kits** ([`DEFLECT_KIT`] / [`ABSORB_KIT`]) — outside the
|
||||
// contact guard above on purpose: `0x624530` reaches them on every impact, and
|
||||
// [`no_contact`] sorts deflect and immune into the whiff family, so a guarded placement
|
||||
// would leave exactly these two branches silent, which is what they were.
|
||||
// The stub kits sit outside the contact guard: [`no_contact`] includes deflect and immune.
|
||||
let stub_at = |t: &Transform| t.translation + Vec3::Y * STUB_HEIGHT;
|
||||
if swing.victim_state == VICTIM_DEFLECT {
|
||||
if let Some((victim_tr, ..)) = victim {
|
||||
@@ -814,42 +583,30 @@ fn combat_sounds(
|
||||
}
|
||||
}
|
||||
|
||||
// The victim's wound vocal, the tail of the same dispatch. The reference's ladder is
|
||||
// fully read now (`0x62460c`..`0x624674`): absorb/resist/immune leave through the stub
|
||||
// above and never reach it; then `hitInfo & AFFECTS_VICTIM (0x2)` is required, and the
|
||||
// class is crushing `0x8000` → 9, else critical `0x80` → 3, else `MISS 0x10` → nothing,
|
||||
// else 2.
|
||||
// The wound vocal (`0x62460c`..`0x624674`): absorb, resist and immune never reach it;
|
||||
// then `hitInfo & 0x2` (`AFFECTS_VICTIM`) is required, and the class is crushing → 9,
|
||||
// critical → 3, `MISS` → nothing, else 2.
|
||||
//
|
||||
// **`damage > 0` stands in for that `0x2`, deliberately** (and the parry/block
|
||||
// suppression with it). vmangos leaves `HITINFO_AFFECTS_VICTIM` *set* on a parry and a
|
||||
// dodge — it only clears it for immune and for zero damage with MISS|ABSORB
|
||||
// (`Unit.cpp:1366`/`1587`), where its own comment calls the bit "no being hit animation
|
||||
// on victim without it". Taking `0x2` literally against this server would therefore
|
||||
// grunt on every parry, which is audibly wrong and is not what the bit meant. The
|
||||
// reference's ladder is faithful to the reference; this gate is faithful to the sound.
|
||||
// Deviation: `damage > 0` without a parry or block stands in for `0x2`, because vmangos
|
||||
// clears that bit only for immune and a zero-damage miss or absorb
|
||||
// (`Unit.cpp:1366`/`1587`), so it would grunt on every parry.
|
||||
if swing.damage > 0
|
||||
&& swing.hit_info & HITINFO_ABSORB_OR_RESIST == 0
|
||||
&& !matches!(swing.victim_state, VICTIM_PARRY | VICTIM_BLOCK)
|
||||
{
|
||||
// The vocal's **class**, which decides both gates below: crushing `0x8000` → 9,
|
||||
// else critical `0x80` → 3, else 2 (`0x62462c`/`0x624649`/`0x624666`).
|
||||
// The class decides both gates below (`0x62462c`/`0x624649`/`0x624666`).
|
||||
let crushing = swing.hit_info & HITINFO_CRUSHING != 0;
|
||||
// The `AISOUNDDESC` gate (`0x4591f0` from `0x6234cb`): a server-pushed object sound
|
||||
// live on the victim suppresses its own vocal — **classes 0-3 and 8 only**
|
||||
// (`0x6234bb`/`0x6234bf`/`0x6234c4`), so a CRUSHING blow's class 9 punches through a
|
||||
// scripted voice line where an ordinary hit and a crit do not. The CGPlayer twin
|
||||
// `0x62f880` omits the gate, so a player is never suppressed. Filtered off the victim
|
||||
// rather than `continue`d, because everything else in this iteration still stands.
|
||||
// The `AISOUNDDESC` gate (`0x4591f0` from `0x6234cb`): a live server-pushed sound on
|
||||
// the victim suppresses classes 0-3 and 8 (`0x6234bb`), so a crushing blow's class 9
|
||||
// gets through. The player twin `0x62f880` has no gate.
|
||||
let vocal_victim = victim.filter(|(_, _, net, ..)| {
|
||||
crushing
|
||||
|| net.kind == EntityKind::Player
|
||||
|| !swing.victim.is_some_and(|v| object_sound_playing(&out, v))
|
||||
});
|
||||
if let Some((victim_tr, _, net, victim_is_you, _)) = vocal_victim {
|
||||
// The class chance roll (decision 2073), inside the victim's own `[vt+0x88]` and
|
||||
// therefore keyed on the VICTIM's type: class 2 is 60 for a creature and 30 for a
|
||||
// player, while classes 3 and 9 carry 100 and always sound. So an ordinary wound
|
||||
// grunt thins out under sustained melee and the big hits punch through it.
|
||||
// The class roll, keyed on the victim's type (`[vt+0x88]`): class 2 is 60 for a
|
||||
// creature and 30 for a player; classes 3 and 9 are 100.
|
||||
if !crushing && !crit {
|
||||
let threshold = if net.kind == EntityKind::Player {
|
||||
INJURY_CHANCE_PLAYER
|
||||
@@ -860,22 +617,14 @@ fn combat_sounds(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// **A zero column plays NOTHING** (decision 2075). benilla used to walk down the
|
||||
// family here — crushing → critical → ordinary — on the reasoning that "crushing
|
||||
// rows are often 0 in data"; they are, and the reference is simply silent for
|
||||
// them. `0x623490` tests the selected id exactly once (`0x6234e6 test ebx,ebx ;
|
||||
// 0x6234e8 je 0x62350e`, the shared epilogue), and no fallback is even reachable:
|
||||
// the row pointer dies at `0x6234e4`, so neither the bus pick `0x623b10` (which
|
||||
// takes the category) nor the play `0x458890` (bus, kit, &pos) ever sees the row
|
||||
// to re-read a column from. The CGPlayer twin `0x62f880` is identical
|
||||
// (`0x62f8be`/`0x62f8c3`).
|
||||
// A zero column plays nothing, with no fallback to another class: `0x623490`
|
||||
// tests the id once (`0x6234e6`), as does the player twin (`0x62f8be`).
|
||||
let vocal = net
|
||||
.display_id
|
||||
.and_then(|d| voices.0.for_display(d))
|
||||
.map(|v| v.injury[if crushing { 2 } else { usize::from(crit) }])
|
||||
.unwrap_or(0);
|
||||
// Your own wounds get the CGPlayer twin's private bus 8 (cap 1); everyone
|
||||
// else's share the world's bus 7 (cap 2).
|
||||
// Your own wounds take bus 8 (cap 1), everyone else's bus 7 (cap 2).
|
||||
let bus = if victim_is_you {
|
||||
Bus::SELF_INJURY
|
||||
} else {
|
||||
@@ -907,10 +656,7 @@ pub(super) fn plugin(app: &mut App) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// **The `$CSS` split** — `0x624ca0`'s `{0, 2, 6}`, and only that. The two outcomes this
|
||||
/// pins hardest are immune and deflect: benilla used to sort them onto the miss whoosh
|
||||
/// (via [`no_contact`], which is the *impact* family's question), where the reference sends
|
||||
/// them through the connecting swing like any other contact.
|
||||
/// `0x624ca0`'s `{0, 2, 6}`; immune and deflect take the connecting swing.
|
||||
#[test]
|
||||
fn only_unaffected_dodge_and_evade_take_the_miss_whoosh() {
|
||||
for whiff in [0, VICTIM_DODGE, VICTIM_EVADE] {
|
||||
@@ -931,11 +677,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `0x623870`'s three answers, on the real shipped `ItemSubClass.dbc`: an empty hand is
|
||||
/// Light (the out-param is written 0 and the function returns *true*), a weapon is its
|
||||
/// subclass's `WeaponSwingSize`, and a **non-weapon in hand is `None`** — the function
|
||||
/// returns false there, so a held misc item swings in silence rather than borrowing a
|
||||
/// weight. Skips without client data.
|
||||
/// `0x623870`'s three answers on the shipped `ItemSubClass.dbc`. Skips without client data.
|
||||
#[test]
|
||||
fn the_swing_weight_is_the_dbc_column_not_a_guess() {
|
||||
let Some(data) = benilla_formats::wow_data() else {
|
||||
@@ -974,10 +716,8 @@ mod tests {
|
||||
assert_eq!(swing_weight(None, false, &subs), Some(0));
|
||||
}
|
||||
|
||||
/// The join the whole leg rests on, end to end on shipped data: the weight column picks a
|
||||
/// row of `WeaponSwingSounds2`, and the crit bit picks its column. A dagger crit is
|
||||
/// `LightWeaponCritical`; a two-handed sword is `HeavyWeaponNormal`; unarmed is Light.
|
||||
/// Skips without client data.
|
||||
/// The weight picks the `WeaponSwingSounds2` row and the crit bit its column, on shipped
|
||||
/// data. Skips without client data.
|
||||
#[test]
|
||||
fn a_weapons_subclass_reaches_its_own_woosh_kit() {
|
||||
let Some(data) = benilla_formats::wow_data() else {
|
||||
@@ -1010,9 +750,8 @@ mod tests {
|
||||
assert_eq!(kit(Some((4, 6)), false), None, "a shield makes no whoosh");
|
||||
}
|
||||
|
||||
/// The two fixed stub kits resolve to the exact `(DONOTRENAME)` rows the client's startup
|
||||
/// cache names — the join that used to be the "unpinned kit id" leaving both branches
|
||||
/// silent. Skips without client data.
|
||||
/// The stub and miss kits are the `(DONOTRENAME)` rows the startup cache names. Skips without
|
||||
/// client data.
|
||||
#[test]
|
||||
fn the_deflect_and_absorb_stubs_name_real_kits() {
|
||||
let Some(data) = benilla_formats::wow_data() else {
|
||||
@@ -1031,8 +770,6 @@ mod tests {
|
||||
named(ABSORB_KIT).as_deref(),
|
||||
Some("(DONOTRENAME)AbsorbGetHit")
|
||||
);
|
||||
// …and they are the ids the miss whooshes sit beside in the same cache, so a rename in
|
||||
// shipped data would take all four out together rather than one silently.
|
||||
assert_eq!(
|
||||
named(COMBAT_MISS_1H).as_deref(),
|
||||
Some("(DONOTRENAME)Combat Miss 1H")
|
||||
@@ -1043,8 +780,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A row shaped like the real 5875 Sword1H-metal row (byte-verified ids): flesh 143/144,
|
||||
/// chain 145/146, plate 147/148, parry-metal 1002, parry-wood 1001, shields 3263/3262.
|
||||
/// The shipped Sword1H-metal row's ids.
|
||||
fn sword1h_metal() -> benilla_formats::WeaponImpactRow {
|
||||
let mut impact = [0u32; 10];
|
||||
let mut crit = [0u32; 10];
|
||||
@@ -1064,9 +800,7 @@ mod tests {
|
||||
benilla_formats::WeaponImpactRow { impact, crit }
|
||||
}
|
||||
|
||||
/// The clang's family follows the **defending item's class**, and metal-vs-wood follows its
|
||||
/// `Material` — including on a shield, which benilla used to hardcode to metal. Skips
|
||||
/// without client data (the metal test is `Material.dbc`'s).
|
||||
/// Skips without client data (the metal test is `Material.dbc`'s).
|
||||
#[test]
|
||||
fn defense_clang_follows_the_defending_items_class_and_material() {
|
||||
let Some(data) = benilla_formats::wow_data() else {
|
||||
@@ -1081,7 +815,7 @@ mod tests {
|
||||
// Class 2 = the parry pair; 1 is metal, 2 is wood.
|
||||
assert_eq!(defense_clang(&row, Some((2, 1)), m), 1002, "metal weapon");
|
||||
assert_eq!(defense_clang(&row, Some((2, 2)), m), 1001, "wooden weapon");
|
||||
// Class 4 = the shield pair — and a wooden shield is NOT the metal kit.
|
||||
// Class 4 = the shield pair; a wooden shield is not the metal kit.
|
||||
assert_eq!(defense_clang(&row, Some((4, 1)), m), 3263, "metal shield");
|
||||
assert_eq!(defense_clang(&row, Some((4, 2)), m), 3262, "wooden shield");
|
||||
// Plate and chain shields are metal-flagged; leather and cloth are not.
|
||||
@@ -1091,8 +825,7 @@ mod tests {
|
||||
assert_eq!(defense_clang(&row, Some((4, 0)), m), 3262, "no material");
|
||||
}
|
||||
|
||||
/// `0x625400(sel)`: a parry prefers a mainhand **weapon** and otherwise falls through to the
|
||||
/// offhand; a block goes straight to the offhand. Nothing defending = no clang.
|
||||
/// `0x625400(sel)`.
|
||||
#[test]
|
||||
fn the_defending_item_is_the_hand_the_outcome_names() {
|
||||
let sword_and_board = Wielded {
|
||||
@@ -1121,15 +854,13 @@ mod tests {
|
||||
};
|
||||
assert_eq!(defending_item(Some(&torch_and_board), false), Some((4, 6)));
|
||||
|
||||
// Nothing in the offhand, nothing defending — an unarmed parry is silent.
|
||||
// Nothing in the offhand, nothing defending: an unarmed parry is silent.
|
||||
let bare = Wielded::default();
|
||||
assert_eq!(defending_item(Some(&bare), false), None);
|
||||
assert_eq!(defending_item(Some(&bare), true), None);
|
||||
assert_eq!(defending_item(None, true), None);
|
||||
}
|
||||
|
||||
/// Parry and block are the defended pair — the outcomes whose sound comes from the victim
|
||||
/// dispatch's clang instead of the attacker's weapon-sound block. A landed hit is not.
|
||||
#[test]
|
||||
fn only_parry_and_block_are_defended() {
|
||||
assert!(defended(VICTIM_PARRY));
|
||||
@@ -1147,7 +878,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The landed hit indexes the victim's slot directly, crit-tiered.
|
||||
#[test]
|
||||
fn landed_impact_reads_the_victim_slot_crit_tiered() {
|
||||
let row = sword1h_metal();
|
||||
@@ -1159,10 +889,7 @@ mod tests {
|
||||
assert_eq!(landed_impact(&row, impact_slot::STONE, false), 3206);
|
||||
}
|
||||
|
||||
/// The **creature** half of `[vt+0x90]` (`0x6238f0`): the `CreatureSoundData` column through
|
||||
/// the four-entry remap `{0, 8, 7, 9}`, with everything `>= 4` refused back to flesh. It
|
||||
/// reaches flesh, stone, wood and ethereal — and can never reach the chain or plate columns,
|
||||
/// which is what makes the player override the only route to them.
|
||||
/// `0x6238f0`'s remap never reaches the chain or plate slots.
|
||||
#[test]
|
||||
fn the_creature_impact_slot_is_the_four_entry_remap() {
|
||||
assert_eq!(creature_impact_slot(0), impact_slot::FLESH);
|
||||
@@ -1183,10 +910,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The **player** half, on the shipped `Material.dbc`: the chest armor's slot. This is the
|
||||
/// leg that used to send every player victim to flesh — a plate warrior's own hits sounded
|
||||
/// like a punch on meat. Joined here to the real weapon row so the assertion is the kit you
|
||||
/// actually hear, not an index. Skips without client data.
|
||||
/// The chest armor's slot on shipped data, asserted as the kit heard. Skips without client
|
||||
/// data.
|
||||
#[test]
|
||||
fn a_players_chest_armor_picks_the_chain_and_plate_columns() {
|
||||
let Some(data) = benilla_formats::wow_data() else {
|
||||
@@ -1202,7 +927,7 @@ mod tests {
|
||||
let hit = |material: u32| {
|
||||
landed_impact(sword, materials.armor_impact_slot(material) as usize, false)
|
||||
};
|
||||
// Real 5875 row 8 (subclass 7, metal): flesh 143, chain 145, plate 147.
|
||||
// Shipped row 8 (subclass 7, metal): flesh 143, chain 145, plate 147.
|
||||
assert_eq!(hit(6), 147, "plate chest");
|
||||
assert_eq!(hit(5), 145, "chain chest");
|
||||
assert_eq!(hit(8), 143, "leather chest rings as flesh");
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
//! Creature voice — CreatureSoundData-driven vocals (decision 0070 slice 3): the death cry on a
|
||||
//! live health→0 transition, and the anim-tag vocals (`$FD1..4` fidgets, `$FDX` stand,
|
||||
//! `$WNG`/`$WGG` wing flap/glide).
|
||||
//! Creature voice from CreatureSoundData: the barks (aggro, pet, death), the alert flare, the
|
||||
//! anim-tag vocals, the body loop, the pet-dismiss sound and the hard-landing grunt. The swing
|
||||
//! vocals and the `$AH0..3` custom attacks are in [`super::combat`].
|
||||
//!
|
||||
//! The swing-driven vocals (exertion/injury) and the `$AH0..3` custom-attack columns live in
|
||||
//! [`super::combat`] (decisions 0075 + 0525 — the custom attacks are the swing dispatch's
|
||||
//! natural-weapon impact, not free-standing anim vocals); the
|
||||
//! aggro/alert flares land here ([`ai_reaction_vocals`], decision 0280), and so does the ambient
|
||||
//! body loop ([`creature_body_loops`] — the `loop_sound` column, `0x623800`'s alive-gate). Still
|
||||
//! untriggered — data in the catalog, triggers INFERRED (0280): stun/jump_start/jump_end
|
||||
//! (offsets verified, live triggers likely M2 tags — unpinned).
|
||||
//! The reference never plays the stun (class 4, column 7) or jump-start (class 11, column 25)
|
||||
//! vocals: no call site dispatches them. Jump-end (class 12, column 26) is dispatched with force 1
|
||||
//! on landing (`0x602d00`, at `0x602dac` and `0x602dcb`), but columns 25 and 26 are empty in every
|
||||
//! `CreatureSoundData.dbc` row. None of the three is played here.
|
||||
|
||||
use bevy::ecs::entity::EntityHashMap;
|
||||
use bevy::prelude::*;
|
||||
@@ -48,20 +45,15 @@ fn load_creature_voices(mut commands: Commands, assets: Option<Res<WorldAssets>>
|
||||
}
|
||||
}
|
||||
|
||||
/// `0x623a40`'s bark states, in the order its jump table `0x623afc` lists them — the number IS
|
||||
/// the priority (higher wins, `0x623a82`). State `3` is a real arm that plays nothing and has no
|
||||
/// known caller, so it has no constant.
|
||||
/// `0x623a40`'s bark states in its jump table `0x623afc`; the number is the priority (higher
|
||||
/// wins, `0x623a82`). State 3 plays nothing and has no known caller.
|
||||
const BARK_AGGRO: u8 = 0;
|
||||
const BARK_PET_ORDER: u8 = 1;
|
||||
const BARK_PET_ATTACK: u8 = 2;
|
||||
const BARK_DEATH: u8 = 4;
|
||||
|
||||
/// Step 3 of [`play_bark`] — `0x623a66`-`0x623a88`, the priority comparison alone.
|
||||
///
|
||||
/// `latched` is the state of the bark currently sounding on this unit, or `None` for a free slot.
|
||||
/// The reference's two `je`s skip the comparison entirely when the handle is absent or finished,
|
||||
/// so a free slot admits **every** state including `0`; a held slot admits only a *strictly*
|
||||
/// higher one (`jle` aborts on equal, which is what keeps a burst of identical barks to one).
|
||||
/// The priority test (`0x623a66`-`0x623a88`) against the sounding bark's state: a free slot
|
||||
/// admits every state, a held one only a strictly higher one (`jle`), so a burst plays once.
|
||||
const fn bark_admitted(latched: Option<u8>, state: u8) -> bool {
|
||||
match latched {
|
||||
None => true,
|
||||
@@ -69,10 +61,8 @@ const fn bark_admitted(latched: Option<u8>, state: u8) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Step 5 of [`play_bark`] — the jump table at `0x623afc`, as a lookup. `0` means "this state
|
||||
/// plays nothing", which covers both the table's own silent arm (state 3, `0x623af0`) and its
|
||||
/// out-of-range default (`0x623aa0 cmp eax,4; ja`), and is also what an unpopulated column reads
|
||||
/// as. All three are the same observable in the reference and the same `0` here.
|
||||
/// The jump table `0x623afc`; 0 plays nothing, for state 3 (`0x623af0`), a state past 4
|
||||
/// (`0x623aa0`) and an empty column alike.
|
||||
const fn bark_kit(voice: &benilla_formats::CreatureVoice, state: u8) -> u32 {
|
||||
match state {
|
||||
BARK_AGGRO => voice.aggro,
|
||||
@@ -83,31 +73,15 @@ const fn bark_kit(voice: &benilla_formats::CreatureVoice, state: u8) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The creature bark dispatcher, whole** — `0x623a40(ecx = unit, state)`, the one function
|
||||
/// every one-shot creature vocal below goes through (decision 2039). Four states are live here:
|
||||
/// `0` the HOSTILE aggro flare, `1` the pet's ORDER bark, `2` the pet's ATTACK bark, `4` the
|
||||
/// death cry. (State `3` exists in the table and plays nothing — it still stops and latches.)
|
||||
/// The creature bark dispatcher `0x623a40(unit, state)`: 0 the hostile aggro flare, 1 and 2 the
|
||||
/// pet's order and attack barks, 4 the death cry. Its order is the reference's:
|
||||
///
|
||||
/// Reading it as one function is the point: it was three call sites with three different subsets
|
||||
/// of its gates before, and the pet barks could not be added correctly to any of them, because
|
||||
/// what makes a pet bark audible over a pet's near-continuous aggro flares is exactly the
|
||||
/// priority rule the aggro-only site had no reason to carry.
|
||||
///
|
||||
/// The order of operations is the reference's, and it is load-bearing:
|
||||
///
|
||||
/// 1. **No voice row, no bark** (`0x623a4a`) — the caller's `for_display` miss.
|
||||
/// 2. **A server-pushed object sound live on this unit mutes it** (`0x623a59` → `0x4591f0`): a
|
||||
/// scripted voice line is not talked over. Unlike the class route `0x623490`/`0x62f880`, this
|
||||
/// one is a direct call, not a virtual — so it has **no player exemption**.
|
||||
/// 3. **The priority latch** (`0x623a66`–`0x623a88`): while `[unit+0xb20]` holds a live handle, a
|
||||
/// state `<=` the latched one is dropped outright. A free slot skips the comparison entirely.
|
||||
/// 4. **Stop the old and latch the new** (`0x623a8d`/`0x623a95`) — *before* the column is read.
|
||||
/// 5. **Then** resolve the column and play. **A zero column still silences step 4's victim** —
|
||||
/// the handle is overwritten with the null result (`0x623aee`), so a state whose column is
|
||||
/// unpopulated cuts short whatever it superseded and sounds nothing in its place. That is the
|
||||
/// client's behaviour, not an oversight of ours. (It is not reachable through the pet barks on
|
||||
/// a vmangos server: `SendPetTalk` is gated on `SUMMON_PET`, and only those four voices carry
|
||||
/// the columns — see [`pet_talk_vocals`].)
|
||||
/// 1. No voice row, no bark (`0x623a4a`).
|
||||
/// 2. A server-pushed object sound live on the unit mutes it (`0x623a59` → `0x4591f0`); a direct
|
||||
/// call, so unlike the class route `0x623490`/`0x62f880` players are not exempt.
|
||||
/// 3. While `[unit+0xb20]` holds a live bark, a state at or below its own is dropped.
|
||||
/// 4. Stop the old bark and latch the new (`0x623a8d`/`0x623a95`), before the column is read.
|
||||
/// 5. Resolve the column and play: a zero column still silences step 4's victim (`0x623aee`).
|
||||
fn play_bark(
|
||||
kits: &mut SoundKits,
|
||||
assets: &WorldAssets,
|
||||
@@ -149,13 +123,9 @@ fn play_bark(
|
||||
}
|
||||
}
|
||||
|
||||
/// Play the death vocal on a **live** death — the reference's two triggers, each a field edge
|
||||
/// (decision 2297): the `UNIT_FIELD_HEALTH` watcher's alive→dead arm (`0x6046f0` at `0x6047a3`,
|
||||
/// `OLD > 0 && NEW ≤ 0` — the real death handler `0x605860`) and the `UNIT_DYNAMIC_FLAGS`
|
||||
/// watcher's `UNIT_DYNFLAG_DEAD` SET edge (`0x600543`), which fires the very same `0x623a40(4)`
|
||||
/// — so a feign drops the body with its death cry, exactly like a kill (decision 1022). A unit
|
||||
/// that streams in already dead cries nothing: the edge stream is create-suppressed, the same
|
||||
/// distinction the animation driver makes for the settled-corpse pose.
|
||||
/// The death cry `0x623a40(4)` on either edge: health falling to 0 (`0x6046f0` at `0x6047a3`, the
|
||||
/// death handler `0x605860`) or `UNIT_DYNFLAG_DEAD` set (`0x600543`), so a feign cries too. A
|
||||
/// unit that streams in dead cries nothing: create edges are suppressed.
|
||||
fn death_vocals(
|
||||
mut edges: MessageReader<FieldChanged>,
|
||||
units: Query<(&NetEntity, &Transform)>,
|
||||
@@ -199,46 +169,15 @@ fn death_vocals(
|
||||
}
|
||||
}
|
||||
|
||||
/// The aggro/alert flare vocals (`SMSG_AI_REACTION` → the net bridge; decision 0280): HOSTILE →
|
||||
/// the aggro bark (CreatureSoundData col 10), ALERT → the alert bark (col 13) — pure audio, like
|
||||
/// the client (`0x6056e0` plays no animation/UI on either leg).
|
||||
/// `SMSG_AI_REACTION` vocals, audio only (`0x6056e0` plays no animation). HOSTILE is the aggro
|
||||
/// bark (column 10) through [`play_bark`], whose slot drops a repeat while one sounds: vmangos
|
||||
/// sends HOSTILE on every target acquisition (`Unit::Attack`) and every pet autocast or
|
||||
/// self-picked target (`Unit::SendPetAIReaction`), so a pet draws them in bursts.
|
||||
///
|
||||
/// **HOSTILE rides the unit's one-shot voice channel, and that is not a nicety** (decision 1399):
|
||||
/// the bark goes out through `0x623a40(0)`,
|
||||
/// which stores its channel in `[unit+0xb20]` with category 0 latched in `[unit+0xb24]` —
|
||||
/// **the lowest category in the table, so it never interrupts a playing bark and a repeat is
|
||||
/// dropped while one is live** ([`unit_voice_playing`]). The wire does not send this packet
|
||||
/// sparingly: vmangos fires `SMSG_AI_REACTION` HOSTILE from `Unit::Attack` on every target
|
||||
/// acquisition *and* from `Unit::SendPetAIReaction` on every pet autocast and self-picked
|
||||
/// target (`PetAI::DoAttack`, `PetAI::UpdateAI`'s autocast leg), so a hunter's pet in a normal
|
||||
/// fight draws them in bursts. Played ungated, a level-60 bear pet fired its 3.166 s
|
||||
/// `mBearAggroA.wav` **63 times in two minutes** — three copies inside a single frame, then a
|
||||
/// ~10 Hz run — which is up to thirty overlapping copies of one roar, and is what the director
|
||||
/// heard as a phasing, stuttering, constantly-growling bear. The reference receives the same
|
||||
/// packets and collapses them at this slot.
|
||||
///
|
||||
/// **ALERT is a different route, and is faithfully unconditional** — pinned since, decision 1401.
|
||||
/// It reaches `vtable+0x88(8,0)` → `0x623490` → col 13, and it *is* rolled, but the class-8
|
||||
/// threshold is **100** and the compare is inclusive, so `P = 1`. Its one
|
||||
/// real gate is a mute while a **server-pushed** object sound is live on the unit — the
|
||||
/// `SMSG_PLAY_OBJECT_SOUND` (opcode `0x278`) registry. ALERT stores no latch and stays off the
|
||||
/// slot.
|
||||
///
|
||||
/// **That gate is now in.** 1401 recorded the opcode as one "benilla does not implement at all",
|
||||
/// which was wrong when it was written — benilla has parsed, decoded and played
|
||||
/// `SMSG_PLAY_OBJECT_SOUND` since well before it (`sound::net::play_object_sound` →
|
||||
/// `ServerSoundKind::ObjectSound` → `sound::zone::server_sounds`, which even resolves the source
|
||||
/// entity to position the kit at it). What was missing was the **per-GUID registry** the gates
|
||||
/// consult (`0x4591f0`, tested by `0x623a40`'s gate (i) and by `0x623490`'s gate 3 for classes 0,
|
||||
/// 1, 2, 3 and 8): the pushed channel played untagged, so nothing could ask "is an object sound
|
||||
/// live on this unit".
|
||||
///
|
||||
/// It could not be tagged with a bare `PlayExtras::source`, because that shared tag also answered
|
||||
/// the greeting latch — a third meaning in one field. [`kit::Latch`] split those apart (decision
|
||||
/// 1399), and `Latch::ObjectSound` is the registry: a resolved object sound registers on its unit
|
||||
/// for as long as it sounds, and [`kit::object_sound_playing`] is `0x4591f0`. ALERT (class 8) and
|
||||
/// HOSTILE (the priority route) consult it below; the combat vocals (classes 0–3) consult it in
|
||||
/// `super::combat`. Players are exempt — the CGPlayer twin `0x62f880` omits the gate entirely.
|
||||
/// ALERT is the class-8 route (`vtable+0x88(8,0)` → `0x623490` → column 13): its roll threshold
|
||||
/// is 100, inclusive, so it always plays unless a server-pushed object sound
|
||||
/// (`SMSG_PLAY_OBJECT_SOUND`, registered as `Latch::ObjectSound`, tested by `0x4591f0`) is live on
|
||||
/// a non-player unit. It stores no latch.
|
||||
fn ai_reaction_vocals(
|
||||
mut reactions: MessageReader<crate::net::AiReactionMessage>,
|
||||
units: Query<(&NetEntity, &Transform, Option<&MountChild>)>,
|
||||
@@ -261,10 +200,8 @@ fn ai_reaction_vocals(
|
||||
let Ok((net, transform, mount_child)) = units.get(r.unit) else {
|
||||
continue;
|
||||
};
|
||||
// The mounted redirect (`0x60c480` — `+0xb44 ?: +0xb40`; 0441 fold-back): ALERT reads
|
||||
// through the mount-preferred
|
||||
// getter — a mounted unit alerts with its MOUNT's voice — while HOSTILE (like DEATH)
|
||||
// reads the base row directly and stays the rider's own bark.
|
||||
// ALERT reads the mount-preferred row (`0x60c480`: `+0xb44 ?: +0xb40`), so a mounted unit
|
||||
// alerts in its mount's voice; HOSTILE, like death, reads the rider's own row.
|
||||
let display = if r.hostile {
|
||||
net.display_id
|
||||
} else {
|
||||
@@ -276,8 +213,7 @@ fn ai_reaction_vocals(
|
||||
let Some(voice) = display.and_then(|d| voices.0.for_display(d)) else {
|
||||
continue;
|
||||
};
|
||||
// HOSTILE is the priority route — `0x623a40(0)`, [`play_bark`], which owns both the
|
||||
// `AISOUNDDESC` mute and the `[unit+0xb20]` latch.
|
||||
// HOSTILE is the bark route `0x623a40(0)`.
|
||||
if r.hostile {
|
||||
play_bark(
|
||||
&mut kits,
|
||||
@@ -292,11 +228,8 @@ fn ai_reaction_vocals(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// ALERT is the *class* route (`vtable+0x88(8,0)` → `0x623490` → col 13) and is a
|
||||
// different function: it neither tests nor stores the voice slot, so it stays a plain
|
||||
// play. It does consult the same `AISOUNDDESC` pool (`0x4591f0` from `0x6234cb`) — but
|
||||
// there through a virtual whose **CGPlayer twin `0x62f880` omits the gate**, which is why
|
||||
// the player exemption lives on this leg and not in [`play_bark`].
|
||||
// ALERT neither tests nor stores the voice slot. Its object-sound mute (`0x6234cb` →
|
||||
// `0x4591f0`) runs through a virtual whose CGPlayer twin `0x62f880` omits it.
|
||||
if voice.alert == 0 {
|
||||
continue;
|
||||
}
|
||||
@@ -318,20 +251,10 @@ fn ai_reaction_vocals(
|
||||
}
|
||||
}
|
||||
|
||||
/// The pet's voice (`SMSG_PET_ACTION_SOUND` → the net bridge; decision 2039): the two talk
|
||||
/// selectors, straight onto [`play_bark`] at states 1 and 2.
|
||||
///
|
||||
/// The reference's handler `0x6040c0` is four instructions of its own once the guid is resolved:
|
||||
/// selector `0` → `0x623a40(1)`, selector `1` → `0x623a40(2)`, **anything else → nothing** (there
|
||||
/// is no default arm — `cmp eax,1; jne` falls to the epilogue). So the third value vmangos could
|
||||
/// invent would be silent here too, and that is the client's answer, not a gap in ours.
|
||||
///
|
||||
/// **This is a warlock-demon surface, and the two ends say so independently.** Only four voices
|
||||
/// in the shipped file carry these columns — the Imp, Succubus, Doomguard and Voidwalker
|
||||
/// (`benilla_formats`' `only_the_four_demon_voices_carry_pet_barks`) — and vmangos only *sends*
|
||||
/// the packet for `GetPetType() == SUMMON_PET`, i.e. those same demons, at a **10% roll** per
|
||||
/// order (`Unit.cpp:8939`, `PetHandler.cpp:522`, `PetAI.cpp:335`; the other 90% is
|
||||
/// `SendPetAIReaction`, the growl). A hunter's pet never reaches either half.
|
||||
/// `SMSG_PET_ACTION_SOUND`: `0x6040c0` plays selector 0 as `0x623a40(1)`, 1 as `0x623a40(2)`,
|
||||
/// and any other value not at all. Only the Imp, Succubus, Doomguard and Voidwalker voices carry
|
||||
/// these columns, and vmangos sends it only for a `SUMMON_PET`, on a 10% roll per order
|
||||
/// (`Unit.cpp:8960`, `PetHandler.cpp:522`, `PetAI.cpp:335`).
|
||||
fn pet_talk_vocals(
|
||||
mut talks: MessageReader<crate::net::PetTalkMessage>,
|
||||
units: Query<(&NetEntity, &Transform)>,
|
||||
@@ -358,16 +281,12 @@ fn pet_talk_vocals(
|
||||
let Ok((net, transform)) = units.get(t.unit) else {
|
||||
continue;
|
||||
};
|
||||
// `0x6040c0` resolves the guid with `0x468460(ecx = 8)` — the **TYPEMASK_UNIT** bit test,
|
||||
// which a player's cumulative mask also carries (a mind-controlled player is somebody's
|
||||
// pet too) but a GameObject's does not.
|
||||
// `0x6040c0` resolves the guid with the `TYPEMASK_UNIT` test `0x468460(ecx = 8)`, which a
|
||||
// player passes.
|
||||
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
|
||||
continue;
|
||||
}
|
||||
// The pet's OWN voice row, not a mount-preferred one: `0x6040c0` resolves the packet's
|
||||
// guid and hands that object straight to `0x623a40`, which reads `[unit+0xb40]` — the
|
||||
// base row (the `+0xb44` redirect belongs to `0x60c480`, which is
|
||||
// not on this path).
|
||||
// The pet's own row: `0x623a40` reads the base `[unit+0xb40]`, not the mount redirect.
|
||||
let Some(voice) = net.display_id.and_then(|d| voices.0.for_display(d)) else {
|
||||
continue;
|
||||
};
|
||||
@@ -385,21 +304,9 @@ fn pet_talk_vocals(
|
||||
}
|
||||
}
|
||||
|
||||
/// A dismissed pet's parting sound (`SMSG_PET_DISMISS_SOUND` → the net bridge; decision 2039) —
|
||||
/// `CreatureSoundData` column 29, at a bare world point.
|
||||
///
|
||||
/// **Not a bark, and that is the whole shape of it.** `0x604140` never touches `0x623a40`: it
|
||||
/// resolves the row *fresh by model id* (`CreatureModelData[id].SoundID` → `CreatureSoundData`,
|
||||
/// no display step and no fallback), reads `[row+0x74]`, and plays it through the plain kit call
|
||||
/// at the packet's own point — no unit, no voice latch, no `AISOUNDDESC` mute, no attach point.
|
||||
/// It cannot be otherwise: there is no unit left to hang any of those on.
|
||||
///
|
||||
/// That fresh, inlined resolve is also why column 29 was believed dead — a census over the
|
||||
/// consumers of the *cached* `[unit+0xb40]` row can never reach it.
|
||||
///
|
||||
/// **vmangos never sends this packet**, so nothing here fires against our server today. It is
|
||||
/// built because the reference is the spec, and because it is what makes loading column 29 a
|
||||
/// mechanism rather than a guess.
|
||||
/// `SMSG_PET_DISMISS_SOUND`: `CreatureSoundData` column 29 at the packet's point. Not a bark:
|
||||
/// `0x604140` resolves the row by model id (`CreatureModelData[id].SoundID`, no display step, no
|
||||
/// fallback), reads `[row+0x74]` and plays it with no unit, latch or mute. vmangos never sends it.
|
||||
fn pet_dismiss_sounds(
|
||||
mut dismissals: MessageReader<crate::net::PetDismissSoundMessage>,
|
||||
voices: Option<Res<CreatureVoices>>,
|
||||
@@ -440,18 +347,11 @@ fn pet_dismiss_sounds(
|
||||
}
|
||||
}
|
||||
|
||||
/// The ambient body loop (`loop_sound`, CreatureSoundData col 23): a creature whose body
|
||||
/// inherently sounds — an elemental's rumble, a slime's gurgle, a shredder's engine — hums
|
||||
/// continuously while alive. The client's `0x623800` gate: health > 0, `UNIT_DYNAMIC_FLAGS` DEAD
|
||||
/// (`0x20`, the feign-death
|
||||
/// visual) clear, the column nonzero, and a "not already playing" latch — here the tracked
|
||||
/// channel's own liveness, reconciled every frame. That reconcile shape covers the client's
|
||||
/// field-delta watchers (death stops it, resurrection restarts it) *and* doubles as the restart
|
||||
/// trigger the kit player's out-of-range cull documents (walk away → the loop stops; walk back →
|
||||
/// it re-arms). Despawn teardown is the mixer's source-channel reaper. INTERIM, named: the loop
|
||||
/// is **forced** regardless of the kit's own 0x200 flag — every col-23 kit in 5875 is authored
|
||||
/// `*Loop*` yet half omit the flag, so respecting it would silence half the class (`0x461d80`'s
|
||||
/// flag handling is unpinned; the record covers the reading).
|
||||
/// The body loop (column 23, an elemental's rumble, a shredder's engine) while alive, under the
|
||||
/// reference's `0x623800` gate: health > 0, `UNIT_DYNFLAG_DEAD` clear, the column nonzero, not
|
||||
/// already playing. Reconciled every frame, which also re-arms it after an out-of-range cull. It
|
||||
/// loops whatever the kit's `0x200` flag, which half the column's kits omit: `0x461d80` never
|
||||
/// reads the kit's flags.
|
||||
fn creature_body_loops(
|
||||
units: Query<(
|
||||
Entity,
|
||||
@@ -461,8 +361,7 @@ fn creature_body_loops(
|
||||
Option<&MountChild>,
|
||||
)>,
|
||||
mounts: Query<&NetEntity, With<MountBody>>,
|
||||
// The loop kit this system last armed per unit — a mount transition swaps the resolved
|
||||
// kit, and the superseded loop must stop (the liveness latch is per (source, kit)).
|
||||
// The kit last armed per unit: a mount swap changes it, and the old loop must stop.
|
||||
mut armed: Local<EntityHashMap<u32>>,
|
||||
voices: Option<Res<CreatureVoices>>,
|
||||
kits: Option<ResMut<SoundKits>>,
|
||||
@@ -479,9 +378,7 @@ fn creature_body_loops(
|
||||
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
|
||||
continue;
|
||||
}
|
||||
// The mounted redirect (`0x60c480` — `+0xb44 ?: +0xb40`; 0441 fold-back): the loop
|
||||
// column reads the mount-preferred
|
||||
// row — a mounted mechanostrider's engine hums; dismounting swaps back to the rider's.
|
||||
// The mount-preferred row (`0x60c480`: `+0xb44 ?: +0xb40`): a mechanostrider's engine.
|
||||
let display = mount_child
|
||||
.and_then(|mc| mounts.get(mc.0).ok())
|
||||
.and_then(|m| m.display_id)
|
||||
@@ -490,13 +387,11 @@ fn creature_body_loops(
|
||||
.and_then(|d| voices.0.for_display(d))
|
||||
.map(|v| v.loop_sound)
|
||||
.unwrap_or(0);
|
||||
// `0x623800`'s own gate verbatim (`0x623817` health, `0x62381e` the flag): RAW health ≤ 0
|
||||
// — absent = 0, deliberately not `unit_is_dead`'s max-health guard, which would leave a
|
||||
// unit whose snapshot has not landed humming — or `UNIT_DYNFLAG_DEAD` set, the feign-death
|
||||
// bit the reference re-evaluates this very gate on (`0x60053c`, decision 1022).
|
||||
// `0x623800`'s gate: raw health (`0x623817`), absent read as 0 so a unit awaiting its
|
||||
// snapshot stays silent, and `UNIT_DYNFLAG_DEAD` (`0x62381e`, re-evaluated at `0x60053c`).
|
||||
let alive = store.0.unit_health().unwrap_or(0) > 0 && !store.0.unit_reads_dead();
|
||||
let desired = if alive { kit } else { 0 };
|
||||
// Stop a superseded loop first: death, or a mount transition that changed the row.
|
||||
// Stop a superseded loop: death, or a mount swap that changed the row.
|
||||
if let Some(&prev) = armed.get(&entity) {
|
||||
if prev != desired && source_kit_playing(&out, entity, prev) {
|
||||
stop_source_kit(&mut out, entity, prev);
|
||||
@@ -508,8 +403,7 @@ fn creature_body_loops(
|
||||
}
|
||||
armed.insert(entity, desired);
|
||||
if !source_kit_playing(&out, entity, desired) {
|
||||
// Out of range this returns without allocating a channel — the next frame retries,
|
||||
// which is exactly the re-arm-on-audible the kit player asks its triggers for.
|
||||
// Out of range this allocates nothing, and the next frame retries.
|
||||
if let Err(e) = play_kit_ext(
|
||||
&mut kits,
|
||||
&assets,
|
||||
@@ -531,43 +425,23 @@ fn creature_body_loops(
|
||||
}
|
||||
}
|
||||
|
||||
/// Route the CreatureSoundData anim tags: fidgets, stand, wing flap/glide. (The `$AH0–3`
|
||||
/// custom-attack columns are NOT free-standing anim vocals — they are the swing dispatch's
|
||||
/// natural-weapon impact sound, record-gated and latch-exclusive with the generic weapon
|
||||
/// impact; [`super::combat`] plays them off [`crate::creature_anim::SwingImpact`], decision
|
||||
/// 0525.)
|
||||
/// A mounted unit's fidgets are the MOUNT model's own tags, fired by the mount CHILD entity
|
||||
/// carrying the mount's display — so they already resolve the mount's voice row, the split-
|
||||
/// entity shape of the client's mounted redirect (`0x60c480`: `+0xb44 ?: +0xb40` — 0441
|
||||
/// fold-back).
|
||||
/// Routes the CreatureSoundData anim tags. A mounted unit's tags fire on the mount child, which
|
||||
/// carries the mount's display, so they resolve the mount's voice (the reference's `0x60c480`).
|
||||
///
|
||||
/// **The three tag families gate differently, and the difference is byte-exact** (decision 1401):
|
||||
///
|
||||
/// - **`$FD1..$FD4` → fidget 1..4, ungated.** `0x6232c0` → `0x623440` → `0x6230a0` reads
|
||||
/// `row[+0x38 + 4*(n-1)]` literally and touches no gate at all. A zero slot bails silently, with
|
||||
/// no fallback to another column.
|
||||
/// - **`$WNG`/`$WGG` → wing flap/glide, also ungated — but by *constant*, not by construction.**
|
||||
/// Both take the class-bark route (`0x5fff9c` pushes class 7, `0x5fff78` class 10, into
|
||||
/// `[vtable+0x88]`), so they *are* rolled — against thresholds of **100**, which the inclusive
|
||||
/// compare makes a tautology. They sit outside the mute guard `{0,1,2,3,8}` and run on the
|
||||
/// uncapped bus. Playing them unconditionally is faithful; it is worth knowing that it is
|
||||
/// faithful for a reason living in a table rather than in the control flow.
|
||||
/// - **`$FDX` → the stand vocal, gated three times over**, which is what this function had wrong:
|
||||
/// the local-player skip (`0x6236c2` — you never hear your own), then a **40.6 %** chance roll
|
||||
/// ([`STAND_CHANCE`]), then a **10 s cooldown on one world-global timestamp**
|
||||
/// ([`STAND_COOLDOWN`]). Ungated, a crocodile croaks every 4 s forever: `Crocodile.m2` keys
|
||||
/// `$FDX` at t=0.000 of a 4.000 s **loop-flag** Stand variation carrying 45 % of the variation
|
||||
/// weight, and a t=0 key re-fires on every wrap. Row 136's stand column is kit 571
|
||||
/// `BasiliskStand2`, so it is audible — and it is the only model in 5875 that authors `$FDX`
|
||||
/// *and* reaches a nonzero stand column (censused across all 20 models that reach one of the 19
|
||||
/// rows carrying a stand kit).
|
||||
/// - `$FD1..$FD4`: fidget columns 1 to 4, ungated; `0x6232c0` → `0x623440` → `0x6230a0` reads
|
||||
/// `row[+0x38 + 4*(n-1)]`, and a zero column is silent. `$FD5..$FD9` are silent in the
|
||||
/// reference too, which reads only four fidget columns. The reference plays these and `$FDX`
|
||||
/// at the unit's origin + 2.0 z (`0x6232c0`); here they play at the origin.
|
||||
/// - `$WNG`/`$WGG`: wing flap and glide, on the class route (`0x5fff9c` class 7, `0x5fff78` class
|
||||
/// 10) against a threshold of 100, so always played, outside the mute set {0, 1, 2, 3, 8}.
|
||||
/// - `$FDX`: the stand vocal, never the local player's (`0x6236c2`), then a [`STAND_CHANCE`] roll,
|
||||
/// then a [`STAND_COOLDOWN`] on one world-wide timestamp. `Crocodile.m2` keys it at t=0 of a
|
||||
/// looping Stand, so ungated it would croak every 4 s.
|
||||
fn creature_anim_vocals(
|
||||
mut events: MessageReader<AnimSoundEvent>,
|
||||
// GlobalTransform: the mount child's local Transform is the seat-relative ~origin — world
|
||||
// position is the only correct read for both parented and top-level sources.
|
||||
// GlobalTransform: the mount child's local Transform is seat-relative.
|
||||
units: Query<(&NetEntity, &GlobalTransform, Has<SelfPlayer>)>,
|
||||
// The class-5 window. A `Local` is exactly one instance, which is the point: the reference's
|
||||
// `[0xc4e0e4]` is a single timestamp for the whole world, not one per unit.
|
||||
// The class-5 window: one timestamp for the whole world, the reference's `[0xc4e0e4]`.
|
||||
mut stand_window: Local<Option<std::time::Duration>>,
|
||||
time: Res<Time>,
|
||||
voices: Option<Res<CreatureVoices>>,
|
||||
@@ -592,13 +466,11 @@ fn creature_anim_vocals(
|
||||
let Some(voice) = net.display_id.and_then(|d| voices.0.for_display(d)) else {
|
||||
continue;
|
||||
};
|
||||
// `$FDX`'s three gates, in the reference's own order. All of them run BEFORE the
|
||||
// column-is-zero bail below, and the window is stamped on any *allowed* attempt — so a
|
||||
// silent or distance-culled stand vocal burns the ten seconds for every creature in the
|
||||
// world, exactly as `0x623290` does.
|
||||
// `$FDX`'s gates run before the zero-column bail, and any allowed attempt stamps the
|
||||
// window, even a silent or culled one (`0x623290`).
|
||||
if ev.ident == *b"$FDX" {
|
||||
if is_self {
|
||||
continue; // `0x6236c2`: you never hear your own stand vocal
|
||||
continue; // `0x6236c2`
|
||||
}
|
||||
if !bark_chance_pass(STAND_CHANCE, kits.roll()) {
|
||||
continue;
|
||||
@@ -636,15 +508,14 @@ fn creature_anim_vocals(
|
||||
}
|
||||
}
|
||||
|
||||
/// Registration hook for [`super::SoundPlugin`].
|
||||
/// The **fall-landing wound vocal** — the client-side hard-landing predictor's sound leg
|
||||
/// (`0x602d00 → call [vtable+0x88] class 2` at `0x602d84`; decision 0412): a landing past the HARD
|
||||
/// threshold plays the unit's ordinary CreatureSoundData **wound vocal, normal row** (class 2 →
|
||||
/// column `+0xc` = `injury[0]` — the same row a landed melee hit voices; the crit/crushing rows
|
||||
/// are unused here). NOT wire-driven: the server's `SMSG_ENVIRONMENTALDAMAGELOG` set plays no
|
||||
/// sound at all — the grunt is predicted at the landing frame off the client's own fall height.
|
||||
/// The dust leg of the same predictor lives in `creature_anim::env_damage`; both gate on the
|
||||
/// shared [`crate::creature_anim::HARD_LANDING_DESCENT`].
|
||||
/// The hard-landing grunt: the reference's landing predictor `0x602d00` voices a landing past the
|
||||
/// hard threshold with the class-2 wound vocal (`0x602d84`, column `+0xc`, `injury[0]`), predicted
|
||||
/// client-side; `SMSG_ENVIRONMENTALDAMAGELOG` plays nothing. The dust in
|
||||
/// `creature_anim::env_damage` shares [`crate::creature_anim::HARD_LANDING_DESCENT`].
|
||||
///
|
||||
/// This gates on fall height alone. The reference also sends a mover with SAFE_FALL, SWIMMING or
|
||||
/// FIXED_Z (`0x602d48`) to the soft landing at every height, and a dead or ghost unit
|
||||
/// (`0x605f30`, `0x602d71`) in the 13-70 yd band.
|
||||
fn fall_landing_vocals(
|
||||
mut landings: MessageReader<crate::creature_anim::HardLanding>,
|
||||
units: Query<(&NetEntity, &Transform)>,
|
||||
@@ -692,6 +563,7 @@ fn fall_landing_vocals(
|
||||
}
|
||||
}
|
||||
|
||||
/// Registration hook for [`super::SoundPlugin`].
|
||||
pub(super) fn plugin(app: &mut App) {
|
||||
app.add_systems(Startup, load_creature_voices.after(AssetSet::Open))
|
||||
.add_systems(
|
||||
@@ -714,13 +586,8 @@ mod tests {
|
||||
use super::super::kit::occupies_voice_slot;
|
||||
use super::*;
|
||||
|
||||
/// **The measured report.** Every `SMSG_AI_REACTION` HOSTILE arrival for the director's bear
|
||||
/// pet in one traced session (2026-08-17, `RUST_LOG=benilla_app::sound=debug`), in seconds
|
||||
/// from the first: 63 of them inside 19 s — three inside a single frame, then a ~1.53 s
|
||||
/// metronome, then two ~10 Hz runs. vmangos sends them that fast by design (`Unit::Attack`
|
||||
/// fires one on every target acquisition, `Unit::SendPetAIReaction` one on every pet autocast
|
||||
/// and every self-picked target — `PetAI::DoAttack` and `PetAI::UpdateAI`'s autocast leg), so
|
||||
/// this is the traffic the client is *supposed* to survive.
|
||||
/// Every `SMSG_AI_REACTION` HOSTILE arrival for a bear pet in one traced session, in seconds
|
||||
/// from the first: 63 inside 19 s, the rate vmangos sends by design.
|
||||
const BEAR_BURST_S: [f32; 63] = [
|
||||
0.000, 0.000, 0.000, 1.417, 2.950, 4.481, 5.997, 7.530, 9.046, 10.589, 10.670, 10.792,
|
||||
12.312, 12.413, 12.546, 12.603, 12.713, 12.813, 12.912, 13.013, 13.112, 13.214, 13.315,
|
||||
@@ -730,14 +597,11 @@ mod tests {
|
||||
18.340, 18.418, 18.553, 18.623, 18.728, 18.827, 18.928,
|
||||
];
|
||||
|
||||
/// Kit 478 `BearAggro` has exactly one variation, `mBearAggroA.wav` — **3.166 s** at
|
||||
/// 22 050 Hz, measured off the install. Long enough that the burst above stacks up to some
|
||||
/// thirty copies of one roar over each other, which is what the director heard as phasing.
|
||||
/// Kit 478 `BearAggro`'s one variation, `mBearAggroA.wav`: 3.166 s at 22 050 Hz.
|
||||
const BEAR_AGGRO_S: f32 = 3.166;
|
||||
|
||||
/// Replay a burst through the `[unit+0xb20]` slot and return the arrivals that actually
|
||||
/// sounded. The slot holds at most one live channel; the pump reaps it when the sound stops,
|
||||
/// and that reap IS the handle release.
|
||||
/// Replays a burst through the `[unit+0xb20]` slot and returns the arrivals that sound; the
|
||||
/// slot frees when its sound ends.
|
||||
fn barks_that_sound(arrivals: &[f32], clip: f32) -> Vec<f32> {
|
||||
let bear = Entity::from_raw_u32(1).expect("valid entity id");
|
||||
let mut slot: Option<(Option<Entity>, Latch, f32)> = None;
|
||||
@@ -747,7 +611,7 @@ mod tests {
|
||||
slot = None;
|
||||
}
|
||||
if slot.is_some_and(|(src, l, _)| occupies_voice_slot(src, l, bear)) {
|
||||
continue; // category 0 is the lowest — a live bark wins, this one is dropped
|
||||
continue; // category 0 is the lowest: a live bark wins
|
||||
}
|
||||
slot = Some((Some(bear), Latch::Voice(BARK_AGGRO), t + clip));
|
||||
sounded.push(t);
|
||||
@@ -755,9 +619,7 @@ mod tests {
|
||||
sounded
|
||||
}
|
||||
|
||||
/// **The bug and the fix, on the measured data** (decision 1399). Ungated — what benilla
|
||||
/// did — every arrival sounds: 63 overlapping 3.166 s roars. Through the voice slot the same
|
||||
/// burst is five barks, no two of them overlapping.
|
||||
/// Through the voice slot the measured burst is five barks, none overlapping.
|
||||
#[test]
|
||||
fn the_voice_slot_collapses_the_measured_bear_burst() {
|
||||
assert_eq!(
|
||||
@@ -780,8 +642,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The slot is **per unit**, not global: a second bear beside the first keeps its own voice.
|
||||
/// A world-wide throttle would have collapsed the burst too, and would have been wrong.
|
||||
/// The slot is per unit: a second bear keeps its own voice.
|
||||
#[test]
|
||||
fn the_voice_slot_is_per_unit() {
|
||||
let (a, b) = (
|
||||
@@ -793,27 +654,22 @@ mod tests {
|
||||
assert!(!occupies_voice_slot(barking.0, barking.1, b));
|
||||
}
|
||||
|
||||
/// **The priority rule, at every boundary the reference's two jumps and one `jle` draw**
|
||||
/// (decision 2039). The equal case is the one that matters most: it is what collapses a burst
|
||||
/// of identical barks to one, and it is why the aggro flood of 1399 stays collapsed now that
|
||||
/// the slot carries a state instead of a bare bool.
|
||||
/// The priority rule at each boundary; refusing an equal state is what collapses a burst.
|
||||
#[test]
|
||||
fn a_held_bark_admits_only_a_strictly_higher_state() {
|
||||
// A free slot admits everything, including the lowest state.
|
||||
for state in [BARK_AGGRO, BARK_PET_ORDER, BARK_PET_ATTACK, BARK_DEATH] {
|
||||
assert!(bark_admitted(None, state), "free slot, state {state}");
|
||||
}
|
||||
// Equal is refused — `jle`, not `jl`.
|
||||
// Equal is refused: `jle`, not `jl`.
|
||||
for state in [BARK_AGGRO, BARK_PET_ORDER, BARK_PET_ATTACK, BARK_DEATH] {
|
||||
assert!(!bark_admitted(Some(state), state), "equal, state {state}");
|
||||
}
|
||||
// A pet's ATTACK bark cuts through its own aggro flare and its own order bark — the whole
|
||||
// reason the state had to become a payload: a hunter's pet draws aggro flares in bursts
|
||||
// (1399), and under a bare liveness gate the pet's own voice would never be heard.
|
||||
// A pet's attack bark cuts through its own aggro flare and order bark.
|
||||
assert!(bark_admitted(Some(BARK_AGGRO), BARK_PET_ORDER));
|
||||
assert!(bark_admitted(Some(BARK_AGGRO), BARK_PET_ATTACK));
|
||||
assert!(bark_admitted(Some(BARK_PET_ORDER), BARK_PET_ATTACK));
|
||||
// ...and nothing cuts through the death cry, which is the table's maximum.
|
||||
// Nothing cuts through the death cry, the table's maximum.
|
||||
assert!(bark_admitted(Some(BARK_PET_ATTACK), BARK_DEATH));
|
||||
for state in [BARK_AGGRO, BARK_PET_ORDER, BARK_PET_ATTACK] {
|
||||
assert!(
|
||||
@@ -821,12 +677,11 @@ mod tests {
|
||||
"over death: {state}"
|
||||
);
|
||||
}
|
||||
// The order bark does NOT interrupt an attack bark — the two pet states are ordered, and
|
||||
// the wire sends them close together.
|
||||
// The order bark does not interrupt an attack bark.
|
||||
assert!(!bark_admitted(Some(BARK_PET_ATTACK), BARK_PET_ORDER));
|
||||
}
|
||||
|
||||
/// The five-arm jump table, column for column — and its two silences.
|
||||
/// The five-arm jump table, column for column, and its two silences.
|
||||
#[test]
|
||||
fn the_bark_table_maps_each_state_to_its_column() {
|
||||
let voice = benilla_formats::CreatureVoice {
|
||||
@@ -854,10 +709,8 @@ mod tests {
|
||||
assert_eq!(bark_kit(&voice, BARK_PET_ORDER), 9098);
|
||||
assert_eq!(bark_kit(&voice, BARK_PET_ATTACK), 9097);
|
||||
assert_eq!(bark_kit(&voice, BARK_DEATH), 314);
|
||||
// State 3 is a real arm of the table that reads no column, and everything past 4 is the
|
||||
// range check's default. Both play nothing — and neither is `alert` (col 13) nor
|
||||
// `pet_dismiss` (col 29): those two reach their kits through entirely different
|
||||
// functions, off this table altogether.
|
||||
// State 3 reads no column and a state past 4 is the range check's default; `alert`
|
||||
// (column 13) and `pet_dismiss` (column 29) are reached off this table.
|
||||
assert_eq!(bark_kit(&voice, 3), 0);
|
||||
assert_eq!(bark_kit(&voice, 5), 0);
|
||||
assert_eq!(bark_kit(&voice, 255), 0);
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
//! **The death thud** — the body-fall impact as a corpse lands, fired on the `$DTH` animation
|
||||
//! event (`0x6236e0`). The *other* half of the same event: its camera shake is
|
||||
//! [`crate::camera_shake`]'s (`0x625c30`), and the two are siblings, not one system — the shake
|
||||
//! is authored per model in `CreatureModelData` and only 49 of the 430 shipped models carry one,
|
||||
//! while the **sound is universal**: **329 of the 407 creature-model files the archives hold** key
|
||||
//! a `$DTH` (`benilla-extract thudcensus`; 430 rows collapse to 410 distinct paths, 407 of which
|
||||
//! ship), and only the *sample* scales with the body. **Players included** — every character model keys one,
|
||||
//! and `[unit+0xb34]` is filled from `UNIT_FIELD_DISPLAYID` for a player exactly as for a creature
|
||||
//! (`0x60afb0`), so your own body lands audibly too.
|
||||
//! The death thud: the body-fall sound as a corpse lands, on the `$DTH` event (`0x6236e0`); its
|
||||
//! camera shake is [`crate::camera_shake`]'s (`0x625c30`). Every model that keys `$DTH` thuds,
|
||||
//! players included (`[unit+0xb34]` holds a player's display too, `0x60afb0`); only the sample
|
||||
//! scales with the body.
|
||||
//!
|
||||
//! ```text
|
||||
//! $DTH → sizeClass = CreatureDisplayInfo.SizeClass ?? CreatureModelData.SizeClass
|
||||
@@ -14,60 +9,31 @@
|
||||
//! DeathThudLookups[sizeClass][terrainSound] → SoundEntries (land | water)
|
||||
//! ```
|
||||
//!
|
||||
//! The terrain leg is **exactly the footstep sound's** ([`super::footsteps`]) — the reference
|
||||
//! keeps one cached terrain-type dword per unit (`CGUnit+0xc60`) and the `$DTH` handler reads that
|
||||
//! same dword at `0x623749`, as the `$FSD` handler does at `0x62341d`. `None` (the client's `−1`)
|
||||
//! is silence here too, never "ask the other leg".
|
||||
//! The terrain is the footstep's ([`super::footsteps`]): one cached terrain dword per unit
|
||||
//! (`CGUnit+0xc60`), read by `$DTH` at `0x623749` and by `$FSD` at `0x62341d`. `None` (the
|
||||
//! reference's -1) is silence.
|
||||
//!
|
||||
//! ## Indoors is silent, and that is the data
|
||||
//! Most indoor floors are silent in the reference too: a WMO surface with no material is
|
||||
//! `TerrainType 10 "None"`, `SoundID` 0, which `FootstepTerrainLookup` has a row for and
|
||||
//! `DeathThudLookups` does not. That is 97.8 % of shipped WMO materials; stone, metal and wood
|
||||
//! floors (Stormwind, Ironforge, most dungeons) thud.
|
||||
//!
|
||||
//! The two consumers share the terrain id and then **diverge on it**, which is the single most
|
||||
//! surprising thing about this system. Both tables are keyed on `TerrainType.SoundID`, and
|
||||
//! `TerrainType 10 "None"` — the id a WMO surface takes when its `MOMT+0x20` says nothing — has
|
||||
//! `SoundID = 0`. `FootstepTerrainLookup` carries a row at terrain sound **0** for 17 footstep
|
||||
//! classes; `DeathThudLookups` carries **none at all**. So the same floor that creaks underfoot
|
||||
//! swallows the body that lands on it.
|
||||
//! The handler's gates, in order (`0x6236e0`); there is no hover, stealth, ghost, CVar or distance
|
||||
//! gate:
|
||||
//!
|
||||
//! That is not a rounding error in the content: `benilla-extract thudcensus` measures **10 075 of
|
||||
//! the 10 299 shipped MOMT materials (97.8 %) at `TerrainType 10`**, and **694 of the 815 WMO
|
||||
//! roots have no surface that can thud at all** — every inn and tavern in the game among them.
|
||||
//! What *does* thud indoors is the blacksmiths, barns, barracks, chapels, Stormwind, Ironforge and
|
||||
//! most dungeon sets, whose floors carry a real Stone/Metallic/Wood material.
|
||||
//! 1. In liquid more than 2.0 yd over the feet (`0x62372a`): silent, not the land kit.
|
||||
//! 2. Size class outside `0..=4` (`0x623744`, unsigned, so a -1 lands here): silent.
|
||||
//! 3. No terrain, no lookup row or a kit of 0: silent.
|
||||
//!
|
||||
//! So "a big corpse hit the ground silently *indoors*" is the **reference's own behaviour**, not a
|
||||
//! gap — and the retest for this system has to be run outdoors, or in one of the 121 buildings the
|
||||
//! census names.
|
||||
//! Deviation: the water column is chosen on liquid above the feet (`depth > 0`, the footstep
|
||||
//! splash's test), because the reference's test, the flag saying liquid was found at the unit's
|
||||
//! sampled position (`[node+0x90] & 0x20`, `0x670630`), has no depth and splashes a body on a
|
||||
//! lake's shore.
|
||||
//!
|
||||
//! **Three gates, and no others.** The handler's early-returns in order (`0x6236e0`):
|
||||
//!
|
||||
//! 1. **In liquid, and more than `2.0` yd of it over the corpse's feet** (`0x62372a`, the float
|
||||
//! at `0x801628` — the same dword the armor foley lifts its emitter by) ⇒ **completely
|
||||
//! silent**. Not "play the land one": a body that sinks makes no sound at all.
|
||||
//! 2. **Size class outside `0..=4`** (`0x623744`, an *unsigned* `>= 5`, so a `−1` from both DBC
|
||||
//! rows lands here) ⇒ silent. No shipped display reaches it.
|
||||
//! 3. **No terrain, or no lookup row** ⇒ silent, and a resolved kit of `0` is silence too.
|
||||
//!
|
||||
//! And that is the whole list. Unlike the footstep it does **not** gate on hover, stealth,
|
||||
//! player-ghost, a CVar or distance — the same "no gates at all" the sibling camera shake has.
|
||||
//! Nor does it walk to a root unit: a `$DTH` is a corpse's own collapse, and nothing rides a
|
||||
//! dying mount.
|
||||
//!
|
||||
//! **One deliberate deviation, on gate 1's *other* half.** The reference picks the water column
|
||||
//! on "the unit's liquid node reports liquid" alone (`[node+0x90] & 0x20`, `0x670630`) — a flag
|
||||
//! with no depth test of its own, so a body lying on a beach beside a lake would splash. benilla
|
||||
//! asks the question the footstep splash already asks: is the surface **above the feet**
|
||||
//! (`depth > 0`). Same answer wherever the reference is right, and the shore case is the one
|
||||
//! place they differ. (`0x670630`'s own semantics are still open; if the node flag turns out
|
||||
//! to already mean "submerged", the two collapse into one.)
|
||||
//!
|
||||
//! The play is `0x458870` — **bus 0, uncapped** (`0x458880 xor ecx,ecx`; cap `0x7FFFFFFF`), 3D at
|
||||
//! the unit's own position origin (its feet, `vtbl+0x14`), with no Z lift. That is the same call
|
||||
//! the armor foley makes, and deliberately *not* the capped [`Bus::FOOTSTEP`] the step itself
|
||||
//! takes: a battlefield's worth of bodies all land. The `1.0` it passes is a **multiplier** on the
|
||||
//! kit's authored `SoundEntries.Volume`, not the final gain — the shipped rows carry 0.4 for
|
||||
//! Small/Medium, 1.0 for Large/Giant/Colossal, 0.3/0.6 in water — and the `-1` beside it is a
|
||||
//! **file-variation index** (`variant != -1`), not a channel. Every `DeathThud*` row sets flag
|
||||
//! `0x400`, so the kit player's own ±15 % pitch draw applies (`0x458da0`, [`super::math`]).
|
||||
//! The play is `0x458870`: bus 0, uncapped (`0x458880`), at the unit's feet with no lift, not the
|
||||
//! capped footstep bus. Its `1.0` multiplies the kit's `SoundEntries.Volume`, and its `-1` is a
|
||||
//! file-variation index. Every `DeathThud*` row sets flag `0x400`, the ±15 % pitch draw
|
||||
//! (`0x458da0`, [`super::math`]).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -84,16 +50,12 @@ use super::footsteps::Footsteps;
|
||||
use super::kit::{play_kit_ext, KitRef, PlayExtras, SoundCategory, SoundKits};
|
||||
use super::{AudioListener, SoundConfig, SoundOutput};
|
||||
|
||||
/// **The depth that silences the thud** — `0x62372a fcomp [0x801628]`, the float `2.0`. Read as
|
||||
/// `surfaceZ − feetZ`, the same depth the swim decision (`0x6030c0`) measures, so this is "two
|
||||
/// yards of water over the body's feet", not two yards of body under the surface.
|
||||
/// The liquid depth over the feet that silences the thud (`0x62372a fcomp [0x801628]`), measured
|
||||
/// `surfaceZ − feetZ` as the swim decision `0x6030c0` does.
|
||||
const DROWNED_DEPTH: f32 = 2.0;
|
||||
|
||||
/// **The whole decision**, given what the world has already answered: the drowned gate, the
|
||||
/// `TerrainType.SoundID` hop, and the join. `depth` is `surfaceZ − feetZ` and `None` when the
|
||||
/// corpse is not in liquid — the same shape [`super::footsteps`] wades on.
|
||||
///
|
||||
/// Pure, so the three gates can be exercised against the real tables without a running client.
|
||||
/// The drowned gate, the `TerrainType.SoundID` hop and the lookup; `depth` is `surfaceZ − feetZ`,
|
||||
/// `None` out of liquid.
|
||||
fn pick_kit(
|
||||
thuds: &DeathThudCatalog,
|
||||
steps: &FootstepCatalog,
|
||||
@@ -102,10 +64,10 @@ fn pick_kit(
|
||||
depth: Option<f32>,
|
||||
) -> Option<u32> {
|
||||
if depth.is_some_and(|d| d > DROWNED_DEPTH) {
|
||||
return None; // gate 1 — the body sank
|
||||
return None; // gate 1: the body sank
|
||||
}
|
||||
let terrain_sound = steps.sound_class_of(terrain)?; // gate 3a — no such TerrainType row
|
||||
thuds.kit(size_class, terrain_sound, depth.is_some()) // gate 3b — no row, or a kit of 0
|
||||
let terrain_sound = steps.sound_class_of(terrain)?; // gate 3a: no TerrainType row
|
||||
thuds.kit(size_class, terrain_sound, depth.is_some()) // gate 3b: no row, or a kit of 0
|
||||
}
|
||||
|
||||
/// `DeathThudLookups.dbc` + the `TerrainTypeSounds.dbc` domain, loaded once.
|
||||
@@ -129,12 +91,10 @@ fn load_death_thuds(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
|
||||
|
||||
fn death_thud_sounds(
|
||||
mut events: MessageReader<AnimSoundEvent>,
|
||||
// GlobalTransform for the same reason every other anim-event consumer takes one: the tag can
|
||||
// arrive on a parented child whose local Transform is not a world position (0441).
|
||||
// GlobalTransform: the tag can arrive on a parented child.
|
||||
units: Query<(&NetEntity, &GlobalTransform)>,
|
||||
thuds: Option<Res<DeathThuds>>,
|
||||
// The terrain leg's own catalog — `world.terrain_type` resolves the ground-effect hop through
|
||||
// it, and `sound_class_of` is the `TerrainType.SoundID` step the reference does inline.
|
||||
// The terrain catalog; `sound_class_of` is the `TerrainType.SoundID` step.
|
||||
footsteps: Option<Res<Footsteps>>,
|
||||
creatures: Option<Res<Creatures>>,
|
||||
world: benilla_world::world_point::WorldPoint,
|
||||
@@ -160,29 +120,27 @@ fn death_thud_sounds(
|
||||
let Ok((net, transform)) = units.get(ev.entity) else {
|
||||
continue;
|
||||
};
|
||||
// Gate 2 — the size class. The display's column overrides the model's; both `−1` (or a
|
||||
// missing row) is silence, as is anything past Colossal.
|
||||
// Gate 2: the display's size class overrides the model's; -1 in both, or past Colossal,
|
||||
// is silent.
|
||||
let Some(size_class) = net.display_id.and_then(|d| creatures.size_class(d)) else {
|
||||
continue;
|
||||
};
|
||||
// The liquid over the body (gate 1's input). `water_surface_at` is the unit's own room
|
||||
// claim (0696), so a corpse on an indoor floor under an ADT lake is not "in water".
|
||||
// Gate 1's input, on the unit's own room claim, so a corpse on an indoor floor under an
|
||||
// ADT lake is not in water.
|
||||
let who = benilla_world::world_point::Subject::Unit(ev.entity);
|
||||
let wow = bevy_to_wow(transform.translation());
|
||||
let depth = world
|
||||
.water_surface_at(who, wow)
|
||||
.map(|s| s - wow[2])
|
||||
.filter(|d| *d > 0.0);
|
||||
// Gate 3's input — the terrain leg. `None` is the client's `−1`: silent, and never a
|
||||
// reason to fall back to the ground beneath a floor.
|
||||
// Gate 3's input; `None` is the reference's -1: silent, never the ground beneath a floor.
|
||||
let Some(terrain) = world.terrain_type(&footsteps.0, who, transform.translation()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(kit) = pick_kit(&thuds.0, &footsteps.0, size_class, terrain, depth) else {
|
||||
continue;
|
||||
};
|
||||
// Which leg answered and what it said — the same triage line the footstep prints, because
|
||||
// the wrong-surface family is invisible in the kit name alone once two legs can produce one.
|
||||
// Which surface answered, as the footstep logs it.
|
||||
debug!(
|
||||
"death thud: {} terrain {terrain} size {size_class}{} kit {kit}",
|
||||
world
|
||||
@@ -199,7 +157,7 @@ fn death_thud_sounds(
|
||||
KitRef::Id(kit),
|
||||
Some(transform.translation()),
|
||||
SoundCategory::Sfx,
|
||||
PlayExtras::default(), // bus 0, uncapped, volume 1.0 — `0x458870`'s own
|
||||
PlayExtras::default(), // bus 0, uncapped, volume 1.0: `0x458870`'s
|
||||
) {
|
||||
warn!("death thud (kit {kit}): {e:#}");
|
||||
}
|
||||
@@ -216,11 +174,9 @@ pub(super) fn plugin(app: &mut App) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The three gates on the real shipped tables — the join a corpse actually walks.
|
||||
///
|
||||
/// Anchors, all read off `benilla-extract thudcensus`: `TerrainType 5` is Grass, whose
|
||||
/// `SoundID` is 6; `TerrainType 4` is Wood; `TerrainType 1` is Metallic (`SoundID` 2), the
|
||||
/// row whose water column is 0; `TerrainType 10` is the unauthored `"None"`, `SoundID` 0.
|
||||
/// The three gates on the shipped tables (`benilla-extract thudcensus`): `TerrainType` 5 is
|
||||
/// Grass (`SoundID` 6), 4 Wood, 1 Metallic (`SoundID` 2, water column 0), 10 `"None"`
|
||||
/// (`SoundID` 0).
|
||||
#[test]
|
||||
fn the_three_gates_on_real_tables() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
@@ -230,23 +186,22 @@ mod tests {
|
||||
let pick = |size, terrain, depth| pick_kit(&thuds, &steps, size, terrain, depth);
|
||||
|
||||
// Dry land: a Colossal body on grass → `DeathThudColossalGrass`; a Small one → the Small
|
||||
// kit. The size axis is the whole reason the sound is not one sample.
|
||||
// kit.
|
||||
assert_eq!(pick(4, 5, None), Some(928));
|
||||
assert_eq!(pick(0, 5, None), Some(907 + 1));
|
||||
|
||||
// Wading — up to and including 2.0 yd over the feet — takes the water column.
|
||||
// Wading, up to and including 2.0 yd over the feet, takes the water column.
|
||||
assert_eq!(pick(4, 5, Some(0.1)), Some(1269), "a splash, not a thud");
|
||||
assert_eq!(
|
||||
pick(4, 5, Some(DROWNED_DEPTH)),
|
||||
Some(1269),
|
||||
"exactly 2.0 still sounds"
|
||||
);
|
||||
// Deeper, the body sank: silent outright, NOT the land kit.
|
||||
// Deeper, the body sank: silent, not the land kit.
|
||||
assert_eq!(pick(4, 5, Some(2.01)), None);
|
||||
assert_eq!(pick(4, 5, Some(20.0)), None);
|
||||
|
||||
// A terrain whose water column is 0 is silent in water and audible out of it — the same
|
||||
// "0 is silence, never a fallback" the table half of the census prints as `—`.
|
||||
// A water column of 0 is silent in water, never a fallback to the land kit.
|
||||
assert_eq!(
|
||||
pick(0, 1, None),
|
||||
Some(910),
|
||||
@@ -254,22 +209,15 @@ mod tests {
|
||||
);
|
||||
assert_eq!(pick(0, 1, Some(0.5)), None, "and nothing in the water");
|
||||
|
||||
// `TerrainType "None"` (a WMO floor with no material) resolves to sound class 0, which is
|
||||
// not a `TerrainTypeSounds` row: silent, on any body.
|
||||
// `TerrainType "None"` resolves to sound class 0, not a `TerrainTypeSounds` row: silent.
|
||||
assert_eq!(pick(4, 10, None), None);
|
||||
// A terrain id off the table entirely, and a size class past Colossal.
|
||||
assert_eq!(pick(4, 99, None), None);
|
||||
assert_eq!(pick(5, 5, None), None);
|
||||
}
|
||||
|
||||
/// **Indoors is silent, and the footstep on the same floor is not** — the asymmetry in the
|
||||
/// module docs, pinned as an executable claim because it is the one a future reader will
|
||||
/// disbelieve and "fix".
|
||||
///
|
||||
/// `TerrainType 10 "None"` is 97.8 % of shipped WMO material, and it resolves to terrain sound
|
||||
/// `0`. `FootstepTerrainLookup` has a row there; `DeathThudLookups` has none. A building whose
|
||||
/// floor carries a real material (a blacksmith's stone, a barn's wood) thuds normally — that
|
||||
/// is the control, and it must not change.
|
||||
/// A floor with no material (`TerrainType 10`) takes a footstep but never a thud; a real
|
||||
/// material still thuds. This matches the reference.
|
||||
#[test]
|
||||
fn an_unmaterialed_floor_footsteps_but_never_thuds() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
@@ -277,7 +225,7 @@ mod tests {
|
||||
let thuds = benilla_formats::load_death_thud_catalog(&mut chain).expect("thud catalog");
|
||||
let steps = benilla_formats::load_footstep_catalog(&mut chain).expect("footstep catalog");
|
||||
|
||||
// The unmaterialed floor: a footstep, on every body, at any size.
|
||||
// The unmaterialed floor takes a footstep.
|
||||
assert_eq!(
|
||||
steps.resolve_terrain(7, 10).map(|(dry, _)| dry),
|
||||
Some(560),
|
||||
@@ -290,7 +238,7 @@ mod tests {
|
||||
"…and no size of body thuds on it"
|
||||
);
|
||||
}
|
||||
// The control — a floor with a real material still thuds, at both ends of the size axis.
|
||||
// The control: a real material thuds at both ends of the size axis.
|
||||
assert_eq!(
|
||||
pick_kit(&thuds, &steps, 3, 4, None),
|
||||
Some(926),
|
||||
|
||||
@@ -1,55 +1,12 @@
|
||||
//! The **ambient emitter pool** — the reference's registration table at `0xb06dd8` and its
|
||||
//! per-frame pump `0x461990`.
|
||||
//! The ambient emitter pool: the reference's table at `0xb06dd8` and its pump `0x461990`.
|
||||
//!
|
||||
//! **Two tenants share it, because the reference's one pool has two registrars** (decision 1867).
|
||||
//! A *placed doodad* registers through its `$DSL` marker (`0x6951e0`, handle in
|
||||
//! `[CMapDoodadDef+0x168]`); a *GameObject* registers through **both** its own `$DSL` arm
|
||||
//! (`0x5f3fe5`) and the display-slot sound player `0x5f4010`'s **loop lane** — the branch
|
||||
//! `SoundEntries` flag `0x200` selects — with the handle in `[handler+0x18]`, one per object. All
|
||||
//! of them call the same `0x461d80`/`0x461f80`/`0x462000` register/release/reposition family, so a
|
||||
//! campfire GameObject's `CampFireLargeLoop` and a placed brazier's share an entry and a channel
|
||||
//! exactly as two lamps do. The registrars differ only in how they treat a *second* id arriving on
|
||||
//! a live handle — see [`register`] and [`register_keeping_first`].
|
||||
//!
|
||||
//! B345's fix gave every world-placed doodad a clock and routed its `$DSL` key to audio, which is
|
||||
//! what made a lamp hum at all. It played that hum the obvious way — one channel per doodad, tagged
|
||||
//! to the doodad — and that is **not the shape the reference has**. The client does not voice
|
||||
//! doodads; it voices **sound ids**, and an emitter is only a *position* it can put one at:
|
||||
//!
|
||||
//! - `$DSL` registers the doodad's position as one **record** inside the pool entry that holds its
|
||||
//! SoundEntries id (`0x461d80`, handle `((entry+1) << 16) | record` kept in
|
||||
//! `[CMapDoodadDef+0x168]`). Every doodad naming the same id shares one entry.
|
||||
//! - Each entry runs **exactly one channel**, positioned at whichever of its records is **nearest
|
||||
//! the listener** (`0x461ca0`'s 256-wide argmin), repositioned as that changes and **never
|
||||
//! restarted** (`0x7a5b10`). One hum follows you down a row of thirty lamps.
|
||||
//! - The pump walks the 32 entries and lets the first **four** hold a channel (`0x4619c0 cmp
|
||||
//! edi,4`). Arbitration is by **ascending entry index — claim order, not distance**: a fifth
|
||||
//! distinct ambience is faded out over 3.0 s and does *not* take over when you walk toward it.
|
||||
//!
|
||||
//! So the three limits are structural, not a budget bolted on: **32 distinct ids**, **256 emitters
|
||||
//! per id**, **4 sounding at once**.
|
||||
//!
|
||||
//! **What we had instead**, measured rather than assumed (`benilla-extract soundeventscan`): a
|
||||
//! channel per doodad, parked on its own model and restarted every time you crossed its
|
||||
//! `DistanceCutoff`. The *count* was held down by two gates that were never about ambience —
|
||||
//! **45 of the 60** resolvable `$DSL` kits carry the reference's own `NO_DUPLICATES` bit (0x20),
|
||||
//! which benilla applied on every lane, so three-quarters of doodad ambience already played
|
||||
//! exactly one channel; the other 15 got up to two from `SAME_KIT_MAX`. One channel, yes — but at
|
||||
//! whichever doodad happened to fire first rather than the nearest, retriggering at every cutoff
|
||||
//! crossing, and with **nothing whatever** bounding the number of *distinct* ambiences, so a
|
||||
//! torch-and-campfire cluster could take a third of the 12-voice ceiling away from the sounds a
|
||||
//! player is actually listening for. Applying 0x20 here was itself wrong: it is the **one-shot**
|
||||
//! lane's suppressor (`0x458f40` → `0x7a66a0`), and this lane is now exempt from it
|
||||
//! ([`super::kit::PlayExtras::dedupe_exempt`]).
|
||||
//!
|
||||
//! **What the pool is not.** It is not a cache and not an optimisation — it is the mechanism that
|
||||
//! decides *where* a doodad ambience comes from and *which* ones you hear. Sharing a channel is
|
||||
//! what makes the emitter track the nearest lamp; the cap is what makes the fifth one silent.
|
||||
//!
|
||||
//! **Deliberate divergences**, both documented at their site: a failed kit is flagged in place
|
||||
//! rather than by negating the entry's id, and a channel the frame pump culls past the kit's own
|
||||
//! `DistanceCutoff` is stopped rather than left running at zero gain. Neither changes what is
|
||||
//! audible; see the notes on [`Entry::failed`] and [`pump_emitters`].
|
||||
//! The reference voices sound ids, not doodads. A placed doodad's `$DSL` (`0x6951e0`), a
|
||||
//! GameObject's `$DSL` (`0x5f3fe5`) and a GameObject's looping display slot (`0x5f4010`, the
|
||||
//! `SoundEntries` flag `0x200` lane) all register a position as one record in the entry holding
|
||||
//! that id (`0x461d80`/`0x461f80`/`0x462000`), one handle per owner. Each entry runs one channel
|
||||
//! at its record nearest the listener (`0x461ca0`), moved, never restarted (`0x7a5b10`). The first
|
||||
//! four entries by index that sound hold a channel (`0x4619c0`): claim order, not distance.
|
||||
//! Limits: 32 ids, 256 records per id, 4 sounding.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -65,64 +22,53 @@ use super::kit::{
|
||||
};
|
||||
use super::{math, AudioListener, SoundConfig, SoundOutput};
|
||||
|
||||
/// `0x461eac cmp ecx,0x1c200` over the entry stride `0xe10` — **32** entries, one per distinct
|
||||
/// SoundEntries id. A 33rd distinct id finds no match and no free entry, `0x461d80` returns handle
|
||||
/// 0, and that doodad is simply untracked until an entry frees.
|
||||
/// 32 entries, one per distinct SoundEntries id (`0x461eac`: `0x1c200` over stride `0xe10`); a
|
||||
/// 33rd id gets handle 0 from `0x461d80` and stays untracked until an entry frees.
|
||||
const POOL_ENTRIES: usize = 32;
|
||||
|
||||
/// `[entry+0xC00..+0xCFF]` — **256** emitter records per entry.
|
||||
/// 256 emitter records per entry (`[entry+0xC00..+0xCFF]`).
|
||||
const RECORDS_PER_ENTRY: usize = 256;
|
||||
|
||||
/// `0x4619c0 cmp edi,4` — at most **four** entries hold a channel at once.
|
||||
/// At most four entries hold a channel at once (`0x4619c0`).
|
||||
const PLAYING_CAP: usize = 4;
|
||||
|
||||
/// `0x7a5a10(3.0f)` — the fade a released or cap-evicted channel gets, in seconds.
|
||||
/// The fade a released or cap-evicted channel gets, in seconds (`0x7a5a10(3.0f)`).
|
||||
const FADE_SECS: f32 = 3.0;
|
||||
|
||||
/// The entity a pool entry's one channel rides. Its `Transform` is what [`kit::pump_channels`]'s
|
||||
/// tracked-follow reads, so moving it *is* the reference's reposition (`0x7a5b10`) — the same
|
||||
/// trick the liquid ambience loops use for their slewed emitter.
|
||||
/// The entity an entry's channel rides; moving its `Transform` is the reference's reposition
|
||||
/// (`0x7a5b10`) through [`kit::pump_channels`]'s tracked follow.
|
||||
#[derive(Component)]
|
||||
struct PoolEmitter;
|
||||
|
||||
/// One registered emitter: the reference's `[entry + 12·rec]` position, with presence in the list
|
||||
/// standing in for its `[+0xC00+rec]` active byte. `owner` is the entity holding the registration
|
||||
/// — a placed doodad's anim host, or a GameObject.
|
||||
/// One registered emitter (`[entry + 12·rec]`), held by a doodad's anim host or a GameObject.
|
||||
struct Record {
|
||||
owner: Entity,
|
||||
pos: Vec3,
|
||||
}
|
||||
|
||||
/// One pool entry — a SoundEntries id, its emitters, and the single channel they share.
|
||||
/// One pool entry: a SoundEntries id, its emitters and the one channel they share.
|
||||
#[derive(Default)]
|
||||
struct Entry {
|
||||
/// `[+0xE00]` — the SoundEntries id this entry holds; 0 = free.
|
||||
/// `[+0xE00]`, the SoundEntries id; 0 is free.
|
||||
id: u32,
|
||||
/// **Divergence.** The reference marks a kit that failed to resolve by *negating* the id
|
||||
/// (`[+0xE00] = -id`), which makes the entry stop matching its own id: the doodad's next
|
||||
/// `$DSL` then releases and re-registers into a fresh entry, every cycle, for ever. A separate
|
||||
/// flag keeps the doodad where it is. Both hold exactly one entry and both are silent, so
|
||||
/// nothing observable turns on it — but ours does not churn.
|
||||
/// Deviation: the reference marks an unresolvable kit by negating the id (`[+0xE00] = -id`),
|
||||
/// so the owner's next `$DSL` re-registers into a fresh entry every cycle; a flag keeps it in
|
||||
/// place without the churn. Both are silent.
|
||||
failed: bool,
|
||||
/// `[+0xC00]`'s active set, in claim order (which is what the 257th-record eviction rule
|
||||
/// below reads as "first").
|
||||
/// `[+0xC00]`'s active set, in claim order.
|
||||
records: Vec<Record>,
|
||||
/// `[+0xE04]`'s companion — the entity carrying this entry's channel, `None` when none is
|
||||
/// live.
|
||||
/// The entity carrying this entry's channel (`[+0xE04]`).
|
||||
voice: Option<Entity>,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
/// `0x461b40` steps 1–3: is there a channel coming to this entry at all? (Occupied, its kit
|
||||
/// resolvable, and at least one emitter registered.) Entitlement, not audibility — it decides
|
||||
/// whether the cap walk *services* the entry, never whether it *counts*; the composite return
|
||||
/// of steps 4–6 decides that (see [`cap_step`], decision 2065).
|
||||
/// `0x461b40` steps 1-3: occupied, resolvable, with an emitter. This decides whether the cap
|
||||
/// walk services the entry, not whether it counts ([`cap_step`]).
|
||||
fn entitled(&self) -> bool {
|
||||
self.id != 0 && !self.failed && !self.records.is_empty()
|
||||
}
|
||||
|
||||
/// `0x461ca0` — the position of the **nearest** active record, ties keeping the first
|
||||
/// (`0x461cf6` discards a candidate that is not *strictly* closer).
|
||||
/// The nearest active record (`0x461ca0`); a tie keeps the first (`0x461cf6`).
|
||||
fn nearest(&self, listener: Vec3) -> Option<Vec3> {
|
||||
self.records
|
||||
.iter()
|
||||
@@ -132,12 +78,8 @@ impl Entry {
|
||||
}
|
||||
}
|
||||
|
||||
/// A channel the pool has let go of and is fading out over [`FADE_SECS`].
|
||||
///
|
||||
/// It is an **orphan on purpose**: the reference starts the fade and clears `[+0xE04]` in the same
|
||||
/// breath (`0x461d20`, `0x4619c5`), so the entry is free to be reclaimed by another id — or to
|
||||
/// start a fresh channel of its own — while the old sound is still dying. Holding the fade on the
|
||||
/// entry would serialise those.
|
||||
/// A channel fading out over [`FADE_SECS`], detached from its entry: the reference clears
|
||||
/// `[+0xE04]` as the fade starts (`0x461d20`, `0x4619c5`), so the entry is free at once.
|
||||
struct Fading {
|
||||
emitter: Entity,
|
||||
kit: u32,
|
||||
@@ -148,25 +90,16 @@ struct Fading {
|
||||
#[derive(Resource)]
|
||||
pub(super) struct AmbientEmitterPool {
|
||||
entries: [Entry; POOL_ENTRIES],
|
||||
/// Which entry each owner is registered in — the reference's per-owner handle
|
||||
/// (`[CMapDoodadDef+0x168]` for a placed doodad, `[handler+0x18]` for a GameObject), minus the
|
||||
/// record half (a record *is* its owner here). One handle per owner is the reason a
|
||||
/// GameObject's `$DSL` and its looping display slot contend for the same registration, exactly
|
||||
/// as they do in the reference.
|
||||
/// Each owner's entry, the reference's per-owner handle (`[CMapDoodadDef+0x168]`,
|
||||
/// `[handler+0x18]`); one per owner, so a GameObject's `$DSL` and its looping display slot
|
||||
/// contend for it.
|
||||
handles: EntityHashMap<usize>,
|
||||
fading: Vec<Fading>,
|
||||
/// Kit ids already warned about. A `$DSL` re-fires every animation cycle, so an unresolvable
|
||||
/// id is a per-cycle failure on a per-doodad stream: the same log flood B345's routing half
|
||||
/// had to learn to avoid.
|
||||
/// Kit ids already warned about; a `$DSL` re-fires every animation cycle.
|
||||
complained: HashSet<u32>,
|
||||
/// The last logged census — `(kit id, is a channel actually live)` per admitted entry — so
|
||||
/// the line below reports **edges**, not frames. Liveness is in the key on purpose: a channel
|
||||
/// culled past its cutoff and restarted is exactly the churn a broken nearest-follow would
|
||||
/// produce, and the census is where that has to be visible.
|
||||
/// The last logged `(kit id, live)` census, so the log line reports edges only.
|
||||
last_census: Vec<(u32, bool)>,
|
||||
/// The last logged **withheld** set — the entitled ids past the cap. Part of the edge key for
|
||||
/// the same reason liveness is: *which* ambience the cap took away is the whole question a
|
||||
/// report of a missing layer asks, and the census could only ever say how many (2059).
|
||||
/// The last logged ids withheld by the cap, part of the same edge key.
|
||||
last_withheld: Vec<u32>,
|
||||
}
|
||||
|
||||
@@ -184,20 +117,10 @@ impl Default for AmbientEmitterPool {
|
||||
}
|
||||
|
||||
impl AmbientEmitterPool {
|
||||
/// The **compare-and-swap** registrar — the placed-doodad `$DSL` arm (`0x69521d`'s compare
|
||||
/// followed by `0x462000` reposition or `0x461f80`+`0x461d80` re-register), and, observably,
|
||||
/// the display-slot loop lane's arm too (`0x5f4083`: same id returns without even
|
||||
/// repositioning, which for a fixed emitter is the same thing).
|
||||
///
|
||||
/// Crossing the marker again with the **same** id only moves the record; that is the whole
|
||||
/// reason there is no wrap retrigger, and why `NightElfStreetLampLoop` (4.000 s of sample on a
|
||||
/// 3.333 s sequence) is not chopped every cycle. A **different** id releases the old
|
||||
/// registration and takes a new one, which is what makes `bellows.m2`'s `$DSL` pair —
|
||||
/// `BellowOut` at t=0.000, `BellowIN` at t=1.100 on one looping 2 s sequence — *alternate*
|
||||
/// through one slot instead of droning together.
|
||||
///
|
||||
/// The GameObject `$DSL` arm is the one that does **not** compare — see
|
||||
/// [`register_keeping_first`].
|
||||
/// The compare-and-swap registrar: a placed doodad's `$DSL` (`0x69521d`) and the display-slot
|
||||
/// loop lane (`0x5f4083`). The same id only moves the record, so a loop never retriggers on
|
||||
/// wrap; a different id releases and re-registers, so `bellows.m2`'s two `$DSL` ids alternate
|
||||
/// through one slot.
|
||||
fn register(&mut self, owner: Entity, id: u32, pos: Vec3, listener: Vec3) {
|
||||
if let Some(&e) = self.handles.get(&owner) {
|
||||
if self.entries[e].id == id {
|
||||
@@ -213,16 +136,15 @@ impl AmbientEmitterPool {
|
||||
.position(|x| x.id == id)
|
||||
.or_else(|| self.entries.iter().position(|x| x.id == 0))
|
||||
else {
|
||||
return; // all 32 entries hold other ids — untracked until one frees
|
||||
return; // all 32 entries hold other ids: untracked until one frees
|
||||
};
|
||||
if self.entries[e].id == 0 {
|
||||
self.entries[e].id = id;
|
||||
self.entries[e].failed = false;
|
||||
}
|
||||
if self.entries[e].records.len() >= RECORDS_PER_ENTRY {
|
||||
// `0x461a60` — evict the FIRST record farther from the listener than the newcomer,
|
||||
// and reject the newcomer when none is. Not an LRU and not an argmax: first-farther,
|
||||
// in claim order.
|
||||
// `0x461a60`: evict the first record, in claim order, farther than the newcomer, or
|
||||
// reject the newcomer when none is.
|
||||
let d = math::dist_sq(listener, pos);
|
||||
let Some(victim) = self.entries[e]
|
||||
.records
|
||||
@@ -238,17 +160,10 @@ impl AmbientEmitterPool {
|
||||
self.handles.insert(owner, e);
|
||||
}
|
||||
|
||||
/// The **GameObject** lane's `$DSL` arm (`0x5f3fe5`): register when the object holds no
|
||||
/// registration at all, else only *reposition* the one it holds. **It never compares the id,
|
||||
/// so it never swaps** — the one place the two registrars actually differ.
|
||||
///
|
||||
/// It is not a curiosity: Onyxia's lava trap (`ONYZIASLAIRLAVATRAP.M2`, 208 spawns in the
|
||||
/// lair) and Stratholme's spore trap author **two** ids — `$DSL(8681)` on the chained Stand
|
||||
/// variation and `$DSL(8682)` on Custom0 — so on the compare-and-swap arm the Custom0 hum
|
||||
/// would displace the Stand one, and on this arm it never plays at all. What clears the handle
|
||||
/// on this lane is not a second marker but the state-machine dispatch
|
||||
/// ([`super::gameobject`]'s release) or the object's despawn; there is no `$DSE` arm on the
|
||||
/// GameObject dispatcher at all.
|
||||
/// A GameObject's `$DSL` (`0x5f3fe5`): registers when it holds nothing, else only repositions;
|
||||
/// it never compares the id, so a second id (Onyxia's lava trap's `$DSL(8682)`) never
|
||||
/// displaces the first. Only the state dispatch or despawn clears it: GameObjects have no
|
||||
/// `$DSE` arm.
|
||||
fn register_keeping_first(&mut self, owner: Entity, id: u32, pos: Vec3, listener: Vec3) {
|
||||
if let Some(&e) = self.handles.get(&owner) {
|
||||
self.reposition(e, owner, pos);
|
||||
@@ -257,9 +172,7 @@ impl AmbientEmitterPool {
|
||||
self.register(owner, id, pos, listener);
|
||||
}
|
||||
|
||||
/// `0x462000` — move `owner`'s record inside entry `e`, leaving the entry's id and channel
|
||||
/// alone. The reference writes the position only when it actually changed; ours writes it
|
||||
/// unconditionally, which is the same value.
|
||||
/// Move `owner`'s record in entry `e`, leaving its id and channel alone (`0x462000`).
|
||||
fn reposition(&mut self, e: usize, owner: Entity, pos: Vec3) {
|
||||
if let Some(r) = self.entries[e]
|
||||
.records
|
||||
@@ -270,15 +183,10 @@ impl AmbientEmitterPool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `0x461f80` → `0x461d20` — release `owner`'s record. When it was the entry's **last**, the
|
||||
/// id is cleared and the channel starts its 3.0 s fade as an orphan.
|
||||
///
|
||||
/// Reached from a placed doodad's `$DSE` (the authored stop token — the elevator and machinery
|
||||
/// family, where the loop runs for one leg of the animation), from a GameObject's state-machine
|
||||
/// dispatch (`0x5f3cc8 call 0x5f40c0`, [`super::gameobject`]) and from the host's despawn (the
|
||||
/// owner's own teardown, `0x6a0840`). There is deliberately **no** map-change reset: the
|
||||
/// reference has none either (`0x461a20`'s only caller is the process-shutdown chain), and a
|
||||
/// streamed-out tile releases its doodads one by one, which is the same thing done honestly.
|
||||
/// Release `owner`'s record (`0x461f80` → `0x461d20`); the last one frees the entry and fades
|
||||
/// its channel. Reached from a doodad's `$DSE`, a GameObject's state dispatch (`0x5f3cc8`) and
|
||||
/// a despawn (`0x6a0840`). There is no map-change reset: the reference's (`0x461a20`) runs only
|
||||
/// at shutdown.
|
||||
fn release(&mut self, owner: Entity) {
|
||||
let Some(e) = self.handles.remove(&owner) else {
|
||||
return;
|
||||
@@ -299,9 +207,7 @@ impl AmbientEmitterPool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand this entry's channel to the fade list and leave the entry otherwise intact — the
|
||||
/// cap's eviction (`0x4619c5`), which keeps the id and its records so the entry can start a
|
||||
/// fresh channel the moment it is back under the cap.
|
||||
/// The cap's eviction (`0x4619c5`): fade the channel, keep the id and records.
|
||||
fn retire(&mut self, e: usize) {
|
||||
let (kit, voice) = {
|
||||
let entry = &mut self.entries[e];
|
||||
@@ -317,44 +223,23 @@ impl AmbientEmitterPool {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the cap walk does with one entry, folded over ascending index
|
||||
/// (`0x4619c0`–`0x4619f9`).
|
||||
/// What the cap walk does with one entry, over ascending index (`0x4619c0`-`0x4619f9`).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum CapStep {
|
||||
/// Not entitled — free, failed, or emitter-less. Costs the cap nothing and gets no service.
|
||||
/// Not entitled: costs the cap nothing and gets no service.
|
||||
Skip,
|
||||
/// Under the cap: service it (start or reposition), then let the channel say whether it
|
||||
/// counts.
|
||||
/// Under the cap: start or reposition, then count it if its channel is live.
|
||||
Service,
|
||||
/// Four are already sounding — fade this entry's channel out over [`FADE_SECS`] and leave
|
||||
/// everything else on the entry (`0x4619c5`).
|
||||
/// Four are sounding: fade this channel and keep the entry (`0x4619c5`).
|
||||
Retire,
|
||||
}
|
||||
|
||||
/// One step of the cap walk. **`sounding_so_far` counts entries that came back LIVE AND IN RANGE,
|
||||
/// not entitled ones** — the correction of 2026-09-07 (decision 2065).
|
||||
///
|
||||
/// Index is claim order, so this is first-come — emphatically **not** nearest. That is the cap's
|
||||
/// one surprising property: standing where five distinct doodad ambiences are *audible*, exactly
|
||||
/// four sound, and walking toward the fifth does not make it take over.
|
||||
///
|
||||
/// **What changed, and why the old reading mattered.** This used to count *entitlement* — id
|
||||
/// present, kit resolvable, at least one emitter registered — on the reasoning that a channel past
|
||||
/// its `DistanceCutoff` is still "playing" in FMOD, merely attenuated to nothing. The binary says
|
||||
/// otherwise. `0x461b40` returns `[+0xE04] != 0 && 0x7a5810(ch) && !0x7a5870(ch)`, and `0x7a5870`
|
||||
/// is bit `0x100000` of `[ch+0x38]` — **the `DistanceCutoff` verdict**, maintained by a second
|
||||
/// system the pump never mentions (`0x7a5ca0` registers the channel into the cull list
|
||||
/// `[0xcf5580]`, and `0x7a5000`, run immediately before `FSOUND_Update`, sets the bit and *stops*
|
||||
/// the channel out of range, clearing it and re-issuing `FSOUND_Stream_PlayEx` back in range). So a
|
||||
/// far, inaudible ambience does **not** hold one of the four, and neither does one whose start
|
||||
/// failed. The one exception is byte-derived too: a kit whose `DistanceCutoff` is **0** is never
|
||||
/// registered into the cull list (`0x7a5cca jnp`), so it holds a slot at any distance — which our
|
||||
/// `play_kit_ext` reproduces, since its own cutoff gate is `cutoff > 0.0 && !audible`.
|
||||
///
|
||||
/// This is not a detail. At the director's Stratholme pin all four channels were held by
|
||||
/// `UndeadCampfireSmall` / `CauldronLoop` / `TorchLoop` / `SlimeWaterfall`, **every one past its
|
||||
/// own cutoff** — nothing audible at all — while the burning-building loop right there was
|
||||
/// withheld. Under the reference's rule not one of the four would have counted.
|
||||
/// One step of the cap walk. `sounding_so_far` counts entries whose channel is live and in
|
||||
/// range, in claim order: `0x461b40` returns `[+0xE04] != 0 && 0x7a5810(ch) && !0x7a5870(ch)`,
|
||||
/// and `0x7a5870` is the `DistanceCutoff` bit that `0x7a5000` sets as it stops an out-of-range
|
||||
/// channel. So a far ambience holds none of the four, but among audible ones a nearer fifth never
|
||||
/// takes over. A kit with `DistanceCutoff` 0 is never culled (`0x7a5cca`) and holds a slot at
|
||||
/// any distance.
|
||||
const fn cap_step(entitled: bool, sounding_so_far: usize) -> CapStep {
|
||||
if !entitled {
|
||||
CapStep::Skip
|
||||
@@ -365,8 +250,8 @@ const fn cap_step(entitled: bool, sounding_so_far: usize) -> CapStep {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pump (`0x461990`): fade the orphans, mark unresolvable kits, then service the admitted
|
||||
/// entries — start a channel where there is none, and otherwise only *move* the one there is.
|
||||
/// The pump (`0x461990`): fade the orphans, mark unresolvable kits, then service entries under
|
||||
/// the cap, starting a channel where there is none and otherwise only moving it.
|
||||
fn pump_emitters(
|
||||
mut pool: ResMut<AmbientEmitterPool>,
|
||||
mut emitters: Query<&mut Transform, With<PoolEmitter>>,
|
||||
@@ -381,9 +266,7 @@ fn pump_emitters(
|
||||
let (Some(mut kits), Some(assets)) = (kits, assets) else {
|
||||
return;
|
||||
};
|
||||
// Running silent (`WOW_NOSOUND=1`, CI, an unattended probe): a start that cannot succeed must
|
||||
// not be retried per frame per entry, and must not warn — the same lesson the liquid loops
|
||||
// learned at 3975 identical lines in a 75 s run.
|
||||
// With no device a start cannot succeed; retrying it per frame would flood the log.
|
||||
if out.mixer.is_none() {
|
||||
return;
|
||||
}
|
||||
@@ -398,9 +281,7 @@ fn pump_emitters(
|
||||
// The orphans' fade-out.
|
||||
pool.fading.retain_mut(|f| {
|
||||
f.gain -= step;
|
||||
// Faded out, or nothing left to fade (the frame pump culled the channel past its cutoff,
|
||||
// or the voice ceiling stole it): let the entity go now rather than hold it for 3 s of
|
||||
// silence.
|
||||
// Faded out, or already culled or stolen: let the entity go now.
|
||||
if f.gain <= 0.0 || !source_kit_playing(&out, f.emitter, f.kit) {
|
||||
stop_source(&mut out, f.emitter);
|
||||
commands.entity(f.emitter).despawn();
|
||||
@@ -410,9 +291,8 @@ fn pump_emitters(
|
||||
true
|
||||
});
|
||||
|
||||
// `0x45cda0(id)` returning null → `[+0xE00] = -id`, permanently. Done before the arbitration
|
||||
// so a dead id never costs a live one its slot, not even for a frame. `NightElfLantern01`'s
|
||||
// `$DSL(33764)` is the shipped case: 5875's `SoundEntries` simply has no such row.
|
||||
// `0x45cda0(id)` finds no row: failed for good ([`Entry::failed`]). Before the cap walk, so a
|
||||
// dead id never costs a live one its slot.
|
||||
for entry in pool.entries.iter_mut() {
|
||||
if entry.id != 0 && !entry.failed && kit_name(&kits, entry.id).is_none() {
|
||||
entry.failed = true;
|
||||
@@ -426,22 +306,17 @@ fn pump_emitters(
|
||||
}
|
||||
}
|
||||
|
||||
// The cap walk (`0x461990`), ascending entry index. An entry is SERVICED while fewer than four
|
||||
// are sounding, and it counts only if servicing produced a live, in-range channel
|
||||
// ([`cap_step`]) — so an out-of-range ambience is kept up to date and re-starts the moment it
|
||||
// comes into range, but never costs a near one its slot.
|
||||
// The cap walk ([`cap_step`]): an out-of-range entry stays positioned but costs no slot.
|
||||
let mut sounding = 0usize;
|
||||
let mut census: Vec<(u32, bool)> = Vec::new();
|
||||
let mut withheld: Vec<u32> = Vec::new();
|
||||
for e in 0..POOL_ENTRIES {
|
||||
match cap_step(pool.entries[e].entitled(), sounding) {
|
||||
CapStep::Skip => {
|
||||
// Free, failed or emitter-less: nothing to hold, nothing to fade.
|
||||
pool.retire(e);
|
||||
continue;
|
||||
}
|
||||
CapStep::Retire => {
|
||||
// Over the cap: the channel fades out and the entry keeps everything else.
|
||||
withheld.push(pool.entries[e].id);
|
||||
pool.retire(e);
|
||||
continue;
|
||||
@@ -450,28 +325,21 @@ fn pump_emitters(
|
||||
}
|
||||
let id = pool.entries[e].id;
|
||||
let Some(nearest) = pool.entries[e].nearest(listener_pos) else {
|
||||
continue; // `entitled()` guarantees a record; belt and braces
|
||||
continue; // unreachable: `entitled()` guarantees a record
|
||||
};
|
||||
|
||||
// Put the voice entity at the nearest record. For a live channel this *is* the reference's
|
||||
// reposition — the frame pump's tracked-follow ships the new position and the sound never
|
||||
// restarts, which is the difference between one hum that follows you down a row of lamps
|
||||
// and thirty hums that each retrigger as you pass.
|
||||
// Move the voice to the nearest record; a live channel follows without restarting.
|
||||
let emitter = match pool.entries[e].voice {
|
||||
Some(em) => {
|
||||
match emitters.get_mut(em) {
|
||||
// Compare-then-write (1362): a parked listener's nearest record is the same
|
||||
// point every frame, and a no-op write still re-propagates the transform.
|
||||
// Compare first: a no-op write still re-propagates the transform.
|
||||
Ok(mut tf) => {
|
||||
if tf.translation != nearest {
|
||||
tf.translation = nearest;
|
||||
}
|
||||
}
|
||||
// Ours, spawned on an earlier frame, so this cannot happen — except through a
|
||||
// query-filter mistake, which would silently freeze every ambience at the
|
||||
// first emitter it ever found and leave the whole nearest-follow inert. That
|
||||
// failure is invisible in the audio (a hum is a hum), so it has to be loud
|
||||
// here.
|
||||
// Only a query-filter mistake reaches this, freezing the ambience at its
|
||||
// first emitter, which is inaudible, so it warns.
|
||||
Err(err) => {
|
||||
if pool.complained.insert(id) {
|
||||
warn!(
|
||||
@@ -496,12 +364,8 @@ fn pump_emitters(
|
||||
census.push((id, true));
|
||||
continue;
|
||||
}
|
||||
// No channel: either this entry has never sounded, or the frame pump culled ours past the
|
||||
// kit's `DistanceCutoff`. **Divergence:** the reference's pool lane has no distance
|
||||
// pre-check and leaves the channel running at zero gain past the cutoff, where ours stops
|
||||
// and restarts it. For a loop of ambience that is inaudible — restarting a hum you could
|
||||
// not hear costs nothing but a phase — and it hands the voice back while it is silent.
|
||||
// The *cap* is unaffected, because [`admitted`] counts entitlement, not liveness.
|
||||
// No channel: never started, or culled past the kit's `DistanceCutoff`, which the
|
||||
// reference's cull also stops and re-plays back in range (`0x7a5000`).
|
||||
if let Err(err) = play_kit_ext(
|
||||
&mut kits,
|
||||
&assets,
|
||||
@@ -513,13 +377,11 @@ fn pump_emitters(
|
||||
SoundCategory::Sfx,
|
||||
PlayExtras {
|
||||
source: Some(emitter),
|
||||
// Looping is an entry-point constant on this lane, not a flag: `0x7a54d0` builds
|
||||
// mode `0x1002` (`HW3D|LOOP_NORMAL`) and calls `SetLoopCount(-1)`, against
|
||||
// `0x7a5490`'s `0x1000` for the one-shot path. Every `$DSL` loops; no `$DSO` does;
|
||||
// `SoundEntries.Flags` is consulted for neither.
|
||||
// This lane always loops: `0x7a54d0` builds mode `0x1002` (`HW3D|LOOP_NORMAL`),
|
||||
// whatever `SoundEntries.Flags` says; the one-shot path (`0x7a5490`) never does.
|
||||
force_loop: true,
|
||||
// The per-id suppressors are the one-shot lane's; this lane dedupes structurally
|
||||
// (one entry per id) and must not be blocked by its own fading predecessor.
|
||||
// `NO_DUPLICATES` (0x20) is the one-shot lane's (`0x458f40` → `0x7a66a0`); this
|
||||
// lane dedupes by entry and must not be blocked by its own fading predecessor.
|
||||
dedupe_exempt: true,
|
||||
..default()
|
||||
},
|
||||
@@ -528,11 +390,8 @@ fn pump_emitters(
|
||||
warn!("doodad emitter kit {id}: {err:#} (further reports for this kit suppressed)");
|
||||
}
|
||||
}
|
||||
// Whether the start actually took: `play_kit_ext` succeeds *without playing* when the kit
|
||||
// is past its cutoff, held by the loading cover, or refused by the voice ceiling. Reading
|
||||
// the channel back is the only honest answer — and since 2065 it is also the cap's own
|
||||
// counter (`0x461b40`'s composite return, [`cap_step`]), which is why this is read rather
|
||||
// than predicted.
|
||||
// Read back, not predicted: `play_kit_ext` succeeds without playing past the cutoff,
|
||||
// under the loading cover or at the voice ceiling, and liveness is the cap's counter.
|
||||
let live = source_kit_playing(&out, emitter, id);
|
||||
if live {
|
||||
sounding += 1;
|
||||
@@ -540,19 +399,8 @@ fn pump_emitters(
|
||||
census.push((id, live));
|
||||
}
|
||||
|
||||
// The pool's one observable, in the three states the cap actually has. "Which doodad ambiences
|
||||
// are sounding, and which one went quiet" is invisible in the audio itself — five ids in range
|
||||
// and four sounding is indistinguishable from four in range unless something says so — and it
|
||||
// is precisely the question a retest of the cap has to answer. Logged on the **edge**, so a
|
||||
// parked camera prints one line, not one per frame.
|
||||
//
|
||||
// **Naming the withheld entries is the half the census was missing** (2059): "1 withheld by
|
||||
// the cap" cannot say *which* ambience went silent, and that is the only question a report of
|
||||
// a missing sound layer asks. Since 2065 the split is three ways rather than two, because the
|
||||
// cap's own counter is liveness: `serviced [out of range]` is an entry the walk reached, kept
|
||||
// positioned and could not sound — costing the cap **nothing** — while `withheld` is one the
|
||||
// walk never reached because four were already sounding. Those were the same word before, and
|
||||
// conflating them is exactly the reading the binary refuted.
|
||||
// Log the pool on each change: sounding, serviced but out of range (no slot spent), and
|
||||
// withheld by the cap, each by name.
|
||||
if census != pool.last_census || withheld != pool.last_withheld {
|
||||
let entitled = pool.entries.iter().filter(|x| x.entitled()).count();
|
||||
let named: Vec<String> = census
|
||||
@@ -583,9 +431,8 @@ fn pump_emitters(
|
||||
}
|
||||
}
|
||||
|
||||
/// Register `owner`'s emitter for kit `id` at `pos` — the **compare-and-swap** arm: a placed
|
||||
/// doodad's `$DSL` (`0x6951e0`) and a GameObject's looping display slot (`0x5f4010`), both called
|
||||
/// from their own routers.
|
||||
/// Register `owner`'s emitter for kit `id` at `pos`, compare-and-swap: a placed doodad's `$DSL`
|
||||
/// (`0x6951e0`) and a GameObject's looping display slot (`0x5f4010`).
|
||||
pub(super) fn register(
|
||||
pool: &mut AmbientEmitterPool,
|
||||
owner: Entity,
|
||||
@@ -596,9 +443,8 @@ pub(super) fn register(
|
||||
pool.register(owner, id, pos, listener);
|
||||
}
|
||||
|
||||
/// Register `owner`'s emitter, **keeping whatever id it already holds** — a GameObject's `$DSL`
|
||||
/// arm (`0x5f3fe5`), which never compares the id. See
|
||||
/// [`AmbientEmitterPool::register_keeping_first`].
|
||||
/// Register `owner`'s emitter, keeping any id it already holds: a GameObject's `$DSL`
|
||||
/// (`0x5f3fe5`).
|
||||
pub(super) fn register_keeping_first(
|
||||
pool: &mut AmbientEmitterPool,
|
||||
owner: Entity,
|
||||
@@ -609,19 +455,14 @@ pub(super) fn register_keeping_first(
|
||||
pool.register_keeping_first(owner, id, pos, listener);
|
||||
}
|
||||
|
||||
/// Release `owner`'s emitter — a placed doodad's `$DSE`, and a GameObject's state-machine
|
||||
/// dispatch (`0x5f40c0`).
|
||||
/// Release `owner`'s emitter: a placed doodad's `$DSE`, or a GameObject's state dispatch
|
||||
/// (`0x5f40c0`).
|
||||
pub(super) fn release(pool: &mut AmbientEmitterPool, owner: Entity) {
|
||||
pool.release(owner);
|
||||
}
|
||||
|
||||
/// Release the emitters of owners that have gone — a placed doodad's host (`0x7133a0`'s teardown
|
||||
/// leg) or a GameObject (whose registration lives on the type handler that goes with it).
|
||||
///
|
||||
/// This replaces B345's channel-scoped reaper. An owner no longer *owns* a channel, so stopping
|
||||
/// "its" channel is the wrong verb: what a despawn retires is one **record**, and the sound only
|
||||
/// stops when the last emitter of that id in the world is gone. Streaming out one of thirty lamps
|
||||
/// must not silence the other twenty-nine.
|
||||
/// Release the records of despawned owners (`0x7133a0`'s teardown leg for a doodad host); the
|
||||
/// sound stops only with the last emitter of its id.
|
||||
fn release_emitters_on_despawn(
|
||||
mut doodads: RemovedComponents<benilla_world::doodad_anim::DoodadAnimHost>,
|
||||
mut gos: RemovedComponents<crate::go_anim::GoAnim>,
|
||||
@@ -636,11 +477,8 @@ pub(super) fn plugin(app: &mut App) {
|
||||
app.init_resource::<AmbientEmitterPool>().add_systems(
|
||||
Update,
|
||||
(
|
||||
// Registration (`$DSL`/`$DSE` in `anim_events`, the display-slot loop lane in
|
||||
// `gameobject`) and the despawn release both write the pool; the pump reads it and
|
||||
// starts channels; the frame pump then applies this
|
||||
// frame's positions and gains. Ordered explicitly rather than left to the scheduler,
|
||||
// because an arbitrary order costs a frame of latency on a stop.
|
||||
// Registrars and the despawn release, then the pump, then the frame pump: an
|
||||
// arbitrary order would cost a frame of latency on a stop.
|
||||
release_emitters_on_despawn.before(pump_emitters),
|
||||
pump_emitters
|
||||
.after(super::anim_events::route_anim_events)
|
||||
@@ -655,7 +493,7 @@ pub(super) fn plugin(app: &mut App) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Entities are only identities here — the pool never looks one up.
|
||||
/// Entities are only identities here: the pool never looks one up.
|
||||
fn doodads(n: u32) -> Vec<Entity> {
|
||||
(0..n)
|
||||
.map(Entity::from_raw_u32)
|
||||
@@ -678,8 +516,7 @@ mod tests {
|
||||
pool
|
||||
}
|
||||
|
||||
/// The structural dedupe: N doodads naming one kit share ONE entry, which is what makes the
|
||||
/// pump run one channel for the lot.
|
||||
/// N doodads naming one kit share one entry, and so one channel.
|
||||
#[test]
|
||||
fn every_doodad_naming_one_kit_shares_a_single_entry() {
|
||||
let mut pool = AmbientEmitterPool::default();
|
||||
@@ -689,7 +526,7 @@ mod tests {
|
||||
}
|
||||
assert_eq!(pool.entries.iter().filter(|e| e.id != 0).count(), 1);
|
||||
assert_eq!(pool.entries[0].records.len(), 30);
|
||||
// …and its channel goes to the nearest of the thirty, not to whichever fired last.
|
||||
// Its channel goes to the nearest of the thirty.
|
||||
assert_eq!(
|
||||
pool.entries[0]
|
||||
.nearest(Vec3::new(29.0, 0.0, 0.0))
|
||||
@@ -700,7 +537,7 @@ mod tests {
|
||||
assert_eq!(pool.entries[0].nearest(Vec3::ZERO).unwrap().x, 0.0);
|
||||
}
|
||||
|
||||
/// `0x461cf6` discards a candidate that is not *strictly* closer.
|
||||
/// `0x461cf6` discards a candidate that is not strictly closer.
|
||||
#[test]
|
||||
fn a_tie_for_nearest_keeps_the_first_record() {
|
||||
let mut pool = AmbientEmitterPool::default();
|
||||
@@ -710,8 +547,7 @@ mod tests {
|
||||
assert_eq!(pool.entries[0].nearest(Vec3::ZERO).unwrap().z, 5.0);
|
||||
}
|
||||
|
||||
/// Re-crossing the marker with the same id REPOSITIONS (`0x462000`); it never takes a second
|
||||
/// record and never releases. This is the no-wrap-retrigger property, in the data.
|
||||
/// The same id only repositions (`0x462000`).
|
||||
#[test]
|
||||
fn re_firing_the_same_id_only_moves_the_record() {
|
||||
let mut pool = AmbientEmitterPool::default();
|
||||
@@ -722,10 +558,8 @@ mod tests {
|
||||
assert_eq!(pool.entries[0].records[0].pos, Vec3::new(1.0, 2.0, 3.0));
|
||||
}
|
||||
|
||||
/// **The GameObject lane never swaps** (`0x5f3fe5`, no id compare) — the one place the two
|
||||
/// registrars differ. Onyxia's lava trap authors `$DSL(8681)` on its Stand variation and
|
||||
/// `$DSL(8682)` on Custom0; on this arm the second never displaces the first, and what
|
||||
/// eventually clears the handle is the state dispatch, not another marker.
|
||||
/// `0x5f3fe5` never swaps: Onyxia's lava trap's `$DSL(8682)` on Custom0 never displaces its
|
||||
/// Stand `$DSL(8681)`.
|
||||
#[test]
|
||||
fn the_gameobject_arm_keeps_the_id_it_first_registered() {
|
||||
let mut pool = AmbientEmitterPool::default();
|
||||
@@ -734,17 +568,15 @@ mod tests {
|
||||
pool.register_keeping_first(go, 8682, Vec3::new(1.0, 0.0, 0.0), Vec3::ZERO);
|
||||
assert_eq!(pool.entries.iter().filter(|e| e.id != 0).count(), 1);
|
||||
assert_eq!(pool.entries[0].id, 8681, "8682 must not displace it");
|
||||
// …and the second marker still MOVED the record — `0x462000` runs on both legs.
|
||||
// The second marker still moved the record.
|
||||
assert_eq!(pool.entries[0].records[0].pos, Vec3::new(1.0, 0.0, 0.0));
|
||||
// The release is what frees it, and then the next marker takes the entry it names.
|
||||
pool.release(go);
|
||||
pool.register_keeping_first(go, 8682, Vec3::ZERO, Vec3::ZERO);
|
||||
assert_eq!(pool.entries[0].id, 8682);
|
||||
}
|
||||
|
||||
/// The two lanes share one handle per owner, so a GameObject's looping display slot and its
|
||||
/// own `$DSL` contend for it exactly as `[handler+0x18]` does — and the compare-and-swap arm
|
||||
/// the display slot uses does displace.
|
||||
/// A GameObject's `$DSL` and looping display slot share `[handler+0x18]`, and the display
|
||||
/// slot's compare-and-swap displaces.
|
||||
#[test]
|
||||
fn the_display_slot_lane_shares_the_owners_one_handle() {
|
||||
let mut pool = AmbientEmitterPool::default();
|
||||
@@ -755,8 +587,7 @@ mod tests {
|
||||
assert_eq!(pool.entries[0].id, 4694);
|
||||
}
|
||||
|
||||
/// `bellows.m2`: two `$DSL` keys on one looping sequence alternate through the doodad's single
|
||||
/// handle, rather than droning together.
|
||||
/// `bellows.m2`: two `$DSL` ids alternate through the doodad's one handle.
|
||||
#[test]
|
||||
fn a_different_id_moves_the_doodads_one_registration() {
|
||||
let mut pool = AmbientEmitterPool::default();
|
||||
@@ -767,7 +598,7 @@ mod tests {
|
||||
assert_eq!(pool.entries.iter().find(|e| e.id != 0).unwrap().id, 2000);
|
||||
}
|
||||
|
||||
/// Releasing the last record frees the entry (`0x461d20`) so another id can claim it.
|
||||
/// Releasing the last record frees the entry (`0x461d20`).
|
||||
#[test]
|
||||
fn the_last_release_frees_the_entry() {
|
||||
let mut pool = AmbientEmitterPool::default();
|
||||
@@ -781,9 +612,7 @@ mod tests {
|
||||
assert!(pool.handles.is_empty());
|
||||
}
|
||||
|
||||
/// `0x461e60`: a 33rd distinct id is UNTRACKED — the registration fails outright rather than
|
||||
/// evicting an incumbent. `$DSL` re-fires every cycle, so it takes an entry the moment one
|
||||
/// frees.
|
||||
/// `0x461e60`: a 33rd id evicts nothing; its re-firing `$DSL` takes the next free entry.
|
||||
#[test]
|
||||
fn a_thirty_third_distinct_id_is_untracked_until_an_entry_frees() {
|
||||
let mut pool = AmbientEmitterPool::default();
|
||||
@@ -800,8 +629,7 @@ mod tests {
|
||||
assert_eq!(pool.entries[0].id, 100 + POOL_ENTRIES as u32);
|
||||
}
|
||||
|
||||
/// `0x461a60` — first-farther-than-the-newcomer, in claim order. Not an LRU, and not the
|
||||
/// farthest: the FIRST record farther than the incoming one goes.
|
||||
/// `0x461a60`: the first record in claim order farther than the newcomer goes.
|
||||
#[test]
|
||||
fn the_two_hundred_and_fifty_seventh_record_evicts_the_first_farther_one() {
|
||||
let mut pool = AmbientEmitterPool::default();
|
||||
@@ -813,8 +641,7 @@ mod tests {
|
||||
}
|
||||
assert_eq!(pool.entries[0].records.len(), RECORDS_PER_ENTRY);
|
||||
|
||||
// A newcomer at x = 10 is nearer than record 0 (x = 255), so record 0 — the first one
|
||||
// farther than it — is the victim, and the newcomer takes the tail slot.
|
||||
// A newcomer at x = 10: record 0 (x = 255) is the first farther one and goes.
|
||||
let late = ds[RECORDS_PER_ENTRY];
|
||||
pool.register(late, 9, Vec3::new(10.0, 0.0, 0.0), Vec3::ZERO);
|
||||
assert_eq!(pool.entries[0].records.len(), RECORDS_PER_ENTRY);
|
||||
@@ -829,7 +656,7 @@ mod tests {
|
||||
assert_eq!(pool.entries[0].records.last().unwrap().pos.x, 10.0);
|
||||
}
|
||||
|
||||
/// …and one farther than every incumbent is REJECTED, not swapped in.
|
||||
/// A newcomer farther than every incumbent is rejected.
|
||||
#[test]
|
||||
fn a_record_farther_than_all_of_them_is_rejected() {
|
||||
let mut pool = AmbientEmitterPool::default();
|
||||
@@ -843,9 +670,8 @@ mod tests {
|
||||
assert!(!pool.handles.contains_key(&late));
|
||||
}
|
||||
|
||||
/// Run the cap walk over a pool, given each entry's liveness verdict for this frame —
|
||||
/// [`cap_step`] folded exactly as [`pump_emitters`] folds it, without a mixer. Returns
|
||||
/// `(sounding indices, withheld indices)`.
|
||||
/// [`cap_step`] folded as [`pump_emitters`] folds it, with liveness given; returns
|
||||
/// `(sounding, withheld)` indices.
|
||||
fn walk(pool: &AmbientEmitterPool, live: impl Fn(usize) -> bool) -> (Vec<usize>, Vec<usize>) {
|
||||
let (mut sounding, mut withheld) = (Vec::new(), Vec::new());
|
||||
for e in 0..POOL_ENTRIES {
|
||||
@@ -862,7 +688,7 @@ mod tests {
|
||||
(sounding, withheld)
|
||||
}
|
||||
|
||||
/// The cap picks by ENTRY INDEX — claim order — and stops at four SOUNDING entries.
|
||||
/// The cap picks by entry index and stops at four sounding entries.
|
||||
#[test]
|
||||
fn the_cap_admits_the_first_four_sounding_entries_by_index() {
|
||||
let pool = pool_with(&[(10, 1), (20, 1), (30, 1), (40, 1), (50, 1), (60, 1)]);
|
||||
@@ -871,15 +697,8 @@ mod tests {
|
||||
assert_eq!(withheld, vec![4, 5]);
|
||||
}
|
||||
|
||||
/// **The correction of 2065, and the reason Stratholme went silent.** An entry the walk
|
||||
/// services but which comes back with no live channel — past its own `DistanceCutoff`, or a
|
||||
/// start that failed — consumes **nothing**. `0x461b40` returns false for it (`0x7a5870`, the
|
||||
/// cutoff bit), so `0x4619c0`'s counter never advances and the walk keeps going.
|
||||
///
|
||||
/// The four here are the real Stratholme pin: `UndeadCampfireSmall`, `CauldronLoop`,
|
||||
/// `TorchLoop` and `SlimeWaterfall`, every one out of range, in front of the burning-building
|
||||
/// loop with 88 emitters on top of the listener. Under the old entitlement count they held all
|
||||
/// four slots and it was withheld; under the binary's rule they hold none.
|
||||
/// A serviced entry with no live channel consumes no slot (`0x461b40`, `0x7a5870`). The ids are
|
||||
/// four out-of-range Stratholme ambiences ahead of an in-range fire.
|
||||
#[test]
|
||||
fn a_serviced_but_silent_entry_does_not_consume_a_cap_slot() {
|
||||
let pool = pool_with(&[(3255, 1), (3379, 1), (4694, 1), (4855, 1), (6437, 1)]);
|
||||
@@ -891,8 +710,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Free, emitter-less and failed entries are not entitled and cost the cap nothing — the
|
||||
/// entry after them moves up.
|
||||
/// Free, emitter-less and failed entries cost the cap nothing.
|
||||
#[test]
|
||||
fn unentitled_entries_do_not_consume_a_cap_slot() {
|
||||
let mut pool = pool_with(&[(10, 1), (0, 0), (30, 0), (40, 1), (50, 1), (60, 1), (70, 1)]);
|
||||
@@ -901,11 +719,8 @@ mod tests {
|
||||
assert_eq!(sounding, vec![0, 4, 5, 6]);
|
||||
}
|
||||
|
||||
/// The falsifiable half: distance does NOT arbitrate **among entries that are all audible**.
|
||||
/// A fifth id right on top of the listener stays silent behind four claimed earlier — but
|
||||
/// only while those four are inside their own cutoffs, which is the qualifier 2065 added
|
||||
/// (with far incumbents the prediction is the opposite, and reading it without the qualifier
|
||||
/// would tear up a correct mechanism).
|
||||
/// Among audible entries distance does not arbitrate: a fifth on the listener stays silent
|
||||
/// behind four earlier ones still inside their cutoffs.
|
||||
#[test]
|
||||
fn distance_never_promotes_a_fifth_entry_over_an_earlier_audible_one() {
|
||||
let mut pool = AmbientEmitterPool::default();
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
//! Emote sounds (decision 0070 slice 4): `SMSG_TEXT_EMOTE` plays the performer's race/sex voice
|
||||
//! kit (`EmotesTextSound`); `SMSG_EMOTE` plays the anim emote's `EventSoundID` (`Emotes.dbc`).
|
||||
//! Both arrive via `net::EmoteMessage` with the performer resolved to an entity — race/sex come
|
||||
//! from its descriptor store (`UNIT_FIELD_BYTES_0`), position from its transform.
|
||||
//!
|
||||
//! The catalog also serves the **send** side: `/wave`-style chat lines resolve their EmotesText
|
||||
//! id through [`EmoteSounds::text_id`] (`crate::ui_chat`), go out as `CMSG_TEXT_EMOTE` — gated first
|
||||
//! by [`EmoteSounds::text_emote`] + [`EmoteSounds::emote_flags`] (the posture-eligibility gate,
|
||||
//! `0x47db40`) — and the server echo plays our own emote
|
||||
//! through this same receive path — vanilla's actual loop.
|
||||
//!
|
||||
//! [`EmoteSounds::anim`] promotes the catalog's `Emotes.dbc` → `AnimID` column for
|
||||
//! `crate::creature_anim`'s animation consumers (the `SMSG_EMOTE` one-shot and the
|
||||
//! `UNIT_NPC_EMOTESTATE` looping idle) — the one DBC load serves both sound and animation.
|
||||
//! Emote sounds: `SMSG_TEXT_EMOTE` plays the performer's race/sex voice kit
|
||||
//! (`EmotesTextSound`), `SMSG_EMOTE` plays the anim emote's `EventSoundID` (`Emotes.dbc`). Our
|
||||
//! own `/wave` goes out as `CMSG_TEXT_EMOTE` and plays through the server echo, as in 1.12. The
|
||||
//! catalog also serves `crate::ui_chat` and `crate::creature_anim`.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -34,41 +24,35 @@ impl EmoteSounds {
|
||||
self.0.text_id(name)
|
||||
}
|
||||
|
||||
/// An `Emotes.dbc` id's `AnimID` (0/absent = none) — promoted for `crate::creature_anim`'s
|
||||
/// animation consumers so they don't load the DBC a second time.
|
||||
/// An `Emotes.dbc` id's `AnimID`.
|
||||
pub(crate) fn anim(&self, emote_id: u32) -> Option<u32> {
|
||||
self.0.anim(emote_id)
|
||||
}
|
||||
|
||||
/// A text-emote's `EmotesText.dbc` `EmoteID` → its `Emotes.dbc` id (0/absent = chat-only, no
|
||||
/// anim emote — e.g. `/thank`). Promoted for `crate::ui_chat`'s send-side posture-eligibility gate.
|
||||
/// A text emote's `Emotes.dbc` id (`EmotesText.dbc` `EmoteID`); 0 is chat-only (`/thank`).
|
||||
pub(crate) fn text_emote(&self, text_id: u32) -> Option<u32> {
|
||||
self.0.text_emote(text_id)
|
||||
}
|
||||
|
||||
/// The `Emotes.dbc` id in one of the five hard-coded **gesture slots** — promoted for
|
||||
/// `crate::creature_anim::gesture`, the client-local chat/interact gesture producer.
|
||||
/// The `Emotes.dbc` id in one of the five hard-coded gesture slots.
|
||||
pub(crate) fn gesture(&self, slot: usize) -> Option<u32> {
|
||||
self.0.gesture(slot)
|
||||
}
|
||||
|
||||
/// An `Emotes.dbc` id's raw `EmoteFlags` bits — promoted for `crate::ui_chat`'s send-side
|
||||
/// posture-eligibility gate (`0x47db40`).
|
||||
/// An `Emotes.dbc` id's raw `EmoteFlags`, read by the posture-eligibility gate (`0x47db40`).
|
||||
pub(crate) fn emote_flags(&self, emote_id: u32) -> Option<u32> {
|
||||
self.0.emote_flags(emote_id)
|
||||
}
|
||||
|
||||
/// The **stand state** this emote sets, if it is a posture emote (`EmoteSpecProc == 1`) —
|
||||
/// `DoEmote`'s state branch (`0x5ef560` → `0x5ed430`). Promoted for `crate::ui_chat`: it is
|
||||
/// what makes `/sit` actually sit.
|
||||
/// The stand state a posture emote (`EmoteSpecProc == 1`) sets, as in `DoEmote`'s state
|
||||
/// branch (`0x5ef560` → `0x5ed430`).
|
||||
pub(crate) fn posture_state(&self, emote_id: u32) -> Option<u32> {
|
||||
self.0.posture_state(emote_id)
|
||||
}
|
||||
|
||||
/// The `$ESD` anim event's kit for a unit in this looping state emote: the row's
|
||||
/// `EventSoundID`, gated on `EmoteSpecProc == 2` — the client's `row[+0x10] == 2` test in the
|
||||
/// `$ESD` handler `0x6239f0` before it reads `row[+0x18]` (decision 0562). A one-shot emote id
|
||||
/// parked in the state field stays silent, exactly like the reference.
|
||||
/// The `$ESD` anim event's kit for a looping state emote: `EventSoundID` only when
|
||||
/// `EmoteSpecProc == 2` (`row[+0x10] == 2` in the handler `0x6239f0`), so a one-shot emote id
|
||||
/// in the state field stays silent.
|
||||
pub(crate) fn state_event_sound(&self, emote_id: u32) -> Option<u32> {
|
||||
(self.0.spec_proc(emote_id) == Some(2))
|
||||
.then(|| self.0.event_sound(emote_id))
|
||||
@@ -92,8 +76,7 @@ fn load_emote_sounds(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
|
||||
}
|
||||
|
||||
/// Route the bridged emotes: a text emote plays the performer's race/sex voice; an anim emote
|
||||
/// plays its event kit. A performer without race/sex in its store yet (partial snapshot) stays
|
||||
/// silent rather than guessing a voice.
|
||||
/// plays its event kit. A performer whose race/sex has not arrived yet stays silent.
|
||||
fn emote_sounds(
|
||||
mut msgs: MessageReader<EmoteMessage>,
|
||||
units: Query<(&ObjectStore, &Transform)>,
|
||||
@@ -117,10 +100,8 @@ fn emote_sounds(
|
||||
};
|
||||
let kit = match m.kind {
|
||||
EmoteKind::Text(text_id) => {
|
||||
// **`EmoteSounds` gates the received text-emote voice, and only that** — the
|
||||
// reference looks the CVar up by name at play time and, on a zero, never fetches
|
||||
// the kit at all (silence, not a muted play). The `Anim` arm below is the
|
||||
// `Emotes.dbc` `EventSoundID` one-shot, a different channel with no such gate.
|
||||
// The `EmoteSounds` CVar gates only the text-emote voice, read at play time; a
|
||||
// zero never fetches the kit. The `Anim` one-shot has no such gate.
|
||||
if !config.emote_sounds {
|
||||
continue;
|
||||
}
|
||||
@@ -132,7 +113,7 @@ fn emote_sounds(
|
||||
EmoteKind::Anim(emote_id) => emotes.0.event_sound(emote_id),
|
||||
};
|
||||
let Some(kit) = kit.filter(|&k| k != 0) else {
|
||||
continue; // most emotes are voiceless (/wave); silence is correct
|
||||
continue; // most emotes are voiceless (/wave)
|
||||
};
|
||||
if let Err(e) = play_kit(
|
||||
&mut kits,
|
||||
|
||||
@@ -1,62 +1,37 @@
|
||||
//! Footsteps (decision 0070 slice 3): the `$FSD` anim tag resolved through the terrain-type
|
||||
//! chain — `surface under the unit → TerrainType → × the unit's footstep class →
|
||||
//! FootstepTerrainLookup → SoundEntries`.
|
||||
//! Footsteps: the `$FSD` tag through the terrain chain, `surface under the unit → TerrainType ×
|
||||
//! the unit's footstep class → FootstepTerrainLookup → SoundEntries`.
|
||||
//!
|
||||
//! **The surface is [`benilla_world::surface`]'s to answer, not this module's** (decision 1161). It is the
|
||||
//! client's two-leg down-ray: a building that owns the column supplies its own floor's material,
|
||||
//! and only outdoors does the ADT ground-effect layer decide. Reading the ADT unconditionally is
|
||||
//! what put a snow crunch under the Kharanos inn's floorboards. `None` there is the reference's
|
||||
//! `−1` and means silent — never "ask the other leg".
|
||||
//! The surface is [`benilla_world::surface`]'s: a building that owns the column supplies its
|
||||
//! floor's material, and only outdoors does the ADT ground-effect layer decide. `None` is the
|
||||
//! reference's -1 and is silent.
|
||||
//!
|
||||
//! **Trigger: `$FSD` and nothing else** (decision 1080). A footfall is two disjoint channels in
|
||||
//! the client's event dispatcher `0x5ffbd0` — `$FSD → 0x623390` is the *sound*, the per-foot side
|
||||
//! tags (`$FL/$FR/$RL/$RR/$SL/$SR/$BL/$BR/$WL/$WR`) `→ 0x5fbf70` are the *visual* footfall (the
|
||||
//! decal + spray, [`crate::footprints`]) and play nothing. Reading both as steps rang **every gait
|
||||
//! at double rate** — HumanMale's Walk
|
||||
//! keys `$FR0 · $FSD · $FL0 · $FSD`, a horse's gallop four of each per 0.8 s cycle — and made the
|
||||
//! turn-in-place shuffle, whose *only* keys are `$SL0 $SR0` at `t = 0.000`, clatter where the
|
||||
//! reference is silent ([`crate::creature_anim::is_footstep_sound`]).
|
||||
//! Only `$FSD` sounds (`0x5ffbd0` → `0x623390`); the per-foot side tags (`$FL`, `$FR`, `$SL`, …)
|
||||
//! go to the visual footfall `0x5fbf70` ([`crate::footprints`]) and play nothing
|
||||
//! ([`crate::creature_anim::is_footstep_sound`]).
|
||||
//!
|
||||
//! ## A footfall is two sounds
|
||||
//! `$FSD` plays an armor foley and then the terrain step. The foley is `0x623390`'s first act after
|
||||
//! the state gates (`0x6233d9 call [vt+0x8c]`), ahead of the class gate, so a class-0 creature
|
||||
//! still rustles; the foley is uncapped on bus 0, the step on bus 9 with its cap of 6. The foley's
|
||||
//! material:
|
||||
//! - a unit (`0x623610`): `CreatureModelData.FoleyMaterialID` off its display
|
||||
//! (`[[unit+0xb3c]+0x28]`).
|
||||
//! - a player (`0x62fa30`): the chest item's `Material`, through the equipment guid array at
|
||||
//! `[player+0x1d38]` element 4, which is filled only for the local player (`0x5dd454`); the
|
||||
//! private `PLAYER_FIELD_INV_SLOT_*` makes the same read self-only here.
|
||||
//!
|
||||
//! `$FSD` fires the terrain step **and** an armor foley, and the reference orders them: the foley
|
||||
//! is `0x623390`'s first act after the state gates (`0x6233d9 call [vt+0x8c]`), *ahead of* the
|
||||
//! class gate and every terrain lookup below. So a creature whose footstep class is 0 still
|
||||
//! rustles, and the two land on different buses — the foley uncapped on bus 0, the step on bus
|
||||
//! 9's cap of 6. Both are voiced here, in that order, rather than in two systems, because they
|
||||
//! share those three gates and the client shares them too.
|
||||
//! `Material.dbc` names a kit only for chain, plate and leather: cloth is silent.
|
||||
//!
|
||||
//! The foley's material has two sources, one per vtable slot:
|
||||
//! - **a unit** (`0x623610`) — `CreatureModelData.FoleyMaterialID` off its display
|
||||
//! (`[[unit+0xb3c]+0x28]`), i.e. what the *model* is dressed in.
|
||||
//! - **a player** (`0x62fa30`) — the **chest** item's `Material`, read through the equipment
|
||||
//! GUID array at `[player+0x1d38]` element 4. That array is populated only for the local
|
||||
//! player (its ctor writes a count of 113 when the object's guid matches
|
||||
//! `0x468550`'s and **0 otherwise**, `0x5dd454`), so in the reference *other* players are
|
||||
//! silent-bodied. benilla reproduces that for free: `PLAYER_FIELD_INV_SLOT_*` is a private
|
||||
//! field the server sends only to you, so the same read is naturally self-only.
|
||||
//! The footstep class is `CreatureSoundData.FootstepID` through the display→sound chain with the
|
||||
//! model fallback (`benilla_formats::creature_sound`). Class 0 or no row is silent: the handler
|
||||
//! bails on a zero class before any lookup (`0x6233ec`).
|
||||
//!
|
||||
//! `Material.dbc` then names the kit, and only three of its eight rows carry one — chain, plate
|
||||
//! and leather. **Cloth is silent**, which is data, not a gap: a robed mage rustles nothing.
|
||||
//! Before the class, `0x623390` gates on the root unit (the rider of a mount): hover (`0x6233aa`,
|
||||
//! move flag `0x4000_0000`), stealth (`0x62339c`, `BYTES_1` byte 3 bit `0x2`) and player ghost
|
||||
//! (`0x6233d3`, `PLAYER_FLAGS & 0x10`).
|
||||
//!
|
||||
//! The unit's class comes from its voice row (`CreatureSoundData.FootstepID`) through the
|
||||
//! generic display→sound chain incl. the model fallback (`benilla_formats::creature_sound` —
|
||||
//! characters reach class 7 as *data*). **Class 0, or no row at all, = no footstep sounds**:
|
||||
//! the client's `$FSD` handler bails on a zero class before any lookup (`0x6233ec`); the lookup's
|
||||
//! class-0 rows are the Ancient
|
||||
//! Protector's stomps (kit 661), reached only by that model's own nonzero class.
|
||||
//!
|
||||
//! Ahead of the class, `0x623390` opens on the same three state gates the visual handler does,
|
||||
//! read on the ROOT unit (the rider of a mounted composite): **hover** (`0x6233aa`, move flag
|
||||
//! `0x4000_0000`), **stealth/CREEP** (`0x62339c`, `BYTES_1` byte 3 bit `0x2` — not death) and
|
||||
//! **player ghost** (`0x6233d3`, `PLAYER_FLAGS & 0x10`) — a stealthed unit is silent-footed.
|
||||
//!
|
||||
//! Wading (feet below a water surface, down to the unit's own swim boundary `0.75·h`) picks the
|
||||
//! lookup's **splash** slot instead of dry (falling back to dry when the class has no splash kit);
|
||||
//! deeper the unit swims and footfalls go silent. The flat 2.0-yd stand-in that boundary used to be
|
||||
//! is retired now 0464's `collisionHeight` plumb has landed (decision 0645) — a murloc's footfalls
|
||||
//! now go quiet in water that only reaches a human's knees. Still open: this reads a *depth* even
|
||||
//! for the local player rather than its real mode flag — the named follow-up in decision 0530.
|
||||
//! Wading, feet below a water surface down to the unit's swim boundary (`0.75·h`), takes the
|
||||
//! lookup's splash kit, falling back to dry when the class has none; deeper, the unit swims and
|
||||
//! steps are silent. The local player's wading is judged by depth too, not by its swim flag.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -76,15 +51,12 @@ use super::creature::CreatureVoices;
|
||||
use super::kit::{play_kit_ext, Bus, KitRef, PlayExtras, SoundCategory, SoundKits};
|
||||
use super::{AudioListener, SoundConfig, SoundOutput};
|
||||
|
||||
/// The loaded terrain-chain catalog. `pub(crate)`: [`crate::footprints`] reads the same chain's
|
||||
/// `TerrainType.Flags` gate (`leaves_footprints`) — one load, two footfall consumers.
|
||||
/// The terrain-chain catalog; [`crate::footprints`] reads its `TerrainType.Flags` gate too.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct Footsteps(pub(crate) FootstepCatalog);
|
||||
|
||||
/// **The foley's Z offset** — `0x45851d fadd [0x801628]`, a flat `2.0` added to the emitter's Z
|
||||
/// before the play. The rustle comes from the body, not the boots, and the reference lifts it by
|
||||
/// a fixed two yards rather than anything model-derived. WoW's Z is Bevy's Y at the same scale
|
||||
/// (`benilla_assets::coords`), so this adds to `translation.y`.
|
||||
/// The foley's height above the unit's origin (`0x45851d fadd [0x801628]`), a fixed lift, not
|
||||
/// model-derived; WoW's Z is Bevy's Y.
|
||||
const FOLEY_HEIGHT: f32 = 2.0;
|
||||
|
||||
fn load_footsteps(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
|
||||
@@ -104,21 +76,15 @@ fn load_footsteps(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
|
||||
|
||||
fn footstep_sounds(
|
||||
mut events: MessageReader<AnimSoundEvent>,
|
||||
// GlobalTransform: a mounted unit's steps are the MOUNT model's own tags, fired by the
|
||||
// mount CHILD entity — whose local Transform is the seat-relative ~origin. World position
|
||||
// is the only correct read for both parented and top-level sources (0441 fold-back).
|
||||
// GlobalTransform: a mount's steps fire on the mount child, whose local Transform is
|
||||
// seat-relative.
|
||||
units: Query<(&NetEntity, &GlobalTransform, Option<&CollisionHeight>)>,
|
||||
// The handler's state gates read the ROOT unit — a mount child's `$FSD` is the rider's
|
||||
// footfall, and it is the RIDER's stealth/hover/ghost the client tests (`0x623390`'s `this`
|
||||
// is the unit the mount model's event stream is registered against, `0x607a00`). Same
|
||||
// walk-to-root the footprint spawner does.
|
||||
// The state gates read the root unit: `0x623390`'s `this` is the unit the mount model's
|
||||
// events are registered against (`0x607a00`), the rider.
|
||||
parents: Query<&ChildOf>,
|
||||
root_state: Query<(Option<&ObjectStore>, Option<&MovementState>)>,
|
||||
footsteps: Option<Res<Footsteps>>,
|
||||
// The foley half: the material table and the creature catalog that answers a unit's
|
||||
// material, as ONE param (the 16-SystemParam ceiling this signature sits at), plus the item
|
||||
// store that answers a player's (a chest template ask rides the same once-per-entry
|
||||
// discipline every other consumer uses) and the object index its guid resolves through.
|
||||
// The foley's material table and creature catalog, one tuple for the 16-param ceiling.
|
||||
foley: (Option<Res<super::Materials>>, Option<Res<Creatures>>),
|
||||
objects: crate::net::Objects,
|
||||
mut items: Option<ResMut<Items>>,
|
||||
@@ -148,7 +114,7 @@ fn footstep_sounds(
|
||||
let Ok((net, transform, collision)) = units.get(ev.entity) else {
|
||||
continue;
|
||||
};
|
||||
// The three state gates, on the root unit (module docs): hover · stealth · player ghost.
|
||||
// The state gates on the root unit: hover, stealth, player ghost.
|
||||
let mut root = ev.entity;
|
||||
while let Ok(child_of) = parents.get(root) {
|
||||
root = child_of.parent();
|
||||
@@ -162,14 +128,11 @@ fn footstep_sounds(
|
||||
if store.is_some_and(|s| s.0.unit_is_stealthed() || s.0.player_is_ghost()) {
|
||||
continue;
|
||||
}
|
||||
// **The armor foley** — `0x6233d9 call [vt+0x8c]`, the handler's first act past the gates
|
||||
// above and ahead of every gate below (module docs). Emitted on the stepping entity, not
|
||||
// the root: a mount's own body is what rustles under the rider.
|
||||
// The armor foley (`0x6233d9`), ahead of the gates below, on the stepping entity: a
|
||||
// mount's body rustles under the rider.
|
||||
if let (Some(materials), Some(it)) = (materials.as_deref(), items.as_mut()) {
|
||||
let material = match net.kind {
|
||||
// The player override (`0x62fa30`) reads the chest through the *private*
|
||||
// inv-slot array, so this resolves for you and no one else — the reference's
|
||||
// own reach, not a restriction added here.
|
||||
// `0x62fa30` reads the chest through the private inv-slot array: self only.
|
||||
EntityKind::Player => {
|
||||
super::worn_chest_material(store, &objects, it, &net_commands)
|
||||
}
|
||||
@@ -178,8 +141,7 @@ fn footstep_sounds(
|
||||
.and_then(|d| creatures.as_deref()?.foley_material(d)),
|
||||
};
|
||||
if let Some(kit) = material.and_then(|m| materials.0.foley_kit(m)) {
|
||||
// The unit's own origin, raised — NOT the fired key's point. Byte-confirmed at
|
||||
// `0x45851d fadd [0x801628]` (see the terrain leg below for the split).
|
||||
// The unit's origin, raised, not the fired key's point (`0x45851d`).
|
||||
let mut at = transform.translation();
|
||||
at.y += FOLEY_HEIGHT;
|
||||
if let Err(e) = play_kit_ext(
|
||||
@@ -191,22 +153,20 @@ fn footstep_sounds(
|
||||
KitRef::Id(kit),
|
||||
Some(at),
|
||||
SoundCategory::Sfx,
|
||||
PlayExtras::default(), // bus 0, uncapped, volume 1.0 — `0x458870`'s own
|
||||
PlayExtras::default(), // bus 0, uncapped, volume 1.0: `0x458870`'s
|
||||
) {
|
||||
warn!("foley (kit {kit}): {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// The unit's footstep class (module docs): the voice row's class verbatim; zero or no
|
||||
// row = silent (the client's class-0 gate `0x6233ec` — no code default exists).
|
||||
// The voice row's class; zero or no row is silent (`0x6233ec`), with no default.
|
||||
let class = match net.display_id.and_then(|d| voices.0.for_display(d)) {
|
||||
Some(v) if v.footstep_class != 0 => v.footstep_class,
|
||||
_ => continue,
|
||||
};
|
||||
// Wading picks the splash slot; swimming (deeper than the wade ceiling) is silent.
|
||||
let wow = bevy_to_wow(transform.translation());
|
||||
// The unit's own room claim (0696) — before it, every unit walking under an ADT lake
|
||||
// picked the splash slot on dry indoor stone.
|
||||
// The unit's own room claim, so an indoor floor under an ADT lake is dry.
|
||||
let who = benilla_world::world_point::Subject::Unit(ev.entity);
|
||||
let depth = world
|
||||
.water_surface_at(who, wow)
|
||||
@@ -216,9 +176,7 @@ fn footstep_sounds(
|
||||
if depth.is_some_and(|d| d > wade_max) {
|
||||
continue;
|
||||
}
|
||||
// What is under the foot — the WMO leg when a building owns this column, the ADT ground
|
||||
// effect otherwise ([`benilla_world::surface`]). `None` is the client's −1: silent, and never a
|
||||
// reason to fall back to the ground beneath a floor.
|
||||
// `None` is the reference's -1: silent, never the ground beneath a floor.
|
||||
let Some(terrain) = world.terrain_type(&footsteps.0, who, transform.translation()) else {
|
||||
continue;
|
||||
};
|
||||
@@ -232,9 +190,7 @@ fn footstep_sounds(
|
||||
if kit == 0 {
|
||||
continue;
|
||||
}
|
||||
// Which leg answered, and what it said. The wrong-surface family is invisible in the kit
|
||||
// name alone once two legs can produce one (a `*Dirt` step is right indoors and wrong on a
|
||||
// dirt road ten yards away), so the next report should not need a DBC dump to triage.
|
||||
// Which surface answered: the kit name alone does not tell a WMO floor from the ADT.
|
||||
debug!(
|
||||
"footstep: {} terrain {terrain} class {class} kit {kit}",
|
||||
world
|
||||
@@ -248,12 +204,8 @@ fn footstep_sounds(
|
||||
&config,
|
||||
listener,
|
||||
KitRef::Id(kit),
|
||||
// **The terrain footstep is at the EVENT's point, the foley above is not** — one arm,
|
||||
// two sounds, two positions.
|
||||
// `0x623390` hands its `pos` argument to `0x458380`, where it is *both* the
|
||||
// audibility-radius reference and the 3D play position (`0x458434`); the foley leg
|
||||
// `0x6233d9 call [vt+0x8c]` is argument-less and re-derives `GetPosition + 2.0 z`
|
||||
// itself. Playing both at the same place would over-move one of them.
|
||||
// The step plays at the event's point: `0x623390` hands `pos` to `0x458380` as both
|
||||
// the audibility reference and the play position (`0x458434`).
|
||||
Some(ev.pos.unwrap_or_else(|| transform.translation())),
|
||||
SoundCategory::Sfx,
|
||||
PlayExtras {
|
||||
|
||||
@@ -1,33 +1,16 @@
|
||||
//! GameObject **display-slot** sounds — `GameObjectDisplayInfo.Sound[0..9]`, the ten kit columns a
|
||||
//! door/chest/goober plays as its animation runs.
|
||||
//! GameObject display-slot sounds: the ten `GameObjectDisplayInfo.Sound[0..9]` kits a door,
|
||||
//! chest or goober plays as its animation runs.
|
||||
//!
|
||||
//! **One function in the reference reads those columns, and one thing reaches it.** `0x5f4010` is
|
||||
//! the binary's only reader of `Sound[slot]` (whole-image census of the store `0xc0dce4` and its
|
||||
//! index `0xc0dcec`: six references, four gameplay readers, three of them the ModelName column),
|
||||
//! and its only caller is the GameObject M2 **animation-event** dispatcher `0x5f3e20` — registered
|
||||
//! per object at create (`0x5f7d1f` → vtable `+0x30`) on the **family-A** types only, which is
|
||||
//! exactly benilla's [`crate::go_anim::GoAnim`] population. `$GO0..5` address slots 0..5
|
||||
//! (Stand/Open/Loop/Close/Destroy/Opened), `$GC0..3` the four Custom slots 6..9. So a display slot
|
||||
//! is audible only when the object's own model authors the matching event keyframe *and* the clip
|
||||
//! carrying it is playing: **there is no state-transition sound path** (benilla decisions
|
||||
//! 1090/1867).
|
||||
//! The reference's only reader of the columns, `0x5f4010`, is called only from the GameObject M2
|
||||
//! animation-event dispatcher `0x5f3e20` (registered at create, `0x5f7d1f`, on the types
|
||||
//! [`crate::go_anim::GoAnim`] animates). So a slot sounds only when the model authors the event
|
||||
//! keyframe and its clip plays; there is no state-transition sound path.
|
||||
//!
|
||||
//! **The kit's `SoundEntries` flag `0x200` picks the lane** — `0x5f4051 call 0x458830`, whose only
|
||||
//! other consumer image-wide is a spell-visual path (`0x60edf0`). Clear ⇒ a positioned
|
||||
//! **one-shot** (`0x458870`). Set ⇒ the **ambient emitter
|
||||
//! pool** (`0x461d80`), the same 32-entry table placed doodads' `$DSL` registers into, with the
|
||||
//! handle cached in `[handler+0x18]` — one per object, shared with that object's own `$DSL` arm.
|
||||
//! The loop is dropped again by `0x5f40c0`, called from the state-machine dispatch `0x5f3cb0`
|
||||
//! ([`crate::go_anim::GoStateDispatch`]) and by the object's teardown; nothing else stops it, and
|
||||
//! there is no `$DSE` arm on this dispatcher at all.
|
||||
//!
|
||||
//! **What the corpus actually contains** (`benilla-extract goslotscan`, the instrument this module
|
||||
//! is checked against): 213 display rows carry a kit; per slot, *filled → reached by an authored
|
||||
//! tag* is Stand 24→18, Open 99→80, Loop 28→14, Close 42→38, Destroy 26→14, **Opened 0→0**,
|
||||
//! Custom0 77→56, Custom1 10→3, Custom2 5→1, Custom3 1→0. Thirty-seven live pairs name a looping
|
||||
//! kit — every campfire, brazier, torch, fountain, hologram and Stratholme portal — which is the
|
||||
//! whole reason the loop lane is not optional. Slot 5 Opened is empty data in 1.12.1: no display
|
||||
//! row fills it, so it costs nothing and can never be heard.
|
||||
//! The kit's `SoundEntries` flag `0x200` (`0x5f4051 call 0x458830`) picks the lane: clear is a
|
||||
//! positioned one-shot (`0x458870`), set registers into the 32-entry ambient emitter pool
|
||||
//! (`0x461d80`), one handle per object shared with its `$DSL`. Only `0x5f40c0`, from the state
|
||||
//! dispatch `0x5f3cb0` and teardown, drops that loop; this dispatcher has no `$DSE` arm.
|
||||
//! Slot 5 (Opened) is empty in every 1.12.1 display row.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -43,7 +26,7 @@ use super::emitter_pool::AmbientEmitterPool;
|
||||
use super::kit::{kit_looping, play_kit, KitRef, SoundCategory, SoundKits};
|
||||
use super::{AudioListener, SoundConfig, SoundOutput};
|
||||
|
||||
/// The display→sound-slots table (only displays with any non-zero slot; ~a third of the 1638).
|
||||
/// The display → sound-slots table, holding only displays with a non-zero slot.
|
||||
#[derive(Resource)]
|
||||
pub(super) struct GoSounds(GameObjectSounds);
|
||||
|
||||
@@ -62,11 +45,8 @@ fn load_go_sounds(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The GO display-slot an M2 animation-event tag addresses — the reference's GO event dispatcher
|
||||
/// `0x5f3e20` (the 1086 fold-back): `$GO0..5`
|
||||
/// → `Sound[0..5]`, `$GC0..3` → the Custom slots `Sound[6..9]`. Every other tag is not this
|
||||
/// channel's (`$SND`/`$DSO`/`$DSL` carry a literal kit id and ride the generic
|
||||
/// [`crate::sound::anim_events`] arms; `$SHK` is camera shake, no audio).
|
||||
/// The display slot an M2 event tag addresses in `0x5f3e20`: `$GO0..5` → `Sound[0..5]`,
|
||||
/// `$GC0..3` → the Custom slots `Sound[6..9]`.
|
||||
fn go_event_slot(ident: &[u8; 4]) -> Option<usize> {
|
||||
match ident {
|
||||
[b'$', b'G', b'O', d @ b'0'..=b'5'] => Some((d - b'0') as usize),
|
||||
@@ -76,27 +56,15 @@ fn go_event_slot(ident: &[u8; 4]) -> Option<usize> {
|
||||
}
|
||||
|
||||
/// Play the display-slot kits a GameObject's animation events name, and drop its ambient loop when
|
||||
/// the state machine dispatches — the two halves of `0x5f4010`'s lifecycle, in one system so the
|
||||
/// release can never land *after* the register that a state change is about to produce.
|
||||
/// the state machine dispatches.
|
||||
///
|
||||
/// That ordering is structural, not scheduled: the release rides
|
||||
/// [`crate::go_anim::GoStateDispatch`], written the frame the machine arms a new clip, while the
|
||||
/// new clip's own `$GOn` cannot fire before the frame *after* its arm ([`crate::creature_anim`]'s
|
||||
/// scan rule — an arm frame fires nothing). Releases are drained first here, so whichever order
|
||||
/// the scheduler picks for this system against [`crate::go_anim`], a release always precedes the
|
||||
/// register it precedes in the reference.
|
||||
///
|
||||
/// The load-bearing tenants: the fishing bobber's bite — Custom0's `$GC0` at t≈3.87 s → display
|
||||
/// 668 `Sound6` = kit 3355 "Fishing Hooked", fired **once per 0xB3** (the completion retire
|
||||
/// re-arms Stand before a second pass, decision 1100), beside the server's explicit
|
||||
/// `SMSG_PLAY_OBJECT_SOUND(3355)` ~200 ms earlier — and every lit prop in the world, whose
|
||||
/// `CampFireSmallLoop`/`TorchLoop`/`ElvenFountainSmallA` take the pool lane and hum until the
|
||||
/// object's state changes under them.
|
||||
/// Releases drain first so one always precedes the register it precedes in the reference: a
|
||||
/// dispatch lands the frame a new clip is armed, and that clip's `$GOn` cannot fire before the
|
||||
/// next frame (an arm frame fires nothing).
|
||||
pub(super) fn go_display_sounds(
|
||||
mut dispatched: MessageReader<GoStateDispatch>,
|
||||
mut events: MessageReader<crate::creature_anim::AnimSoundEvent>,
|
||||
// `GlobalTransform`: a GameObject is a root entity today, but the event's position is the
|
||||
// model's placement in the reference either way, and reading the world pose cannot be wrong.
|
||||
// The world pose: the event position is the model's placement in the reference.
|
||||
gos: Query<(&NetEntity, &GlobalTransform)>,
|
||||
go_sounds: Option<Res<GoSounds>>,
|
||||
kits: Option<ResMut<SoundKits>>,
|
||||
@@ -105,15 +73,12 @@ pub(super) fn go_display_sounds(
|
||||
config: Res<SoundConfig>,
|
||||
listener: Res<AudioListener>,
|
||||
mut pool: ResMut<AmbientEmitterPool>,
|
||||
// Kit ids already complained about. A `$GOn` on a looping rest clip re-fires every band pass
|
||||
// — the centaur teleporter's Closed band is 0.334 s — so an unresolvable id would warn three
|
||||
// times a second for the object's whole life. The same flood [`super::anim_events`] learned
|
||||
// to avoid at 420 lines in one run past Darnassus.
|
||||
// Kit ids already warned about: a `$GOn` on a looping rest clip re-fires every band pass
|
||||
// (0.334 s on the centaur teleporter), so an unresolvable id would warn several times a second.
|
||||
mut complained: Local<std::collections::HashSet<u32>>,
|
||||
) {
|
||||
// `0x5f3cb0`'s first act is `0x5f3cc8 call 0x5f40c0` — release the object's display-sound loop
|
||||
// — and it runs before the machine picks the new substate's animation. Draining it here even
|
||||
// when nothing else is resolvable keeps the two halves from drifting apart.
|
||||
// `0x5f3cb0` first releases the loop (`0x5f3cc8 call 0x5f40c0`), before it picks the new
|
||||
// substate's animation; drained even when nothing else resolves.
|
||||
for d in dispatched.read() {
|
||||
super::emitter_pool::release(&mut pool, d.0);
|
||||
}
|
||||
@@ -128,8 +93,7 @@ pub(super) fn go_display_sounds(
|
||||
let Some(slot) = go_event_slot(&ev.ident) else {
|
||||
continue;
|
||||
};
|
||||
// A creature clip authoring a `$GO*`/`$GC*` tag doesn't resolve here: the query wants a
|
||||
// GameObject's display row, and only GO entities carry one in the display-slot table.
|
||||
// Only a GameObject has a display row in this table; a creature's `$GO*` tag is ignored.
|
||||
let Ok((net, transform)) = gos.get(ev.entity) else {
|
||||
continue;
|
||||
};
|
||||
@@ -141,23 +105,16 @@ pub(super) fn go_display_sounds(
|
||||
.and_then(|d| go_sounds.0.slots(d))
|
||||
.map(|s| s[slot])
|
||||
.unwrap_or(0);
|
||||
// `0x458830` fails a null id and `0x5f4010` returns — an unfilled column is silence, not a
|
||||
// fallback. 97 shipped models author a `$GO0` against a zero Stand column (the bobber
|
||||
// among them); that is the reference being quiet, not a miss.
|
||||
// An unfilled column is silence: `0x458830` fails a null id and `0x5f4010` returns.
|
||||
if kit == 0 {
|
||||
continue;
|
||||
}
|
||||
// **Where the key fired, not where the object stands** (decision 1904): `0x5f3e20`'s
|
||||
// `[ebp+0x10]` is the kernel's `eventWorldPos` and both lanes below take it verbatim —
|
||||
// `0x458870(id, pos, -1, 1.0f)` for the one-shot, `0x461d80(id, pos, 0)` for the pool. It
|
||||
// is the difference between a portal's hum coming from the portal and from the model's
|
||||
// pivot: 82 of the 135 shipped `$GC0` records sit off their origin, out to 63.4 yd on
|
||||
// `orc_waterwheel.m2`, and 83 of 177 `$GO0`s do.
|
||||
// Where the key fired, not the object's origin: both lanes take `0x5f3e20`'s event world
|
||||
// position (`[ebp+0x10]`) verbatim, `0x458870(id, pos, -1, 1.0f)` and
|
||||
// `0x461d80(id, pos, 0)`. Many shipped events sit off their origin, up to 63.4 yd.
|
||||
let pos = ev.pos.unwrap_or_else(|| transform.translation());
|
||||
// The lane select. A looping kit is NOT a looping channel here: it is a *registration* in
|
||||
// the shared emitter pool, which is what makes one hum follow you down a row of braziers
|
||||
// instead of thirty channels stacking — and what makes a re-crossing of the marker (the
|
||||
// per-pass event re-fire of an armed looping clip) a no-op instead of a restart.
|
||||
// A looping kit registers in the shared emitter pool, not a channel of its own, so a row
|
||||
// of braziers is one hum and a re-fired marker is a no-op, not a restart.
|
||||
if kit_looping(&kits, kit) {
|
||||
super::emitter_pool::register(&mut pool, ev.entity, kit, pos, listener);
|
||||
continue;
|
||||
@@ -193,9 +150,7 @@ pub(super) fn plugin(app: &mut App) {
|
||||
mod tests {
|
||||
use super::go_event_slot;
|
||||
|
||||
/// The dispatcher's slot table (`0x5f3e20`): `$GO0..5` are the
|
||||
/// first six display slots, `$GC0..3` the four Custom slots 6..9 — the bobber's splash is
|
||||
/// `$GC0` → slot 6. Out-of-range digits and other families are not this channel.
|
||||
/// The dispatcher's slot table (`0x5f3e20`).
|
||||
#[test]
|
||||
fn event_tags_map_to_display_slots() {
|
||||
assert_eq!(go_event_slot(b"$GO0"), Some(0));
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
//! Glue-screen audio (decision 0423's polish pass) — the two sounds of the pre-world screens:
|
||||
//! Glue-screen audio: the clicks and the title theme.
|
||||
//!
|
||||
//! - **Clicks**: the char select/create screens emit [`GlueSound`] messages naming the SoundEntries
|
||||
//! kits the 1.12 GlueXML plays verbatim (`gsCharacterCreationClass`, `gsCharacterSelectionEnterWorld`,
|
||||
//! …); [`play_glue_sounds`] drains them into the kit player as 2D SFX — the same shape as
|
||||
//! [`super::ui`]'s `PlaySound` seam, but from the glue screens (which have no Lua VM).
|
||||
//! - **Music**: the glue title theme (`GlueParent.lua`'s `CurrentGlueMusic`,
|
||||
//! `Sound\Music\GlueScreenMusic\wow_main_theme.mp3`), streamed from the login screen on
|
||||
//! (decision 0539) and kept across the glue screens (the ref keeps it through select ⇄ create).
|
||||
//! **The click into the world does not end it** (1550/1553, `0x46c258`): it plays unbroken
|
||||
//! through the whole map load, and the stop is armed by the *load draining* — a 3.0 s fade, still
|
||||
//! behind the loading screen. Re-entering the glue after a logout starts it again.
|
||||
//! - Clicks: the char select and create screens emit [`GlueSound`] with the SoundEntries kit names
|
||||
//! the 1.12 GlueXML plays, drained into the kit player as 2D SFX.
|
||||
//! - Music: `GlueParent.lua`'s `CurrentGlueMusic`, streamed from the login screen and kept across
|
||||
//! the glue screens. Entering the world does not end it (`0x46c258`): it plays through the map
|
||||
//! load and fades over 3.0 s once the load drains, still behind the loading screen.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -19,38 +14,27 @@ use benilla_assets::{LockRecover, WorldAssets};
|
||||
use super::kit::{self, KitRef, SoundCategory, SoundKits};
|
||||
use super::{mixer, SoundConfig, SoundOutput};
|
||||
|
||||
/// The glue music file — a frozen fact of the 1.12 GlueXML (`GlueParent.lua`, `CurrentGlueMusic`).
|
||||
const GLUE_MUSIC: &str = "Sound\\Music\\GlueScreenMusic\\wow_main_theme.mp3";
|
||||
|
||||
/// The theme's own volume, under the Music slider: **0.8** — `0x45aeb0` starts the glue stream at
|
||||
/// `0x3f4ccccd` on both its arms, where the city-intro playlist and the in-world Lua `PlayMusic`
|
||||
/// use 1.0f. It is a plain scalar on the MusicVolume
|
||||
/// category (the stream's flag word is 2, bit `0x2` = `[0x87cef8]`), exactly like a zone track's
|
||||
/// kit volume, so it multiplies rather than replaces the slider.
|
||||
/// The theme's own volume, multiplied into the Music slider: `0x45aeb0` starts the glue stream at
|
||||
/// 0.8 (`0x3f4ccccd`) on the MusicVolume category (flag word 2, bit `0x2` = `[0x87cef8]`).
|
||||
const GLUE_MUSIC_VOLUME: f32 = 0.8;
|
||||
|
||||
/// The stop-fade armed when the world's load drains: **3.0 s**, `[0x803248]` in `.rdata`
|
||||
/// (`0x40400000`) — the constant `0x45aeb0`'s NULL arm hands `0x45b050`, and the fade is **linear
|
||||
/// in amplitude**, not in dB (`0x7a5a50` ramps FMOD's 0–255 level). Not 1.0 s: that is
|
||||
/// `StopGlueMusic`'s literal, and `StopGlueMusic` is a `"movie"`-screen call the world entry never
|
||||
/// makes.
|
||||
/// The stop-fade armed when the world's load drains: 3.0 s (`[0x803248]`, handed to `0x45b050` by
|
||||
/// `0x45aeb0`'s NULL arm), linear in amplitude (`0x7a5a50`). `StopGlueMusic`'s 1.0 s is a
|
||||
/// movie-screen call the world entry never makes.
|
||||
const GLUE_MUSIC_FADE_OUT_MS: u64 = 3000;
|
||||
|
||||
/// A glue-screen `PlaySound` — the SoundEntries kit name the 1.12 GlueXML plays for this click.
|
||||
/// A glue-screen `PlaySound`: the SoundEntries kit name the 1.12 GlueXML plays for this click.
|
||||
#[derive(Message)]
|
||||
pub(crate) struct GlueSound(pub(crate) &'static str);
|
||||
|
||||
/// The held glue-music stream (non-`Sync` handle — non-Send state, like [`super::zone`]'s), plus
|
||||
/// its starvation watch (decision 1109). The handle is held through the stop-fade, not dropped at
|
||||
/// it: the fade rides out under the world-entry load burst, which is exactly the crackle-prone
|
||||
/// window, and [`watch_glue_music`] can only see a stream it still holds. Since 1550 the theme
|
||||
/// itself rides that whole burst too — all the more reason for the watch to be able to see it.
|
||||
/// The held glue-music stream and its starvation watch. The handle is kept through the stop-fade
|
||||
/// so the watch still sees the stream during the world-entry load burst.
|
||||
struct GlueMusic {
|
||||
handle: Option<mixer::StreamingSoundHandle<kira::sound::FromFileError>>,
|
||||
watch: mixer::StreamWatch,
|
||||
/// The stop-fade is armed and running. The per-frame slider feed stands off while it is set,
|
||||
/// so a live `MusicVolume` change cannot stomp the ramp back up to full mid-fade — the hazard
|
||||
/// [`super::zone`]'s ambience crossfade documents from the other side.
|
||||
/// The stop-fade is running; the per-frame slider feed stands off so it cannot undo the ramp.
|
||||
fading: bool,
|
||||
}
|
||||
|
||||
@@ -64,7 +48,7 @@ impl Default for GlueMusic {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain glue clicks into the kit player (2D SFX, by name — the client's name-registry path).
|
||||
/// Drain glue clicks into the kit player as 2D SFX, by kit name.
|
||||
fn play_glue_sounds(
|
||||
mut msgs: MessageReader<GlueSound>,
|
||||
kits: Option<ResMut<SoundKits>>,
|
||||
@@ -92,9 +76,8 @@ fn play_glue_sounds(
|
||||
}
|
||||
}
|
||||
|
||||
/// While on the select screen, start the title theme if it isn't up. Runs per-frame (cheap early
|
||||
/// return once playing) rather than on the state edge: the app *boots* into CharSelect, and the
|
||||
/// initial `OnEnter` fires before the mixer/chain exist — an edge-triggered start misses it.
|
||||
/// On the glue screens, start the title theme if it is not up. Per-frame, not on the state edge:
|
||||
/// the app boots into the glue before the mixer and chain exist, so the first `OnEnter` is early.
|
||||
fn start_glue_music(
|
||||
mut music: NonSendMut<GlueMusic>,
|
||||
mut out: NonSendMut<SoundOutput>,
|
||||
@@ -102,14 +85,11 @@ fn start_glue_music(
|
||||
config: Res<SoundConfig>,
|
||||
) {
|
||||
if music.handle.is_some() && !music.fading {
|
||||
return; // still playing (select ⇄ create hops re-enter CharSelect)
|
||||
return; // still playing (select ⇄ create re-enters CharSelect)
|
||||
}
|
||||
// A theme caught mid-fade is NOT "still playing": returning to the glue inside the 3 s ramp
|
||||
// must bring it back at once. The reference gets there by bookkeeping — the enter-world stop
|
||||
// clears the cached track name (`0x45afec`), so `SetGlueScreen`'s next `PlayGlueMusic` misses
|
||||
// the same-name early-out, stops the outgoing stream and opens a fresh one. Same outcome here:
|
||||
// the old handle is dropped below with its ramp already armed, so it finishes fading on the
|
||||
// backend while the new stream starts at full.
|
||||
// A theme mid-fade restarts at once: the enter-world stop clears the cached track name
|
||||
// (`0x45afec`), so the next `PlayGlueMusic` opens a fresh stream. The old handle drops below
|
||||
// with its ramp armed and finishes fading on the backend.
|
||||
let (Some(assets), Some(mixer_ref)) = (assets, out.mixer.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
@@ -136,24 +116,13 @@ fn start_glue_music(
|
||||
}
|
||||
}
|
||||
|
||||
/// **The world's load drained — arm the theme's fade** (decision 1553, correcting 1550's trigger).
|
||||
/// Arm the theme's 3.0 s fade when the world's load drains.
|
||||
///
|
||||
/// This is the reference's own trigger, and it is not a music event at all: `CGlueMgr::Update`
|
||||
/// state 8 runs a *second* pass every frame after the entry (`[0xb41d94] == 1`), spinning on the
|
||||
/// AsyncFileLoader (`0x443e20`) while any read is outstanding; the frame the queue drains it calls
|
||||
/// `0x45aeb0(NULL)` → `0x45b050(3.0f)` and then sends `CMSG_PLAYER_LOGIN`. So the theme sounds
|
||||
/// through the click and the whole map load, and dies on a 3.0 s fade armed *behind* the loading
|
||||
/// screen — never on the click (1550's own `stop_glue_music`), and never on the first world track
|
||||
/// (1550's replacement, which was the right *feel* found from a misread of the branch).
|
||||
///
|
||||
/// benilla's analogue of "the async queue drained" is the cover's own clear condition — every
|
||||
/// wanted tile spawned, placements up, colliders quiet ([`crate::loading_screen`]) — so the fade is
|
||||
/// armed on `world_hold`'s **falling edge**. The edge is tracked every frame but only acted on
|
||||
/// **in the world**, which is what excludes the logout blackout: that cover drops on the frame the
|
||||
/// state leaves `InWorld` (0738), and firing there would fade the theme the glue had just restarted.
|
||||
///
|
||||
/// The handle is deliberately kept through the fade — the [`GlueMusic`] docs say why — and reaped
|
||||
/// by [`watch_glue_music`] once the fade lands on `Stopped`.
|
||||
/// The reference's trigger: `CGlueMgr::Update` state 8 spins each frame after entry
|
||||
/// (`[0xb41d94] == 1`) while the AsyncFileLoader (`0x443e20`) has reads outstanding; the frame it
|
||||
/// drains it calls `0x45aeb0(NULL)` → `0x45b050(3.0f)`, then sends `CMSG_PLAYER_LOGIN`. benilla's
|
||||
/// analogue is `world_hold`'s falling edge ([`crate::loading_screen`]), acted on only in the world
|
||||
/// so the logout blackout's drop does not fade the theme the glue just restarted.
|
||||
fn hand_off_glue_music(
|
||||
config: Res<SoundConfig>,
|
||||
state: Res<State<ClientState>>,
|
||||
@@ -172,27 +141,19 @@ fn hand_off_glue_music(
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame stream health + handle reaping: the starvation watch (decision 1109) over the held
|
||||
/// theme, and the drop once it reaches `Stopped` — after the handoff fade lands, or at the theme's
|
||||
/// natural end. On the glue screens that end re-arms [`start_glue_music`], which brings the theme
|
||||
/// back from the top — the login screen no longer falls silent for good after one play-through.
|
||||
/// **In the world it does not**, and that asymmetry is the point of [`start_glue_music`]'s run
|
||||
/// condition: a theme that outlives a music-less zone must run out, not loop under it (1550).
|
||||
/// The starvation watch over the held theme, and the handle's drop once it reaches `Stopped`.
|
||||
/// On the glue screens [`start_glue_music`] then restarts it; in the world it runs out and stays
|
||||
/// out.
|
||||
fn watch_glue_music(
|
||||
mut music: NonSendMut<GlueMusic>,
|
||||
// **`Real`, not the generic clock** — the watch compares elapsed time against the audio
|
||||
// stream's own position, so it must be fed WALL time. `Res<Time>` is the paced virtual clock:
|
||||
// `frame_pace` median-snaps it and caps it at `max_delta`, so during a loading burst it
|
||||
// neither matches wall nor errs in a predictable direction, and the "ms of injected silence"
|
||||
// it produced was not a measurement of anything.
|
||||
// Wall time: the watch compares against the stream's own position, and the paced virtual
|
||||
// clock is snapped and capped by `frame_pace`.
|
||||
time: Res<Time<bevy::time::Real>>,
|
||||
config: Res<SoundConfig>,
|
||||
) {
|
||||
let music = &mut *music;
|
||||
// The Music slider is live on this stream, as it is on the world's (the
|
||||
// `MusicVolume` handler's re-apply walker `0x7a6660(ecx=2)` matches the glue wrapper, so moving
|
||||
// the slider rescales the playing theme in place — mid-loading-screen included). Stand off
|
||||
// while the stop-fade runs; see [`GlueMusic::fading`].
|
||||
// The Music slider rescales the playing theme in place (the `MusicVolume` re-apply walker
|
||||
// `0x7a6660(ecx=2)` matches the glue stream), except while the stop-fade runs.
|
||||
if !music.fading {
|
||||
if let Some(h) = music.handle.as_mut() {
|
||||
h.set_volume(
|
||||
@@ -205,10 +166,7 @@ fn watch_glue_music(
|
||||
return;
|
||||
};
|
||||
if h.state() == kira::sound::PlaybackState::Stopped {
|
||||
// The theme's end, said out loud — the counterpart to the `glue music: <path>` start line.
|
||||
// With the theme now outliving world entry (1550), "did it actually stop, or is it playing
|
||||
// under the world?" is a real question about a stream nothing else reports on, and it must
|
||||
// be answerable from an ordinary log rather than from a mix capture.
|
||||
// Logged: the theme outlives world entry, and nothing else reports when it stops.
|
||||
info!("glue music: stream ended");
|
||||
music.handle = None;
|
||||
music.fading = false;
|
||||
@@ -218,8 +176,7 @@ fn watch_glue_music(
|
||||
music.watch.feed(h, f64::from(time.delta_secs()));
|
||||
}
|
||||
|
||||
/// Report the glue theme's voice into the global budget (decision 1557) — it rides `InWorld`
|
||||
/// since 1550, so it occupies a channel there like anything else.
|
||||
/// Report the glue theme's voice into the global budget; it plays into `InWorld` too.
|
||||
fn report_stream_voices(music: NonSend<GlueMusic>, mut out: NonSendMut<super::SoundOutput>) {
|
||||
out.glue_streams = usize::from(
|
||||
music
|
||||
@@ -236,13 +193,12 @@ pub(super) fn plugin(app: &mut App) {
|
||||
Update,
|
||||
(
|
||||
play_glue_sounds,
|
||||
// The theme starts at the login screen (the ref's `AccountLogin_OnShow` sets the
|
||||
// same `wow_main_theme` — decision 0539) and keeps across the glue screens.
|
||||
// The theme starts at the login screen (`AccountLogin_OnShow` sets the same
|
||||
// `wow_main_theme`) and keeps across the glue screens.
|
||||
start_glue_music
|
||||
.run_if(in_state(ClientState::Login).or(in_state(ClientState::CharSelect))),
|
||||
// No run condition on either: the theme rides `InWorld` now (1550), so its handoff
|
||||
// and the watch both have to keep running there — and the entry load burst is
|
||||
// exactly the window the watch exists for (1109).
|
||||
// No run condition: the theme plays into `InWorld`, and the entry load burst is
|
||||
// the window the watch exists for.
|
||||
hand_off_glue_music,
|
||||
watch_glue_music
|
||||
.after(start_glue_music)
|
||||
|
||||
@@ -1,37 +1,24 @@
|
||||
//! NPC vocal lines — the two systems an NPC greets/farewells with, corrected against the
|
||||
//! director's observations and the reference.
|
||||
//! NPC greetings: the select greeting and the interaction-window hello and goodbye.
|
||||
//!
|
||||
//! Both systems share only the **data chain** (`NetEntity.display_id` →
|
||||
//! `CreatureDisplayInfo.field[11]` NPCSoundID → `NPCSounds {hello +0x4, goodbye +0x8, pissed +0xc}`
|
||||
//! → `SoundEntries` kit) and the **per-unit channel latch** (`[unit+0xb1c]`: a unit refuses a new
|
||||
//! line while its previous one still sounds; the played channel is tagged with the unit entity via
|
||||
//! [`kit::source_playing`], and the pump's reap-on-stop is the release). Playback is 3D at the NPC.
|
||||
//! Both read `CreatureDisplayInfo.field[11]` NPCSoundID → `NPCSounds {hello +0x4, goodbye +0x8,
|
||||
//! pissed +0xc}` → a `SoundEntries` kit, played 3D at the NPC, and both respect the unit's channel
|
||||
//! latch `[unit+0xb1c]`: a unit refuses a new line while its last one sounds
|
||||
//! ([`kit::source_playing`]).
|
||||
//!
|
||||
//! ## System 1 — the SELECT greeting (`0x60c270`, left-click)
|
||||
//! ## The select greeting (`0x60c270`, left-click)
|
||||
//!
|
||||
//! Left-clicking a unit ([`crate::target::select_on_click`], the client's `0x4925d0` which fires
|
||||
//! *before* `SetTarget`) plays the **variation-cycling** welcome ([`NpcGreetingRequest`]). A global
|
||||
//! sequence counter per NPC ([`GreetSeq`], the client's `[0xc4d970]`, reset when the greeted NPC
|
||||
//! changes) steps `0x623910`'s law ([`greet_line`]): **5 HELLO takes** (each a free-pick within the
|
||||
//! hello kit, so they vary) → **the PISSED kit's variations in order** → **wrap + reset**. This is
|
||||
//! "left-click greets, repeat left-clicks cycle, eventually the annoyed line."
|
||||
//! Left-clicking a unit ([`crate::target::select_on_click`], the reference's `0x4925d0`, before
|
||||
//! `SetTarget`) plays the cycling welcome ([`NpcGreetingRequest`]). A counter per NPC
|
||||
//! ([`GreetSeq`], `[0xc4d970]`, reset when the greeted NPC changes) steps `0x623910`
|
||||
//! ([`greet_line`]): five hello takes, then the pissed kit's takes in order, then a wrap.
|
||||
//!
|
||||
//! ## System 2 — the interaction-WINDOW hello/goodbye (`0x60c3b0`, right-click)
|
||||
//! ## The window hello and goodbye (`0x60c3b0`, right-click)
|
||||
//!
|
||||
//! Opening any interaction window (gossip/vendor/trainer/bank/mail/trade/quest — each class's
|
||||
//! `SetActiveNPC 0x4e1fa0`-style method, `0x4930d0` with **14 call sites, none a mouseover**) plays
|
||||
//! the NPC's HELLO **once, non-cycling, `variation = -1`** (`0x60c464`: FMOD free-picks a take — so
|
||||
//! it *sounds* different from the cycling select greeting, but it is the **same** `+0x4` slot, not a
|
||||
//! separate "vendor" line). The window **closing to nothing** plays GOODBYE (`+0x8`, `welcome=0`).
|
||||
//! A **swap** — opening NPC B's window while A's is open — plays B's hello and **suppresses A's
|
||||
//! goodbye** (`0x4930fd`: the internal clear gets `dl = (new_guid != 0) = 1`). So goodbye is gated
|
||||
//! on "the dialog is closing to *nothing*," NOT on the target changing — the v1 "goodbye ≈
|
||||
//! target-clear" reading is refuted. benilla tracks the **active interaction NPC** as the union of
|
||||
//! its shipped window sessions ([`MerchantOpen`]/[`GossipState`]/[`QuestGiver`]/[`TrainerOpen`]);
|
||||
//! despawn resolves nothing, the client's suppressed teardown clear. The trainer belongs in that
|
||||
//! union like every other interaction window: gossip→trainer on the same NPC is a *swap*, not a
|
||||
//! close-to-nothing, so its goodbye is suppressed — the trainer's absence from the union is why the
|
||||
//! 0237 arc briefly mis-played a goodbye on that transition.
|
||||
//! Opening any interaction window (`0x4930d0`, 14 call sites, none a mouseover) plays the NPC's
|
||||
//! hello once with a free-picked take (`0x60c464`), the same `+0x4` slot. Closing to nothing plays
|
||||
//! the goodbye (`+0x8`). A swap to another NPC's window plays the new hello and suppresses the old
|
||||
//! goodbye (`0x4930fd`: `dl = (new_guid != 0)`). The active NPC is the union of the window sessions
|
||||
//! ([`MerchantOpen`], [`GossipState`], [`QuestGiver`], [`TrainerOpen`]).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -50,13 +37,10 @@ use benilla_world::view::WorldCamera;
|
||||
use super::kit::{self, play_kit_ext, KitRef, Latch, PlayExtras, SoundCategory, SoundKits};
|
||||
use super::{SoundConfig, SoundOutput};
|
||||
|
||||
/// The number of HELLO takes the select greeter plays before it steps into the pissed kit
|
||||
/// (`0x62394a: cmp ebx,5; jge …`).
|
||||
/// Hello takes before the select greeter steps into the pissed kit (`0x62394a: cmp ebx,5`).
|
||||
const HELLO_TAKES: usize = 5;
|
||||
|
||||
/// Request to play an NPC's **select** greeting (System 1, the variation-cycling path) — written by
|
||||
/// [`crate::target::select_on_click`] on the left-click select gesture (`0x60c270`, "before
|
||||
/// `SetTarget`"; director-confirmed: left-click greets and repeat left-clicks cycle).
|
||||
/// Plays an NPC's select greeting, sent by [`crate::target::select_on_click`] (`0x60c270`).
|
||||
#[derive(Message, Clone, Copy)]
|
||||
pub(crate) struct NpcGreetingRequest {
|
||||
pub(crate) npc: Entity,
|
||||
@@ -66,17 +50,15 @@ pub(crate) struct NpcGreetingRequest {
|
||||
#[derive(Resource)]
|
||||
struct NpcGreetings(NpcGreetingCatalog);
|
||||
|
||||
/// System 1's per-NPC variation counter — the client's `[0xc4d970]` (the sequence index) keyed by
|
||||
/// `[0xc4d908]` (the tracked NPC; the counter resets when it changes). Advanced only on a play that
|
||||
/// actually happened (the latch gate sits before the counter, `0x60c28c`).
|
||||
/// The select greeting's counter `[0xc4d970]`, keyed by the tracked NPC `[0xc4d908]` and reset
|
||||
/// when it changes; it advances only on a play that started (`0x60c28c`).
|
||||
#[derive(Default)]
|
||||
struct GreetSeq {
|
||||
tracked: Option<Entity>,
|
||||
seq: usize,
|
||||
}
|
||||
|
||||
/// Which line + variation a select play at sequence `seq` produces (`0x623910`). `variation = None`
|
||||
/// is the client's `-1` free-pick.
|
||||
/// The line and take a select play produces (`0x623910`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum GreetLine {
|
||||
/// HELLO (`+0x4`), free-pick take.
|
||||
@@ -85,10 +67,8 @@ enum GreetLine {
|
||||
Pissed(usize),
|
||||
}
|
||||
|
||||
/// The select-greeter sequence law (`0x623910`, byte-exact): the first [`HELLO_TAKES`] plays are
|
||||
/// HELLO free-picks; then the pissed kit's `pissed_variations` takes in order; then it **wraps** —
|
||||
/// a final HELLO free-pick that resets the counter to 0. Returns the line to play now and the
|
||||
/// counter value to commit for next time.
|
||||
/// `0x623910`: [`HELLO_TAKES`] hello free-picks, then the pissed kit's takes in order, then a
|
||||
/// hello that resets the counter. Returns the line and the next counter.
|
||||
fn greet_line(seq: usize, pissed_variations: usize) -> (GreetLine, usize) {
|
||||
if seq < HELLO_TAKES {
|
||||
(GreetLine::Hello, seq + 1)
|
||||
@@ -97,15 +77,13 @@ fn greet_line(seq: usize, pissed_variations: usize) -> (GreetLine, usize) {
|
||||
if p < pissed_variations {
|
||||
(GreetLine::Pissed(p), seq + 1)
|
||||
} else {
|
||||
// edi >= count: HELLO, variation -1, return 0 — the wrap (`0x62…`, counter reset).
|
||||
// edi >= count: hello, variation -1, counter reset.
|
||||
(GreetLine::Hello, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The vocal an active-interaction-NPC transition produces (System 2, the client's `SetActiveNPC`
|
||||
/// semantics): opening or swapping to a new NPC plays its **hello** (and suppresses any prior
|
||||
/// goodbye); closing to *nothing* plays the old NPC's **goodbye**.
|
||||
/// The vocal an active-NPC change produces (`SetActiveNPC`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum WindowVocal {
|
||||
Hello(u64),
|
||||
@@ -113,12 +91,8 @@ enum WindowVocal {
|
||||
None,
|
||||
}
|
||||
|
||||
/// The active interaction NPC: the union of every shipped window session that shares the left panel
|
||||
/// slot (they are mutually exclusive after the OnHide→CloseX reconcile — decision 0095). System 2
|
||||
/// diffs THIS across frames, so **every** NPC-bound window must be in it — a window left out makes a
|
||||
/// swap *to* it look like a close-to-nothing and spuriously plays the prior window's goodbye. That
|
||||
/// omission is exactly what mis-played a goodbye on gossip→trainer until the trainer joined the union
|
||||
/// (decision 0237); a new window (bank, mail, …) must be added here too.
|
||||
/// The active interaction NPC, the union of the window sessions. Every NPC-bound window must be
|
||||
/// in it: one left out makes a swap to it look like a close and plays a stray goodbye.
|
||||
fn active_interaction_npc(
|
||||
merchant: &MerchantOpen,
|
||||
gossip: &GossipState,
|
||||
@@ -132,15 +106,12 @@ fn active_interaction_npc(
|
||||
.or(trainer.trainer)
|
||||
}
|
||||
|
||||
/// System 2's transition law. `prev`/`active` are the previously- and currently-active interaction
|
||||
/// NPC guids (the union of the open window sessions). A swap (`Some(a) → Some(b)`) is a hello for
|
||||
/// `b` with **no** goodbye for `a` — the byte-verified suppression (`dl = new_guid != 0`).
|
||||
/// Open or swap plays the new NPC's hello with no goodbye for the old (`dl = new_guid != 0`);
|
||||
/// closing to nothing plays the goodbye.
|
||||
fn window_vocal(prev: Option<u64>, active: Option<u64>) -> WindowVocal {
|
||||
match (prev, active) {
|
||||
(p, a) if p == a => WindowVocal::None,
|
||||
// open or swap → hello for the new NPC (prior goodbye suppressed).
|
||||
(_, Some(new)) => WindowVocal::Hello(new),
|
||||
// closing to nothing → the old NPC's goodbye.
|
||||
(Some(old), None) => WindowVocal::Goodbye(old),
|
||||
(None, None) => WindowVocal::None,
|
||||
}
|
||||
@@ -161,8 +132,7 @@ fn load_npc_greetings(mut commands: Commands, assets: Option<Res<WorldAssets>>)
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared preconditions + data lookup: a non-player unit (type-mask bit 3 set, bit 4 clear),
|
||||
/// alive, whose display carries an NPCSoundID row. Returns the greeting row + world position.
|
||||
/// A live non-player unit (type-mask bit 3 set, bit 4 clear) whose display has an NPCSoundID row.
|
||||
fn resolve(
|
||||
npc: Entity,
|
||||
units: &Query<(&NetEntity, &Transform, Option<&ObjectStore>)>,
|
||||
@@ -179,8 +149,8 @@ fn resolve(
|
||||
Some((row, transform.translation))
|
||||
}
|
||||
|
||||
/// Play one line for `npc` at kit `kit_id` (0 = no such line → nothing), variation `variant`
|
||||
/// (`None` = free-pick), latch-gated on the unit's channel (`0x60c28c`) and tagged with the unit.
|
||||
/// Plays one line, kit 0 being none and `None` a free-picked take, gated on the unit's channel
|
||||
/// latch (`0x60c28c`).
|
||||
fn play_line(
|
||||
npc: Entity,
|
||||
kit_id: u32,
|
||||
@@ -207,9 +177,7 @@ fn play_line(
|
||||
PlayExtras {
|
||||
variant,
|
||||
source: Some(npc),
|
||||
// The one play that takes `[unit+0xb1c]`: while this channel lives, the NPC refuses
|
||||
// another hello. Decision 1399's marker — before it, every source-tagged channel on
|
||||
// the unit answered this question and a body loop could mute the greeting.
|
||||
// The one play that takes `[unit+0xb1c]`; the unit's other sounds do not.
|
||||
latch: Latch::Greeting,
|
||||
..default()
|
||||
},
|
||||
@@ -218,7 +186,7 @@ fn play_line(
|
||||
}
|
||||
}
|
||||
|
||||
/// System 1 — the select greeting: drain [`NpcGreetingRequest`]s and play the cycling welcome.
|
||||
/// The select greeting.
|
||||
fn play_select_greetings(
|
||||
mut reqs: MessageReader<NpcGreetingRequest>,
|
||||
mut seq: Local<GreetSeq>,
|
||||
@@ -250,8 +218,8 @@ fn play_select_greetings(
|
||||
seq: 0,
|
||||
};
|
||||
}
|
||||
// The latch gate sits before the counter — a re-click while the line still sounds neither
|
||||
// plays nor advances (`0x60c28c`).
|
||||
// The latch gate precedes the counter: a re-click while the line sounds neither plays nor
|
||||
// advances (`0x60c28c`).
|
||||
if kit::source_playing(&out, req.npc) {
|
||||
continue;
|
||||
}
|
||||
@@ -263,8 +231,8 @@ fn play_select_greetings(
|
||||
if kit_id == 0 {
|
||||
continue;
|
||||
}
|
||||
// Play, and commit the next counter only if the play actually started (`0x60c398`); the
|
||||
// latch gate above guarantees the channel was idle, so a live channel now = success.
|
||||
// Commit the counter only if the play started (`0x60c398`); the channel was idle, so a
|
||||
// live one now means it did.
|
||||
play_line(
|
||||
req.npc, kit_id, variant, pos, listener, &mut kits, &assets, &mut out, &config,
|
||||
);
|
||||
@@ -274,8 +242,7 @@ fn play_select_greetings(
|
||||
}
|
||||
}
|
||||
|
||||
/// System 2 — the interaction-window hello/goodbye: diff the active interaction NPC (the union of
|
||||
/// the open window sessions) and play hello on open/swap, goodbye on close-to-nothing.
|
||||
/// The window hello and goodbye, from the active NPC's change.
|
||||
fn play_window_greetings(
|
||||
merchant: Res<MerchantOpen>,
|
||||
gossip: Res<GossipState>,
|
||||
@@ -303,8 +270,7 @@ fn play_window_greetings(
|
||||
};
|
||||
let listener = cam.single().map(|t| t.translation).unwrap_or(Vec3::ZERO);
|
||||
|
||||
// guid → entity via the net index; a despawned NPC resolves nothing (the suppressed teardown
|
||||
// clear, `0x605950`).
|
||||
// A despawned NPC resolves nothing: the reference's teardown clear is silent (`0x605950`).
|
||||
let (guid, hello) = match vocal {
|
||||
WindowVocal::Hello(g) => (g, true),
|
||||
WindowVocal::Goodbye(g) => (g, false),
|
||||
@@ -317,14 +283,13 @@ fn play_window_greetings(
|
||||
return;
|
||||
};
|
||||
let kit_id = if hello { row.hello } else { row.goodbye };
|
||||
// Both window lines free-pick (`variation = -1`), single, latch-gated.
|
||||
// Both window lines free-pick their take (`variation = -1`).
|
||||
play_line(
|
||||
npc, kit_id, None, pos, listener, &mut kits, &assets, &mut out, &config,
|
||||
);
|
||||
}
|
||||
|
||||
/// Unit teardown force-stops its live greeting channel (`0x5fbb6c`: Stop + clear the handle) —
|
||||
/// a despawning NPC doesn't keep talking from where it stood.
|
||||
/// A despawning unit stops its greeting (`0x5fbb6c`).
|
||||
fn stop_on_despawn(mut removed: RemovedComponents<NetEntity>, mut out: NonSendMut<SoundOutput>) {
|
||||
for entity in removed.read() {
|
||||
kit::stop_source(&mut out, entity);
|
||||
@@ -350,26 +315,19 @@ pub(super) fn plugin(app: &mut App) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The select sequence law (`0x623910`): 5 HELLO free-picks, then the pissed kit's takes in
|
||||
/// order, then a wrapping HELLO that resets the counter to 0.
|
||||
#[test]
|
||||
fn select_sequence_cycles_5_hello_then_pissed_then_wraps() {
|
||||
// 5 HELLO takes (seq 0..5), each free-pick, counter advancing.
|
||||
for s in 0..5 {
|
||||
assert_eq!(greet_line(s, 3), (GreetLine::Hello, s + 1));
|
||||
}
|
||||
// Then the pissed kit's 3 variations in order.
|
||||
assert_eq!(greet_line(5, 3), (GreetLine::Pissed(0), 6));
|
||||
assert_eq!(greet_line(6, 3), (GreetLine::Pissed(1), 7));
|
||||
assert_eq!(greet_line(7, 3), (GreetLine::Pissed(2), 8));
|
||||
// Exhausted (5 + 3): a wrapping HELLO that resets the counter to 0.
|
||||
assert_eq!(greet_line(8, 3), (GreetLine::Hello, 0));
|
||||
// A pissed-less NPC (0 variations) wraps straight after the 5 hellos.
|
||||
// An NPC with no pissed takes wraps straight after the five hellos.
|
||||
assert_eq!(greet_line(5, 0), (GreetLine::Hello, 0));
|
||||
}
|
||||
|
||||
/// The window transition law (`SetActiveNPC`): open → hello, close-to-nothing → goodbye, and a
|
||||
/// swap → hello for the new NPC with the old goodbye SUPPRESSED.
|
||||
#[test]
|
||||
fn window_vocal_opens_closes_and_suppresses_swap_goodbye() {
|
||||
assert_eq!(window_vocal(None, Some(0x1)), WindowVocal::Hello(0x1)); // open
|
||||
@@ -379,12 +337,8 @@ mod tests {
|
||||
assert_eq!(window_vocal(None, None), WindowVocal::None);
|
||||
}
|
||||
|
||||
/// The trainer must count toward the active interaction NPC, so a gossip→trainer handoff on ONE
|
||||
/// NPC is a swap (silent), not a close-to-nothing (a spurious goodbye). This reproduces the
|
||||
/// director-reported 0237 bug at the union+law level: before the fix the trainer was absent from
|
||||
/// the union, so `active` went `Some(npc) → None` and window_vocal spoke the goodbye.
|
||||
// Each session carries private cache fields, so it is built via `default()` + a field set rather
|
||||
// than a struct literal — the form `field_reassign_with_default` would otherwise flag.
|
||||
/// A gossip→trainer handoff on one NPC is a silent swap, not a close with a goodbye.
|
||||
// The sessions have private fields, so they are built from `default()` and a field set.
|
||||
#[test]
|
||||
#[allow(clippy::field_reassign_with_default)]
|
||||
fn gossip_to_trainer_on_one_npc_is_a_silent_swap() {
|
||||
@@ -396,18 +350,17 @@ mod tests {
|
||||
TrainerOpen::default(),
|
||||
);
|
||||
|
||||
// Nothing open → no active NPC.
|
||||
assert_eq!(
|
||||
active_interaction_npc(&empty_merchant, &empty_gossip, &empty_quest, &empty_trainer),
|
||||
None,
|
||||
);
|
||||
|
||||
// Frame 1: the gossip menu is open on `npc`.
|
||||
// Frame 1: gossip open on `npc`.
|
||||
let mut gossip = GossipState::default();
|
||||
gossip.npc = Some(npc);
|
||||
let before = active_interaction_npc(&empty_merchant, &gossip, &empty_quest, &empty_trainer);
|
||||
|
||||
// Frame 2: the gossip closed and the trainer opened on the SAME npc (the handoff).
|
||||
// Frame 2: gossip closed, the trainer open on the same NPC.
|
||||
let mut trainer = TrainerOpen::default();
|
||||
trainer.trainer = Some(npc);
|
||||
let after = active_interaction_npc(&empty_merchant, &empty_gossip, &empty_quest, &trainer);
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
//! WMO interior audio identity (decision 0076): the `WMOAreaTable` row for the group the camera
|
||||
//! eye is inside, published as an override layer for the zone schedulers.
|
||||
//!
|
||||
//! The chain: `wmo_portal::CurrentWmoInterior` (the faithful down-ray already run for the portal
|
||||
//! cull — the eye's group, `None` outdoors) → `WMOAreaTable` resolve (exact group row → whole-WMO
|
||||
//! default → name-set-0 fallback, `benilla_formats::WmoAreaCatalog`) → [`CurrentInterior`]: the
|
||||
//! interior's audio FKs. Zone music/ambience/intro ([`super::zone`]) and reverb
|
||||
//! ([`super::reverb`]) treat nonzero fields as overriding the terrain `AreaTable` chain — this is
|
||||
//! where 1.12's interior soundscape actually lives (the Northshire monk chant is the abbey's
|
||||
//! whole-WMO `IntroSound` 221; ~4 000 group rows carry CAVE/AUDITORIUM reverb + inn ambience).
|
||||
//! Zero fields fall through to the terrain chain (INTERIM: the client's exact inherit rule for
|
||||
//! zero interior FKs is unrecorded; fall-through is the `AreaTable` parent-walk's spirit).
|
||||
//! WMO interior audio: the `WMOAreaTable` row for the group the camera eye is in (exact group
|
||||
//! row, then whole-WMO default, then name-set 0), published as an override layer. Zone music,
|
||||
//! ambience, intro and reverb take its nonzero fields over the terrain `AreaTable` chain; zero
|
||||
//! fields fall through to it (the reference's rule for a zero interior field is untraced).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_world::schedule::WorldStage;
|
||||
|
||||
/// The audio FKs of the interior the eye is in (`None` = open world). All-zero fields mean "an
|
||||
/// interior with no audio identity of its own" — consumers fall through to the terrain chain.
|
||||
/// The audio FKs of the interior the eye is in; `None` is the open world.
|
||||
#[derive(Resource, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct CurrentInterior(pub(crate) Option<InteriorAudio>);
|
||||
|
||||
@@ -28,7 +19,7 @@ pub(crate) struct InteriorAudio {
|
||||
pub(crate) ambience: u32,
|
||||
/// `ZoneMusic` FK.
|
||||
pub(crate) zone_music: u32,
|
||||
/// `ZoneIntroMusicTable` FK — the entry fanfare.
|
||||
/// `ZoneIntroMusicTable` FK: the entry fanfare.
|
||||
pub(crate) intro_sound: u32,
|
||||
}
|
||||
|
||||
@@ -54,8 +45,7 @@ fn resolve_interior(
|
||||
}
|
||||
}
|
||||
|
||||
/// `OnExit(InWorld)`: the eye may still be parked inside the old WMO, but the session is over —
|
||||
/// clear the override so neither the glue layer nor the next login's first frames inherit it.
|
||||
/// `OnExit(InWorld)`: clear the override so the glue and the next login do not inherit it.
|
||||
fn leave_world(mut current: ResMut<CurrentInterior>) {
|
||||
current.0 = None;
|
||||
}
|
||||
|
||||
+257
-637
File diff suppressed because it is too large
Load Diff
@@ -1,70 +1,21 @@
|
||||
//! The **output limiter** — the mix's last stage, and the one that stops a busy soundscape from
|
||||
//! tearing (decision 1551).
|
||||
//! The output limiter, the mix's last stage.
|
||||
//!
|
||||
//! ## What it replaces
|
||||
//! Deviation: the reference sums at full scale and clips (a close 1.0 kit reaches
|
||||
//! `FSOUND_SetVolume(255)` through `0x7a5dc0`, FMOD's MMX mixers saturate with `paddsw`, and no
|
||||
//! gain depends on the live voice count); this limits, because every WoW SFX is mastered to full
|
||||
//! scale, so overlapping kits clip into broadband distortion, as when a mass buff lands five
|
||||
//! sample-aligned copies of one 0 dBFS file. `SoundOutputLimiter 0` turns it off. The reference's
|
||||
//! voice and bus caps bound the count, not the level, and its SFX auto-duck (`0x457960`) is armed
|
||||
//! only by server-pushed voice lines.
|
||||
//!
|
||||
//! Every WoW SFX ships mastered to full scale: `HolyProtection.wav` (the Fortitude buff, the
|
||||
//! director's own example) peaks at **1.000**, `MetalShieldBlock1.wav` at 1.000,
|
||||
//! `LightningBoltImpact.wav` at 0.992 — measured off the shipped `sound.MPQ`. benilla's owned
|
||||
//! gain chain is `category · v · rolloff · near_field`, every factor `<= 1.0`, so a single close
|
||||
//! kit at `SoundEntries.Volume 1.0` (2 175 of the 4 623 rows) *already sits on 0 dBFS*. There is
|
||||
//! no headroom anywhere in the chain, and the mix is a plain sum.
|
||||
//! A look-ahead brickwall limiter, stereo-linked and allocation-free on the render path:
|
||||
//!
|
||||
//! So two of them is +6 dB past full scale and five is +14, and kira answers that with
|
||||
//! `frame.clamp(-1.0, 1.0)` (`backend/renderer.rs`) — a squared-off waveform, which is broadband
|
||||
//! distortion, which is what a speaker breaking sounds like. A priest's Prayer of Fortitude
|
||||
//! lands `HolyProtection` on five party members inside one frame: five sample-aligned copies of
|
||||
//! one 0 dBFS file, summing to exactly 5.0. Nothing upstream prevents it — `SoundEntries` id 3116
|
||||
//! carries `Flags 0x0000`, so the no-duplicate bit (0x20, which 2 776 of the 4 623 rows *do*
|
||||
//! carry) does not apply to it.
|
||||
//!
|
||||
//! ## What it does
|
||||
//!
|
||||
//! A **look-ahead brickwall limiter**, stereo-linked, with a mathematically exact guarantee of no
|
||||
//! overshoot — so nothing ever reaches the renderer's clamp and the clamp stops being audible.
|
||||
//! Three stages, all fixed-size and allocation-free on the render path:
|
||||
//!
|
||||
//! 1. **Required gain.** Per frame, `required = min(1, CEILING / max(|L|, |R|))` — stereo-linked so
|
||||
//! a limited transient never shifts the stereo image.
|
||||
//! 2. **Anticipation.** A sliding *minimum* of `required` over the next [`LOOKAHEAD_MS`], then a
|
||||
//! moving *average* of that minimum over the same span. The audio is delayed by one look-ahead
|
||||
//! so the gain is already down when the transient arrives. The two windows are what make the
|
||||
//! bound exact: every term of the average is a minimum over a window that still contains the
|
||||
//! sample being scaled, so the average is `<= required` at that sample — a smooth gain curve
|
||||
//! that provably never overshoots. (The naive one-pole attack does overshoot: it approaches
|
||||
//! its target asymptotically and lets the first millisecond of a +14 dB transient straight
|
||||
//! through, which is the part you actually hear.)
|
||||
//! 3. **Release.** The gain returns to unity exponentially over [`RELEASE_MS`] instead of snapping
|
||||
//! back the instant the peak passes. A limiter whose gain recovers in two milliseconds is
|
||||
//! modulating the signal at audio rate, which is its own distortion — worse on bass than the
|
||||
//! clipping it replaces.
|
||||
//!
|
||||
//! ## Why this and not fidelity
|
||||
//!
|
||||
//! This is **below** the decision-0070 seam, not across it: WoW's owned parameter math upstream is
|
||||
//! untouched, and the thing being replaced is not a reference behaviour but *kira's* hard clamp.
|
||||
//!
|
||||
//! **The reference has no headroom mechanism at all — on either side of its API boundary.** That
|
||||
//! is now read, not assumed: no constant factor exists anywhere in `WoW.exe` (a close-up 1.0 kit
|
||||
//! reaches `FSOUND_SetVolume(255)` through `0x7a5dc0` with the SFX master also 255), and
|
||||
//! decision 1563 opened `fmod.dll` itself and found the other half — a complete multiply census
|
||||
//! over all three software mixers turns up per-channel volume, `1/255`, `1/256`, `2⁻³¹` and a
|
||||
//! mix-rate-derived ramp constant, and **nothing that depends on how many voices are live**. The
|
||||
//! FPU mixer sums into a float buffer and clamps once; the MMX mixers (what the reference
|
||||
//! actually runs) accumulate with **saturating** `paddsw` and their output stage is a bare
|
||||
//! `movq` copy. So the real client sums at full scale and clips, exactly as we did.
|
||||
//!
|
||||
//! Two things it does have are often mistaken for headroom and are neither: the 12-voice device
|
||||
//! ceiling with the 13 per-bus caps (ported — 1555, 1557), which bounds the *count*; and the SFX
|
||||
//! auto-duck (`0x457960`) — it is a **sidechain for server-pushed
|
||||
//! unit voice lines** (`SMSG_PLAY_OBJECT_SOUND`), armed only by that one packet's pool, with the
|
||||
//! arming sound itself exempt. Footsteps, spell impacts, weapon hits, UI sounds, creature barks,
|
||||
//! music and ambience never arm it. It is not a bus limiter and would not have saved the mass
|
||||
//! buff.
|
||||
//!
|
||||
//! Modern engines put a limiter here for exactly this reason — FMOD Studio ships one on the
|
||||
//! master bus by default, and Wwise's own guidance is a peak limiter on the master output.
|
||||
//! `SoundOutputLimiter 0` turns it off to A/B against what the director reported.
|
||||
//! 1. Required gain per frame: `min(1, CEILING / max(|L|, |R|))`.
|
||||
//! 2. Anticipation: a sliding minimum of the required gain over [`LOOKAHEAD_MS`], then a moving
|
||||
//! average of that minimum over the same span, with the audio delayed by one look-ahead. Every
|
||||
//! term of the average is a minimum over a window holding the sample being scaled, so the gain
|
||||
//! never overshoots.
|
||||
//! 3. Release: the gain returns to unity exponentially over [`RELEASE_MS`].
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -76,32 +27,25 @@ use kira::Frame;
|
||||
|
||||
use super::meter::MixLevel;
|
||||
|
||||
/// The output ceiling. A hair under full scale so the renderer's own `clamp` is never the thing
|
||||
/// shaping the waveform — if this ever fires, something upstream of it is broken.
|
||||
/// The output ceiling, a hair under full scale so the renderer's `clamp` never shapes the waveform.
|
||||
const CEILING: f32 = 0.99;
|
||||
|
||||
/// The look-ahead span: both the anticipation window and the added output latency.
|
||||
///
|
||||
/// 2 ms is long enough to swallow the attack of a percussive impact (the shortest thing in this
|
||||
/// game's SFX is a shield clang, whose rise is ~1 ms) and short enough to be nothing at all as
|
||||
/// latency — nothing in WoW is rhythm-critical, and the device buffer above it is already 43 ms
|
||||
/// (decision 1026's `TARGET_BUFFER_FRAMES`).
|
||||
/// The look-ahead span and the added latency: longer than a shield clang's ~1 ms rise, and small
|
||||
/// beside the 43 ms device buffer.
|
||||
const LOOKAHEAD_MS: f32 = 2.0;
|
||||
|
||||
/// How long the gain takes to return to unity once the peak has passed. Long enough that the
|
||||
/// gain is not modulating at audio rate (which would intermodulate the bass), short enough that
|
||||
/// a burst of impacts does not leave the world quiet behind it.
|
||||
/// The release time constant: slow enough not to modulate the bass at audio rate, fast enough
|
||||
/// that a burst does not leave the world quiet.
|
||||
const RELEASE_MS: f32 = 120.0;
|
||||
|
||||
/// The ceiling, for the seam's offline harness to assert against.
|
||||
/// The ceiling, for the offline harness.
|
||||
#[cfg(test)]
|
||||
pub(super) fn ceiling() -> f32 {
|
||||
CEILING
|
||||
}
|
||||
|
||||
/// Install the limiter as the main track's last stage. `enabled` is the live `SoundOutputLimiter`
|
||||
/// CVar cell — read once per block, so flipping it applies without a restart; the delay line runs
|
||||
/// either way, so the toggle never steps the output.
|
||||
/// Installs the limiter as the main track's last stage. `enabled` is the live `SoundOutputLimiter`
|
||||
/// cell, read once per block; the delay line runs either way, so the toggle never steps.
|
||||
pub(super) fn install(
|
||||
builder: &mut kira::track::MainTrackBuilder,
|
||||
level: &Arc<MixLevel>,
|
||||
@@ -159,20 +103,17 @@ impl Effect for Limiter {
|
||||
}
|
||||
}
|
||||
|
||||
/// The limiter's DSP, free of kira and of the atomics — so the whole guarantee is testable
|
||||
/// offline (see the tests below, which are the proof that "never overshoots" is a fact and not a
|
||||
/// hope).
|
||||
/// The limiter's DSP, free of kira and the atomics so the guarantee tests offline.
|
||||
pub(super) struct LimiterCore {
|
||||
/// The look-ahead audio delay, `lookahead` frames deep.
|
||||
delay: Vec<Frame>,
|
||||
delay_w: usize,
|
||||
/// Monotonically-increasing `(sample index, required gain)` — the sliding-minimum window.
|
||||
/// Never holds more than `lookahead + 1` entries, and its capacity is reserved up front, so
|
||||
/// the pushes cannot allocate.
|
||||
/// The sliding-minimum window of `(sample index, required gain)`, at most `lookahead + 1`
|
||||
/// entries in reserved capacity, so pushes never allocate.
|
||||
win: VecDeque<(u64, f32)>,
|
||||
/// Index of the next input sample.
|
||||
n: u64,
|
||||
/// The last `lookahead` sliding-minimum outputs and their running sum — the smoothing average.
|
||||
/// The last `lookahead` sliding minimums and their running sum: the smoothing average.
|
||||
hist: Vec<f32>,
|
||||
hist_w: usize,
|
||||
hist_sum: f64,
|
||||
@@ -199,8 +140,8 @@ impl LimiterCore {
|
||||
}
|
||||
}
|
||||
|
||||
/// (Re)size every buffer for `sample_rate`. The only allocating call — kira guarantees it runs
|
||||
/// off the render path (`Effect::init` / `on_change_sample_rate`).
|
||||
/// Sizes every buffer for `sample_rate`: the only allocating call, run off the render path
|
||||
/// (`Effect::init`, `on_change_sample_rate`).
|
||||
pub(super) fn resize(&mut self, sample_rate: u32) {
|
||||
let rate = sample_rate.max(1) as f32;
|
||||
self.lookahead = ((LOOKAHEAD_MS / 1000.0 * rate).round() as usize).max(1);
|
||||
@@ -219,18 +160,15 @@ impl LimiterCore {
|
||||
self.release = (-1000.0 / (RELEASE_MS * rate)).exp();
|
||||
}
|
||||
|
||||
/// Consume one input frame, emit one output frame plus the gain applied to it.
|
||||
///
|
||||
/// `bypass` steers the *target* to unity rather than short-circuiting the delay: the release
|
||||
/// envelope then carries the gain back smoothly, so toggling the CVar mid-sound is a fade and
|
||||
/// never a step.
|
||||
/// One frame in, one frame out with its gain. `bypass` steers the target to unity rather than
|
||||
/// skipping the delay, so a toggle mid-sound fades instead of stepping.
|
||||
#[inline]
|
||||
pub(super) fn step(&mut self, input: Frame, bypass: bool) -> (Frame, f32) {
|
||||
// Stage 1 — this sample's required gain, stereo-linked.
|
||||
// Stage 1: this sample's required gain, stereo-linked.
|
||||
let peak = input.left.abs().max(input.right.abs());
|
||||
let required = if peak > CEILING { CEILING / peak } else { 1.0 };
|
||||
|
||||
// Stage 2a — sliding minimum of `required` over the look-ahead window ending here.
|
||||
// Stage 2a: sliding minimum of `required` over the look-ahead window ending here.
|
||||
while self.win.back().is_some_and(|&(_, g)| g >= required) {
|
||||
self.win.pop_back();
|
||||
}
|
||||
@@ -241,9 +179,8 @@ impl LimiterCore {
|
||||
}
|
||||
let window_min = self.win.front().map_or(1.0, |&(_, g)| g);
|
||||
|
||||
// Stage 2b — moving average of that minimum over the same span. Every term is a minimum
|
||||
// over a window that still contains the sample leaving the delay line, so the average
|
||||
// cannot exceed that sample's own required gain. That is the no-overshoot proof.
|
||||
// Stage 2b: moving average of that minimum. Every term is a minimum over a window holding
|
||||
// the sample leaving the delay line, so the average never exceeds its required gain.
|
||||
self.hist_sum += f64::from(window_min) - f64::from(self.hist[self.hist_w]);
|
||||
self.hist[self.hist_w] = window_min;
|
||||
self.hist_w = (self.hist_w + 1) % self.lookahead;
|
||||
@@ -253,11 +190,11 @@ impl LimiterCore {
|
||||
(self.hist_sum / self.lookahead as f64) as f32
|
||||
};
|
||||
|
||||
// Stage 3 — instant attack (the anticipation already made it gradual), slow release.
|
||||
// Stage 3: instant attack (the anticipation made it gradual), slow release.
|
||||
let recovered = 1.0 - (1.0 - self.gain) * self.release;
|
||||
self.gain = smoothed.min(recovered);
|
||||
|
||||
// The delay line: emit the frame from one look-ahead ago, scaled by this gain.
|
||||
// Emit the frame from one look-ahead ago, scaled by this gain.
|
||||
let out = self.delay[self.delay_w] * self.gain;
|
||||
self.delay[self.delay_w] = input;
|
||||
self.delay_w = (self.delay_w + 1) % self.lookahead;
|
||||
@@ -278,19 +215,17 @@ mod tests {
|
||||
c
|
||||
}
|
||||
|
||||
/// Run `frames` through and return the output, dropping the priming look-ahead.
|
||||
/// Runs `frames` through and returns the output, look-ahead priming included.
|
||||
fn run(c: &mut LimiterCore, frames: &[Frame]) -> Vec<Frame> {
|
||||
frames.iter().map(|f| c.step(*f, false).0).collect()
|
||||
}
|
||||
|
||||
/// A mono impulse train at N× full scale — the mass-buff case, five sample-aligned copies of
|
||||
/// one 0 dBFS file. **Nothing leaves the limiter past the ceiling**, at any overload.
|
||||
/// A tone at N× full scale (five is the mass-buff case) never leaves past the ceiling.
|
||||
#[test]
|
||||
fn never_exceeds_the_ceiling_at_any_overload() {
|
||||
for n in [2.0f32, 5.0, 12.0, 40.0] {
|
||||
let mut c = core();
|
||||
// 200 ms of a loud 440 Hz tone: continuous, so the release never gets to recover and
|
||||
// the steady-state bound is exercised, not just the transient.
|
||||
// 200 ms of a continuous 440 Hz tone, so the steady-state bound is exercised too.
|
||||
let frames: Vec<Frame> = (0..RATE / 5)
|
||||
.map(|i| {
|
||||
let s = n * (i as f32 * 440.0 * std::f32::consts::TAU / RATE as f32).sin();
|
||||
@@ -309,8 +244,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The hard case a one-pole attack fails: silence, then a single full-amplitude sample at
|
||||
/// 5× scale with no warning. The look-ahead must already have the gain down.
|
||||
/// Silence, then a lone sample at 5× scale: the look-ahead has the gain down already.
|
||||
#[test]
|
||||
fn a_bare_transient_out_of_silence_does_not_leak() {
|
||||
let mut c = core();
|
||||
@@ -325,8 +259,7 @@ mod tests {
|
||||
assert!(peak <= CEILING + 1e-5, "transient leaked at {peak}");
|
||||
}
|
||||
|
||||
/// Below the ceiling the limiter is a pure delay: unity gain, sample-for-sample, no colour.
|
||||
/// A limiter that touches a quiet mix is a bug, not a safety net.
|
||||
/// Below the ceiling the limiter is a pure delay, sample for sample.
|
||||
#[test]
|
||||
fn quiet_material_passes_through_untouched() {
|
||||
let mut c = core();
|
||||
@@ -349,10 +282,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The gain returns to unity after the loud part ends — a limiter that stays down has eaten
|
||||
/// the world's volume. [`RELEASE_MS`] is an exponential *time constant*, so the check is
|
||||
/// against its arithmetic: 250 ms is ~2.1 τ (the gap should be ~12 % of its start), 1 s is
|
||||
/// ~8.3 τ (essentially closed).
|
||||
/// The gain returns to unity after the burst: 250 ms is ~2.1 τ of [`RELEASE_MS`] (the gap near
|
||||
/// 12 % of its start), 1 s is ~8.3 τ.
|
||||
#[test]
|
||||
fn the_gain_recovers_after_the_burst() {
|
||||
let mut c = core();
|
||||
@@ -370,8 +301,7 @@ mod tests {
|
||||
assert!(c.gain > 0.999, "did not recover: {}", c.gain);
|
||||
}
|
||||
|
||||
/// Stereo linking: a peak on one channel scales both by the same gain, so a limited transient
|
||||
/// cannot swing the stereo image.
|
||||
/// A peak on one channel scales both by the same gain.
|
||||
#[test]
|
||||
fn limiting_is_stereo_linked() {
|
||||
let mut c = core();
|
||||
@@ -392,8 +322,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bypass is a *target* change, not a short circuit: the delay keeps running, so the output
|
||||
/// stays sample-aligned and the gain walks back to unity instead of stepping.
|
||||
/// Bypass keeps the delay running and walks the gain back to unity without a step.
|
||||
#[test]
|
||||
fn bypass_keeps_the_delay_and_fades_rather_than_steps() {
|
||||
let mut c = core();
|
||||
@@ -403,8 +332,7 @@ mod tests {
|
||||
let engaged = c.gain;
|
||||
assert!(engaged < 0.3);
|
||||
let mut last = engaged;
|
||||
// Half a second is ~4.2 release time constants: no single-sample step anywhere along the
|
||||
// way, and unity by the end.
|
||||
// Half a second is ~4.2 release time constants: no step on the way, unity at the end.
|
||||
for _ in 0..RATE / 2 {
|
||||
let (_, g) = c.step(Frame::from_mono(5.0), true);
|
||||
assert!(g - last < 0.01, "stepped from {last} to {g}");
|
||||
|
||||
@@ -1,26 +1,13 @@
|
||||
//! The **above-water liquid ambient loops** — the continuous ocean / river / lava / slime beds
|
||||
//! the client plays near liquid (decision 0506). The Booty Bay ocean wash is this system. A
|
||||
//! distinct layer from the submerged UnderWaterLoop swap ([`super::zone`], `0x460af0`): these are
|
||||
//! **3D-positioned loops LAYERED over the zone-ambience bed**, not a replacement of it.
|
||||
//! The above-water liquid ambient loops: ocean, river, lava and slime beds as 3D loops over the
|
||||
//! zone ambience, as the reference's driver `0x462b50` (groups `0xb230b8`) plays them. Liquid near
|
||||
//! the player, not the camera, arms its class's loop; the class is the cell's MCLQ/MLIQ low
|
||||
//! nibble, mapped to a kit by `SoundWaterType.dbc`. Submerging stops them hard
|
||||
//! (`0x458650` → `0x462e10` → `0x462b10`); resurfacing restarts them at full volume.
|
||||
//!
|
||||
//! The verified law (driver `0x462b50`, groups `0xb230b8`):
|
||||
//! - **Trigger:** liquid of a class within **9.0 yd of the PLAYER** (not the camera) arms that
|
||||
//! class's loop — near it, not in it: shores, docks, and wading all sound. The class is the
|
||||
//! wet cell's MCLQ/MLIQ low nibble ([`LiquidSoundSource`]), resolved to a kit **data-driven**
|
||||
//! through `SoundWaterType.dbc` ([`WaterSounds`]).
|
||||
//! - **Concurrency:** max **2** loops at once, priority River > Ocean > Magma > Slime.
|
||||
//! - **Emitter:** one per class, **slewed ≤ 1/6 yd per tick** toward the nearest water
|
||||
//! (`0x462960`) and kept ≥ **√2·4.1667 ≈ 5.89 yd** from the listener (the near-field clamp);
|
||||
//! the channel is 3D with the kit's own MinDistance and the global ×4 rolloff
|
||||
//! ([`math::ROLLOFF_FACTOR`] — the same `FSOUND_3D_SetRolloffFactor(4.0)`).
|
||||
//! - **Fades:** **5.0 s** in and out (`[0x80355c]`/`[0x803560]`) — via the pump-owned
|
||||
//! [`ActiveChannel`] gain lane; **hard stop on submerge** (`0x458650→0x462e10→0x462b10`) and
|
||||
//! **instant full-volume restart on resurface**.
|
||||
//!
|
||||
//! Named approximations (0506): the nearest point is the surface footprint's AABB clamp (the
|
||||
//! ref walks actual cells — ours can lead the fade-in by a couple of yards on L-shaped
|
||||
//! shores); the tick is a frame; `MapWaterSounds`/`EnableAmbience` CVars map onto the ambience
|
||||
//! slider (no separate toggles).
|
||||
//! The nearest point is the footprint's AABB clamp where the reference walks cells, so a fade-in
|
||||
//! can lead by a couple of yards on L-shaped shores; the tick is a frame. The reference's own
|
||||
//! `MapWaterSounds` switch (registered at `0x462a40`) is not built; the loops ride the ambience
|
||||
//! bucket, so `EnableAmbience` and the ambience slider gate them.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -37,41 +24,40 @@ use super::kit::{
|
||||
};
|
||||
use super::{SoundConfig, SoundOutput};
|
||||
|
||||
/// The scan radius (yd) around the player — VERIFIED `0x41100000 = 9.0` in `0x462b50`.
|
||||
/// The scan radius (yd) around the player (`0x462b50`).
|
||||
const TRIGGER_RADIUS: f32 = 9.0;
|
||||
/// The emitter's slew step per tick (yd) — VERIFIED `0x462960`; our tick is a frame.
|
||||
/// The emitter's slew step per tick (yd, `0x462960`).
|
||||
const SLEW_PER_TICK: f32 = 0.166_67;
|
||||
/// The emitter's near-field clamp (yd) — ≈ √2·4.16667, the cell diagonal.
|
||||
/// The emitter's near-field clamp (yd): √2·4.16667, the cell diagonal.
|
||||
const NEAR_CLAMP: f32 = 5.892_557;
|
||||
/// Fade-in/out (s) — VERIFIED `[0x80355c]`/`[0x803560]` = 5.0.
|
||||
/// Fade in and out (s): `[0x80355c]`/`[0x803560]`.
|
||||
const FADE_SECS: f32 = 5.0;
|
||||
/// Concurrent class-loop cap — VERIFIED (2 of the 4 groups, fixed priority).
|
||||
/// Concurrent class loops, by priority River > Ocean > Magma > Slime.
|
||||
const MAX_CONCURRENT: usize = 2;
|
||||
|
||||
/// The `SoundWaterType.dbc` class→kit map. Absent when the client data didn't load.
|
||||
/// The `SoundWaterType.dbc` class → kit map.
|
||||
#[derive(Resource)]
|
||||
pub(super) struct WaterSounds(WaterSoundCatalog);
|
||||
|
||||
/// One class's armed loop.
|
||||
struct ClassLoop {
|
||||
/// The slewed emitter entity (its `Transform` is what the pump's tracked-follow reads).
|
||||
/// The slewed emitter; the pump's tracked-follow reads its `Transform`.
|
||||
emitter: Entity,
|
||||
kit: u32,
|
||||
/// The pump-lane gain, animated 0→1 (arm) / 1→0 (leave); the channel stops at 0.
|
||||
/// The pump-lane gain, 0 → 1 on arm and 1 → 0 on leave; the channel stops at 0.
|
||||
gain: f32,
|
||||
/// A superseded kit (the nearest cell's speed nibble changed) fading out on the same
|
||||
/// emitter: `(kit, gain)`.
|
||||
retiring: Option<(u32, f32)>,
|
||||
}
|
||||
|
||||
/// Driver state: the four class slots (index = `nibble & 3`) + the submerge edge latch.
|
||||
/// The four class slots (index `nibble & 3`) and the submerge edge latch.
|
||||
#[derive(Resource, Default)]
|
||||
struct LiquidLoopState {
|
||||
classes: [Option<ClassLoop>; 4],
|
||||
was_underwater: bool,
|
||||
}
|
||||
|
||||
/// Startup: load the `SoundWaterType` map.
|
||||
fn load_water_sounds(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
|
||||
let Some(assets) = assets else { return };
|
||||
let loaded = {
|
||||
@@ -87,7 +73,7 @@ fn load_water_sounds(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-frame driver (`0x462b50`): scan, arm/retire by priority, slew, fade.
|
||||
/// The per-frame driver: scan, arm or retire by priority, slew, fade.
|
||||
fn drive_liquid_loops(
|
||||
mut state: ResMut<LiquidLoopState>,
|
||||
water_sounds: Option<Res<WaterSounds>>,
|
||||
@@ -105,16 +91,13 @@ fn drive_liquid_loops(
|
||||
let (Some(water_sounds), Some(mut kits), Some(assets)) = (water_sounds, kits, assets) else {
|
||||
return;
|
||||
};
|
||||
// Running silent — no audio device (`WOW_NOSOUND=1`, CI, an unattended probe). A loop that
|
||||
// fails to start is never *held*, so this system re-attempts it every frame and every attempt
|
||||
// warns: a 75 s lava probe logged 3975 identical `no audio device` lines, 89% of the file, and
|
||||
// paid a failed kit resolve per frame for them. The device is a startup fact that nothing here
|
||||
// can recover, and `sound::plugin` already warns about it once (see [`SoundOutput`]).
|
||||
// No audio device: a loop that fails to start is never held, so it would retry and warn every
|
||||
// frame. The startup warning already covers it.
|
||||
if out.mixer.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
// The submerge HARD stop (no fade) + the resurface instant-restart edge.
|
||||
// Submerged: a hard stop with no fade, and the resurface edge restarts at full.
|
||||
if world.submersion().is_water() {
|
||||
for slot in &mut state.classes {
|
||||
if let Some(cl) = slot.take() {
|
||||
@@ -130,17 +113,15 @@ fn drive_liquid_loops(
|
||||
let resurfaced = std::mem::take(&mut state.was_underwater);
|
||||
|
||||
let Ok(player_tf) = player.single() else {
|
||||
return; // no avatar yet — nothing to scan around
|
||||
return; // no avatar yet
|
||||
};
|
||||
let player_pos = player_tf.translation;
|
||||
let player_wow = bevy_to_wow(player_pos);
|
||||
|
||||
// Scan: the nearest wet point per class within the radius (the ref's nearest-liquid walk
|
||||
// `0x6723f0`; AABB-clamp approximation, module docs). The walk is the world's; the priority
|
||||
// order, the voice cap and the slew below are ours.
|
||||
// The nearest wet point per class within the radius (the reference walk is `0x6723f0`).
|
||||
let best = world.nearest_liquid_per_class(player_wow, TRIGGER_RADIUS);
|
||||
|
||||
// Priority River > Ocean > Magma > Slime, cap 2: the class indices ARE the priority order.
|
||||
// The class indices are the priority order.
|
||||
let mut budget = MAX_CONCURRENT;
|
||||
let dt = time.delta_secs();
|
||||
let fade_step = if FADE_SECS > 0.0 { dt / FADE_SECS } else { 1.0 };
|
||||
@@ -157,7 +138,7 @@ fn drive_liquid_loops(
|
||||
let slot = &mut state.classes[class];
|
||||
match (slot.as_mut(), desired_kit) {
|
||||
(None, Some(kit_id)) => {
|
||||
// Arm: spawn the emitter at the (near-clamped) nearest point and start the loop.
|
||||
// Arm: spawn the emitter at the near-clamped nearest point and start the loop.
|
||||
let point = candidate.expect("desired_kit implies a candidate").point;
|
||||
let pos = near_clamped(wow_to_bevy(point), player_pos);
|
||||
let emitter = commands.spawn((Transform::from_translation(pos),)).id();
|
||||
@@ -182,10 +163,10 @@ fn drive_liquid_loops(
|
||||
}
|
||||
(Some(cl), Some(kit_id)) => {
|
||||
if cl.kit != kit_id {
|
||||
// The nearest cell's speed nibble changed (still→fast river): crossfade —
|
||||
// the old kit retires on the same emitter, the new one fades in.
|
||||
// The speed nibble changed (still → fast river): the old kit fades out on
|
||||
// the same emitter while the new one fades in.
|
||||
if let Some((old, g)) = cl.retiring.take() {
|
||||
// A double swap mid-fade: drop the oldest outright.
|
||||
// A second swap mid-fade drops the oldest outright.
|
||||
let _ = g;
|
||||
stop_source_kit(&mut out, cl.emitter, old);
|
||||
}
|
||||
@@ -193,7 +174,7 @@ fn drive_liquid_loops(
|
||||
cl.kit = kit_id;
|
||||
cl.gain = 0.0;
|
||||
}
|
||||
// Slew the emitter toward the nearest point; the pump's tracked-follow ships it.
|
||||
// Slew the emitter toward the nearest point.
|
||||
let point = candidate.expect("desired_kit implies a candidate").point;
|
||||
if let Ok(mut tf) = emitters.get_mut(cl.emitter) {
|
||||
let target = near_clamped(wow_to_bevy(point), player_pos);
|
||||
@@ -205,8 +186,8 @@ fn drive_liquid_loops(
|
||||
step
|
||||
};
|
||||
}
|
||||
// Fade in (instant at full on the resurface edge), and re-arm a channel the
|
||||
// device dropped (the creature-loop retry shape).
|
||||
// Fade in (at full on the resurface edge), and re-arm a channel the device
|
||||
// dropped.
|
||||
cl.gain = if resurfaced {
|
||||
1.0
|
||||
} else {
|
||||
@@ -232,7 +213,7 @@ fn drive_liquid_loops(
|
||||
set_source_kit_gain(&mut out, cl.emitter, cl.kit, cl.gain);
|
||||
}
|
||||
(Some(cl), None) => {
|
||||
// Left the radius (or lost the priority race): the 5.0 s fade-out, then stop.
|
||||
// Out of range or out-prioritised: fade out, then stop.
|
||||
cl.gain -= fade_step;
|
||||
if cl.gain <= 0.0 {
|
||||
stop_source_kit(&mut out, cl.emitter, cl.kit);
|
||||
@@ -264,8 +245,7 @@ fn drive_liquid_loops(
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the emitter at least [`NEAR_CLAMP`] from the player (the ref's near-field clamp on the
|
||||
/// slewed source): inside it, push it back out along the player→emitter direction.
|
||||
/// Keep the emitter at least [`NEAR_CLAMP`] from the player, pushed out along player → emitter.
|
||||
fn near_clamped(pos: Vec3, player: Vec3) -> Vec3 {
|
||||
let d = pos - player;
|
||||
let len = d.length();
|
||||
@@ -278,10 +258,9 @@ fn near_clamped(pos: Vec3, player: Vec3) -> Vec3 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start one class loop: positioned, ambience-bucket, source-tagged (the pump's tracked-follow
|
||||
/// rides the emitter's `Transform`), force-looped (the type-22 kits are all authored loops but
|
||||
/// the lava pool omits the 0x200 flag — the same column-authority INTERIM as the creature
|
||||
/// body-loop), at an initial pump-lane gain.
|
||||
/// Start one class loop on the ambience bucket, tagged to its emitter, at an initial gain.
|
||||
/// Force-looped: the type-22 kits are all authored loops but the lava pool's lacks the `0x200`
|
||||
/// flag; how the reference loops that one is untraced.
|
||||
fn start_loop(
|
||||
kits: &mut SoundKits,
|
||||
assets: &WorldAssets,
|
||||
@@ -319,7 +298,7 @@ pub(super) fn plugin(app: &mut App) {
|
||||
.add_systems(Startup, load_water_sounds.after(AssetSet::Open))
|
||||
.add_systems(
|
||||
Update,
|
||||
// Present, before the channel pump: the pump applies this frame's gains/positions.
|
||||
// Before the channel pump, which applies this frame's gains and positions.
|
||||
drive_liquid_loops
|
||||
.in_set(WorldStage::Present)
|
||||
.before(kit::pump_channels),
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
//! WoW's owned audio math — behavioral ports of the reference's own functions (decision 0070
|
||||
//! "the audible fingerprint").
|
||||
//! The reference's audio math, ported by behaviour in plain f32/f64 rather than its x87
|
||||
//! double-rounding; the differences sit below the 255-level quantum it fed FMOD.
|
||||
//!
|
||||
//! Ports are **behavioral**, not bit-exact: the originals reproduce x87 PC_53 double-rounding
|
||||
//! spill-for-spill; here the formulas run in plain f32/f64 (differences are below audibility and
|
||||
//! below the FMOD 255-level quantum the original fed). Each item cites its RE anchor. Two
|
||||
//! deliberate drops from the original pipeline, both quantization shims for FMOD's integer API:
|
||||
//! the mantissa-bit volume curve (`0x7a5d70`, `(v·255+512.5).bits >> 14 & 0xff` — a ≤0.4%
|
||||
//! nonlinearity that only existed to make a 0..255 level) and the `__ftol` truncations. Our
|
||||
//! backend takes float amplitudes, so the linear value they quantized is fed directly.
|
||||
//! Deviation: the mantissa-bit volume curve (`0x7a5d70`, `(v·255+512.5).bits >> 14 & 0xff`) and
|
||||
//! the `__ftol` truncations are dropped, because they only quantized to FMOD's integer levels and
|
||||
//! our backend takes float amplitudes.
|
||||
|
||||
/// Per-shot volume variation — `0x458c60` `variation_volume`. With a PRNG `draw` (kit variation
|
||||
/// enabled): `v = (draw − 15)·0.01 + base`; without: `v = base`. Scaled by `mult`, clamped `[0,1]`
|
||||
/// (NaN → 0, matching the binary's unordered branch).
|
||||
/// Per-shot volume variation (`0x458c60`): `(draw − 15)·0.01 + base`, or `base` with no draw,
|
||||
/// scaled by `mult` and clamped to `[0,1]`.
|
||||
pub(crate) fn variation_volume(draw: Option<i32>, base: f32, mult: f32) -> f32 {
|
||||
let v = match draw {
|
||||
None => base as f64,
|
||||
Some(r) => (r - 0xf) as f64 * 0.01 + base as f64,
|
||||
};
|
||||
let v = v * mult as f64;
|
||||
// `!(v > 0.0)` is deliberate, NOT `v <= 0.0`: NaN must land in the zero arm (the binary's
|
||||
// unordered `fcom` branch).
|
||||
// `!(v > 0.0)`, not `v <= 0.0`: NaN lands in the zero arm, as the reference's unordered `fcom`
|
||||
// branch does.
|
||||
#[allow(clippy::neg_cmp_op_on_partial_ord)]
|
||||
if !(v > 0.0) {
|
||||
0.0
|
||||
@@ -30,41 +25,33 @@ pub(crate) fn variation_volume(draw: Option<i32>, base: f32, mult: f32) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-shot pitch variation — `0x458da0`: the playback frequency in Hz handed
|
||||
/// to `FSOUND_SetFrequency` when the kit varies pitch: `freq = (22050 · (draw + 0x55)) / 100`
|
||||
/// (32-bit wrapping mul, truncating division — exactly the binary's integer path). The backend
|
||||
/// consumes a *rate*, so callers divide by the file's sample rate: a 22050 Hz WAV at draw 15
|
||||
/// plays at rate 1.0; the absolute-Hz semantics (a 44.1 kHz file would halve) are FMOD's and are
|
||||
/// preserved deliberately.
|
||||
/// Per-shot pitch variation (`0x458da0`): the absolute frequency in Hz handed to
|
||||
/// `FSOUND_SetFrequency`, `(22050 · (draw + 0x55)) / 100` in wrapping 32-bit integer math.
|
||||
/// Callers divide by the file's sample rate, so a 44.1 kHz file plays at half speed, as in FMOD.
|
||||
pub(crate) fn variation_pitch_freq(draw: i32) -> i32 {
|
||||
22050i32.wrapping_mul(draw.wrapping_add(0x55)) / 100
|
||||
}
|
||||
|
||||
/// The variation draw: raw PRNG word → `{0..30}` via **multiply-high scale-to-range**
|
||||
/// (`mulhi(31, raw)` — `mov ecx,0x1f` @ `0x458d35`/`0x458e85` through the `0x455c70` mul-high
|
||||
/// helper). NOT a modulo: `floor(31·raw / 2³²)`, uniform
|
||||
/// over 31 values given a uniform word. Volume spans ±0.15 about base, pitch ×0.85..×1.15.
|
||||
/// The variation draw: a raw PRNG word scaled to `0..=30` by multiply-high, not a modulo
|
||||
/// (`mov ecx,0x1f` at `0x458d35`/`0x458e85` through the `0x455c70` mul-high helper).
|
||||
pub(crate) fn variation_draw(raw: u32) -> i32 {
|
||||
((u64::from(raw) * 31) >> 32) as i32
|
||||
}
|
||||
|
||||
/// Squared listener↔source distance (`0x45ce16` idiom — all gates compare squared).
|
||||
/// Squared listener-to-source distance; every reference gate compares squared (`0x45ce16`).
|
||||
pub(crate) fn dist_sq(a: bevy::math::Vec3, b: bevy::math::Vec3) -> f32 {
|
||||
a.distance_squared(b)
|
||||
}
|
||||
|
||||
/// Selection-time audibility — `0x45cdf0`: play iff `maxdist² > d²`
|
||||
/// (strict; equal/NaN inaudible). `maxdist` is the kit's `DistanceCutoff`; `0` means
|
||||
/// non-positional (callers skip the gate — the `0x7a5ca0` sentinel).
|
||||
/// Selection-time audibility (`0x45cdf0`): strictly `maxdist² > d²`, with `maxdist` the kit's
|
||||
/// `DistanceCutoff`. Callers skip the gate for a non-positional 0 (the `0x7a5ca0` sentinel).
|
||||
pub(crate) fn audible(d_sq: f32, maxdist: f32) -> bool {
|
||||
maxdist * maxdist > d_sq
|
||||
}
|
||||
|
||||
/// The near-field attenuation WoW layers ON TOP of the backend's rolloff — `0x7a5000`, the
|
||||
/// per-frame value at channel `+0x78`:
|
||||
/// `atten = 1 − clamp(√d² − maxdist·0.9, 0, maxdist·0.1) / (maxdist·0.1)` — full volume inside
|
||||
/// 90% of `maxdist`, a linear 1→0 ramp across the last 10%. Callers virtualize the channel
|
||||
/// beyond `maxdist` instead of calling this.
|
||||
/// The near-field attenuation layered on top of the rolloff (`0x7a5000`, channel `+0x78`): full
|
||||
/// inside 90% of `maxdist`, a linear ramp to 0 across the last 10%. Beyond `maxdist` callers
|
||||
/// virtualize the channel instead.
|
||||
pub(crate) fn near_field_atten(d_sq: f32, maxdist: f32) -> f32 {
|
||||
let band = maxdist * 0.1;
|
||||
if band <= 0.0 {
|
||||
@@ -75,23 +62,17 @@ pub(crate) fn near_field_atten(d_sq: f32, maxdist: f32) -> f32 {
|
||||
1.0 - clamped / band
|
||||
}
|
||||
|
||||
// NOTE: `0x457960` is deliberately NOT ported here. benilla once modelled it as a music
|
||||
// "transition crossfade"; it is the SFX-bus auto-duck (the SoundVolume category dips to 50 %
|
||||
// while SFX one-shots play, restoring over 3 s), unrelated to music transitions, which benilla
|
||||
// does not model.
|
||||
// The real music transition is a 4.0 s backend fade-stop (see `sound::zone`). Decision 0100.
|
||||
// NOTE: `0x457960` is the SFX-bus auto-duck, not a music crossfade, and benilla does not model
|
||||
// it. The music transition is a 4.0 s backend fade-stop (`sound::zone`).
|
||||
|
||||
/// The global 3D rolloff factor — `FSOUND_3D_SetRolloffFactor(4.0)` at device init
|
||||
/// (`0x7a47b1`, prefill `0x7a495a` = 4.0f; `SoundRolloffFactor` CVar default "4" round-trips it).
|
||||
/// The global 3D rolloff factor: `FSOUND_3D_SetRolloffFactor(4.0)` at device init (`0x7a47b1`,
|
||||
/// prefill `0x7a495a`), the `SoundRolloffFactor` CVar's default.
|
||||
pub(crate) const ROLLOFF_FACTOR: f32 = 4.0;
|
||||
|
||||
/// The distance rolloff FMOD computed between the kit's `MinDistance` and the far plane —
|
||||
/// FMOD 3.x's logarithmic/inverse model with the global rolloff factor:
|
||||
/// full volume inside `min_dist`, `min_dist / (min_dist + factor·(d − min_dist))` beyond
|
||||
/// (WoW handed FMOD `Sample_SetMinMaxDistance(min_dist, 100000.0)` — `0x458ed0` — and
|
||||
/// [`ROLLOFF_FACTOR`] at init). We disabled the backend's attenuation (decision 0070: gain is
|
||||
/// ours), so this model moves to our side of the seam; the audible total is
|
||||
/// `rolloff · near_field_atten`.
|
||||
/// FMOD 3.x's inverse rolloff from the kit's `MinDistance`: full inside it,
|
||||
/// `min_dist / (min_dist + factor·(d − min_dist))` beyond. The reference hands FMOD
|
||||
/// `Sample_SetMinMaxDistance(min_dist, 100000.0)` (`0x458ed0`); the backend's own attenuation is
|
||||
/// off, so the audible gain is `rolloff · near_field_atten`.
|
||||
pub(crate) fn fmod_rolloff(d_sq: f32, min_dist: f32) -> f32 {
|
||||
if min_dist <= 0.0 {
|
||||
return 1.0;
|
||||
@@ -141,14 +122,14 @@ mod tests {
|
||||
#[test]
|
||||
fn audibility_gate_is_strict() {
|
||||
assert!(audible(99.9 * 99.9, 100.0));
|
||||
assert!(!audible(100.0 * 100.0, 100.0)); // equal = inaudible (the binary's fcompp)
|
||||
assert!(!audible(100.0 * 100.0, 100.0)); // equal is inaudible (the reference's fcompp)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolloff_is_inverse_with_factor_four_beyond_min_distance() {
|
||||
assert_eq!(fmod_rolloff(4.0 * 4.0, 8.0), 1.0); // inside min_dist: full volume
|
||||
assert_eq!(fmod_rolloff(8.0 * 8.0, 8.0), 1.0); // boundary
|
||||
// Double the distance with factor 4: 8/(8 + 4·8) = 0.2 (was 0.5 at factor 1).
|
||||
// Twice min_dist: 8/(8 + 4·8) = 0.2.
|
||||
assert!((fmod_rolloff(16.0 * 16.0, 8.0) - 0.2).abs() < 1e-6);
|
||||
assert_eq!(fmod_rolloff(100.0, 0.0), 1.0); // 0 = non-positional sentinel
|
||||
}
|
||||
|
||||
@@ -1,32 +1,16 @@
|
||||
//! **The message catalog's sound half** — the branch of `CGGameUI::DisplayError 0x496720` that
|
||||
//! nobody had wired (decision 1815).
|
||||
//! The message catalog's sound half: the sound branch of `CGGameUI::DisplayError` (`0x496720`).
|
||||
//!
|
||||
//! Every displayed client message is a row of the registry at `0xb4b498`
|
||||
//! ([`benilla_ui::messages`], decision 1770), and the dispatcher reads two of that row's fields
|
||||
//! before it ever reaches the text: `+0x08`, a **sound cue name**, and `+0x0c`, a **type tag**.
|
||||
//! The test is one comparison (`0x49673d cmp [row+0xc],0x44 / je 0x496784`) and it picks between
|
||||
//! two entirely different sounds:
|
||||
//! Each displayed message is a row of the registry at `0xb4b498` ([`benilla_ui::messages`]), and
|
||||
//! one comparison on its type tag `+0x0c` picks the sound (`0x49673d cmp [row+0xc],0x44`):
|
||||
//!
|
||||
//! - **tag `== 0x44`** — play `+0x08` as a `SoundEntries` **name** through `PlaySoundByName`
|
||||
//! (`0x458030`), unless the name is the literal `"NONE"`. 30 of the 465 rows name a cue:
|
||||
//! `QUESTADDED`, `igQuestFailed`, `LEVELUP`, `TaxiNodeDiscovered`, `FRIENDJOINGAME`, …
|
||||
//! - **tag `!= 0x44`** — ignore `+0x08` entirely and speak the tag as an **error-speech line id**
|
||||
//! in the player's own race and gender voice ([`super::vocal`]). 56 rows do this.
|
||||
//! - `== 0x44`: play `+0x08` as a `SoundEntries` name through `PlaySoundByName` (`0x458030`),
|
||||
//! unless it is `"NONE"`.
|
||||
//! - `!= 0x44`: speak the tag as an error-speech line in the player's race and gender voice
|
||||
//! ([`super::vocal`]).
|
||||
//!
|
||||
//! benilla's catalog has carried both fields since 1770 and read neither; the one place a message
|
||||
//! is displayed (`crate::ui_action::show_messages`) queues the record here instead, and this drains
|
||||
//! it. So a message's sound now comes from the same row its text and its surface do, and cannot
|
||||
//! drift from them — which is the whole argument for the table existing.
|
||||
//!
|
||||
//! **Where in `0x496720` this happens matters and is easy to get backwards** — the sound branch
|
||||
//! runs *first*, before the row's key guard and before any text is resolved. See
|
||||
//! [`MessageSounds::push`].
|
||||
//!
|
||||
//! **The reference's own gate ordering is preserved across the two arms and it is not symmetric.**
|
||||
//! The cue arm plays like any other UI kit and is silenced by `MasterSoundEffects` at the mixer,
|
||||
//! the way every other kit here is; the speech arm tests `MasterSoundEffects` *and*
|
||||
//! `EnableErrorSpeech` in its own body, before touching any state (`0x458264`/`0x45827f`) — see
|
||||
//! [`super::vocal::speak_line`].
|
||||
//! The two arms gate differently: a cue is silenced by `MasterSoundEffects` at the mixer, while
|
||||
//! speech tests `MasterSoundEffects` and `EnableErrorSpeech` in its own body
|
||||
//! (`0x458264`/`0x45827f`).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -39,36 +23,23 @@ use super::kit::{self, KitRef, SoundCategory, SoundKits};
|
||||
use super::vocal::{self, VocalSpeech, NO_SPEECH_TAG};
|
||||
use super::{AudioListener, SoundConfig, SoundOutput};
|
||||
|
||||
/// Catalog rows displayed this frame, awaiting their sound.
|
||||
///
|
||||
/// Pushed by `crate::ui_action::show_messages` — the one sink every displayed message passes
|
||||
/// through — and drained by [`play_message_sounds`] after the UI input pass, so a refusal sounds on
|
||||
/// the frame its line appears. Rows, not resolved sounds: the *reading* of `+0x08`/`+0x0c` belongs
|
||||
/// here beside the players, not scattered across the windows that raise messages.
|
||||
/// Catalog rows displayed this frame, awaiting their sound. Pushed by
|
||||
/// `crate::ui_action::show_messages`, the sink every displayed message passes through.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct MessageSounds {
|
||||
records: Vec<&'static MessageRecord>,
|
||||
/// Cues by `PlaySoundByName` name with no catalog row behind them — the tutorial popup
|
||||
/// (`0x4b5390`'s `"TutorialPopup"`, 1976): played on the same 2D SFX path as a row's cue.
|
||||
/// `PlaySoundByName` cues with no catalog row, such as the tutorial popup's
|
||||
/// `"TutorialPopup"` (`0x4b5390`), played on the same 2D SFX path.
|
||||
cues: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl MessageSounds {
|
||||
/// Queue one displayed message's sound.
|
||||
///
|
||||
/// **The reference sounds a message BEFORE it decides whether to draw it**, and that ordering
|
||||
/// is read, not assumed: `0x496720`'s sound branch is at `0x49673d`, ahead of the
|
||||
/// `[record+0x00]` key guard at `0x4967bd`/`0x4967c5` and far ahead of the sink's
|
||||
/// empty-*text* guard at `0x4945b4`. So a message whose GlobalStrings entry is missing still
|
||||
/// makes its noise there, while benilla — where every raise site drops an empty line before it
|
||||
/// reaches [`show_messages`](crate::ui_action::show_messages) — would stay silent.
|
||||
///
|
||||
/// **On 5875's data that difference has no case**: of the 86 rows that sound at all (56 voice
|
||||
/// lines + 30 cues), every single one resolves a non-empty string in the shipped
|
||||
/// `GlobalStrings.lua` — measured, not assumed (decision 1815). The rows with no text are the
|
||||
/// silent ones, `ERR_CANT_BE_DISENCHANTED` among them, so nothing is lost by hanging the sound
|
||||
/// off the display. It is written down because the *ordering* is the surprising part, and a
|
||||
/// future locale or a patched table could give it a tenant.
|
||||
/// The reference sounds a message before deciding to draw it (`0x49673d`, ahead of the key
|
||||
/// guard at `0x4967bd`/`0x4967c5` and the empty-text guard at `0x4945b4`), while benilla drops
|
||||
/// an empty line before it gets here. No sounding row in the shipped `GlobalStrings.lua` has
|
||||
/// empty text, so the two agree on 1.12.1's data.
|
||||
pub(crate) fn push(&mut self, record: &'static MessageRecord) {
|
||||
self.records.push(record);
|
||||
}
|
||||
@@ -78,15 +49,14 @@ impl MessageSounds {
|
||||
self.cues.push(name);
|
||||
}
|
||||
|
||||
/// What is queued — for the tests that drive the producer
|
||||
/// (`crate::ui_action::show_messages`) and read back what the drain would be handed.
|
||||
/// What is queued, for the producer's tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn queued(&self) -> &[&'static MessageRecord] {
|
||||
&self.records
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain [`MessageSounds`]: cue by name, or speech by line (module doc).
|
||||
/// Drain [`MessageSounds`]: a cue by name, or speech by line.
|
||||
fn play_message_sounds(
|
||||
mut queue: ResMut<MessageSounds>,
|
||||
mut speech: ResMut<VocalSpeech>,
|
||||
@@ -102,13 +72,12 @@ fn play_message_sounds(
|
||||
}
|
||||
let records = std::mem::take(&mut queue.records);
|
||||
let cues = std::mem::take(&mut queue.cues);
|
||||
// Drained even when there is nothing to play it with (headless, no client data), so the queue
|
||||
// can never grow unbounded — `super::ui`'s posture exactly.
|
||||
// Drained even with nothing to play it on, so the queue cannot grow unbounded.
|
||||
let (Some(mut kits), Some(assets)) = (kits, assets) else {
|
||||
return;
|
||||
};
|
||||
// The reference reads the player's sex per play (`0x49676f` → `0x5ed5b0`), not at table-build
|
||||
// time; a descriptor that has not arrived yet means no voice, not a guessed one.
|
||||
// The reference reads the player's sex per play (`0x49676f` → `0x5ed5b0`); none yet means
|
||||
// no voice.
|
||||
let sex = self_q.iter().next().and_then(|s| s.0.unit_gender());
|
||||
for name in cues {
|
||||
if let Err(e) = kit::play_kit(
|
||||
@@ -127,7 +96,7 @@ fn play_message_sounds(
|
||||
for record in records {
|
||||
if record.type_tag == NO_SPEECH_TAG {
|
||||
let Some(name) = record.sound else {
|
||||
continue; // the 435 silent rows, and the `"NONE"` the generator already folded in
|
||||
continue; // a silent row; the generator folds `"NONE"` into `None`
|
||||
};
|
||||
if let Err(e) = kit::play_kit(
|
||||
&mut kits,
|
||||
@@ -136,7 +105,7 @@ fn play_message_sounds(
|
||||
&config,
|
||||
listener.pos,
|
||||
KitRef::Name(name),
|
||||
None, // 2D — `0x458030`'s plays are non-positional UI kits
|
||||
None, // 2D: `0x458030` plays non-positional UI kits
|
||||
SoundCategory::Sfx,
|
||||
) {
|
||||
warn!("sound(message): cue {name:?} for {}: {e:#}", record.key);
|
||||
@@ -161,8 +130,7 @@ fn play_message_sounds(
|
||||
pub(super) fn plugin(app: &mut App) {
|
||||
app.init_resource::<MessageSounds>().add_systems(
|
||||
Update,
|
||||
// After the UI input pass, beside `super::ui`'s own drain and for the same reason: the
|
||||
// message feeds run before it, so a refusal raised this frame sounds this frame.
|
||||
// After the UI input pass, so a refusal raised this frame sounds this frame.
|
||||
play_message_sounds.after(crate::ui_script::UiInput),
|
||||
);
|
||||
}
|
||||
@@ -173,9 +141,7 @@ mod tests {
|
||||
|
||||
use super::NO_SPEECH_TAG;
|
||||
|
||||
/// The two arms are disjoint over the real table, which is what lets the drain branch on one
|
||||
/// comparison: **no row both names a cue and carries a speech line**. (30 rows name a cue and
|
||||
/// every one of them is tagged `0x44`; 56 carry a line and none names a cue.)
|
||||
/// No row both names a cue and carries a speech line, so the drain branches on one test.
|
||||
#[test]
|
||||
fn a_row_is_either_a_cue_or_a_voice_line_never_both() {
|
||||
let mut cues = 0;
|
||||
@@ -195,9 +161,7 @@ mod tests {
|
||||
assert_eq!((cues, voices), (30, 56));
|
||||
}
|
||||
|
||||
/// The lines a player hears most, pinned to the tags the shipped `VocalUISounds.dbc` voices —
|
||||
/// the join this whole module exists to make, asserted on both tables at once so a
|
||||
/// regenerated catalog that moved a tag fails here rather than going quiet in the world.
|
||||
/// The most-heard refusals keep the tags the shipped `VocalUISounds.dbc` voices.
|
||||
#[test]
|
||||
fn the_everyday_refusals_carry_their_voice_lines() {
|
||||
for (key, tag, kind) in [
|
||||
@@ -212,10 +176,9 @@ mod tests {
|
||||
("ERR_NOT_ENOUGH_MONEY", 0x28, MsgKind::Error),
|
||||
("ERR_ITEM_LOCKED", 0x3d, MsgKind::Error),
|
||||
("ERR_GENERIC_NO_TARGET", 0x2d, MsgKind::Error),
|
||||
// Not every voiced line is a red one: the taxi's refusal is the YELLOW info line and
|
||||
// still speaks, which is the reason the drain never reads `kind`.
|
||||
// An info line still speaks, so the drain never reads `kind`.
|
||||
("ERR_TAXINOTENOUGHMONEY", 0x36, MsgKind::Info),
|
||||
// …nor is every one a UIErrorsFrame line at all — this one is a chat row that speaks.
|
||||
// A chat row speaks too.
|
||||
("ERR_ALREADY_IN_GROUP_S", 0x14, MsgKind::Chat),
|
||||
] {
|
||||
let r = by_key(key).unwrap_or_else(|| panic!("{key} is not a catalog row"));
|
||||
|
||||
@@ -1,35 +1,16 @@
|
||||
//! The **output meter** — what the mix's level actually *is*, in numbers (decision 1551).
|
||||
//! The output meter: the summed mix's level, measured on the main track ahead of the limiter.
|
||||
//!
|
||||
//! The three crackle hunts before this one (1026, 1109, 1112/1114) each ended by adding the meter
|
||||
//! that would have named the mechanism: the callback-deadline load, the stream-decoder liveness,
|
||||
//! the HAL's own IO cycle, the recorded waveform. Every one of them watches *timing*. None of
|
||||
//! them watches **amplitude** — and amplitude is the one thing that goes wrong when a lot of
|
||||
//! sounds happen at once, which is exactly the condition the director reports ("a lot of mobs
|
||||
//! attacking same time... it gets really dirty, like a speaker breaking").
|
||||
//! A clipped mix meets every timing deadline, and kira answers it with a hard
|
||||
//! `clamp(-1.0, 1.0)`, which is broadband distortion; so this records what the game asked for:
|
||||
//!
|
||||
//! It cannot be read off any existing counter, because a clipped mix is *healthy* by all of them:
|
||||
//! the callback met its deadline, no decoder starved, the OS delivered every cycle on time. The
|
||||
//! mix was computed perfectly — it was just louder than full scale, and kira's renderer answers
|
||||
//! that with a hard `clamp(-1.0, 1.0)` (`backend/renderer.rs`), which is a squared-off waveform,
|
||||
//! i.e. broadband distortion. So this meter sits on the main track **ahead of the limiter** and
|
||||
//! records what the game *asked* for:
|
||||
//! - `peak`: the largest `|sample|`; above 1.0 the mix did not fit full scale.
|
||||
//! - `over`: how many samples were past full scale.
|
||||
//! - `reduction`: the deepest gain [`super::limiter`] pulled.
|
||||
//! - `nonfinite`: NaN or infinite samples, which `peak` and `over` cannot see (`f32::max` drops a
|
||||
//! NaN and `NaN > 1.0` is false) and which pass the limiter untouched.
|
||||
//!
|
||||
//! - **`peak`** — the largest `|sample|` the summed mix reached. `> 1.0` means the request did not
|
||||
//! fit; `4.7` means the game asked for 4.7× full scale and (before 1551) got a clipped 1.0.
|
||||
//! - **`over`** — how many samples were past full scale. One is a tick; a hundred thousand is the
|
||||
//! report the director filed.
|
||||
//! - **`reduction`** — the deepest gain the limiter had to pull to make it fit ([`super::limiter`]
|
||||
//! writes it here), so the log line reads as one story: asked for this, allowed that.
|
||||
//! - **`nonfinite`** — samples that were NaN or infinite. This is the meter's own blind spot,
|
||||
//! closed deliberately (decision 1556): `f32::max` *discards* a NaN operand and `NaN > 1.0` is
|
||||
//! `false`, so a mix carrying NaN reads as flawless on `peak` and `over` alike — and sails
|
||||
//! through the limiter's `peak > CEILING` test untouched, straight into the driver. A single
|
||||
//! non-finite sample is broadband noise at whatever the hardware makes of the bit pattern,
|
||||
//! which is *also* "a speaker breaking", and no counter we had could see it. It must be tested
|
||||
//! for explicitly or not at all.
|
||||
//!
|
||||
//! The audio-thread half is three atomics and a compare per sample. The main-thread half drains
|
||||
//! them once per report window in `sound::poll_mix_health`.
|
||||
//! The audio thread pays three atomics per block; `sound::poll_mix_health` drains them per report
|
||||
//! window.
|
||||
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -38,11 +19,9 @@ use kira::effect::{Effect, EffectBuilder};
|
||||
use kira::info::Info;
|
||||
use kira::Frame;
|
||||
|
||||
/// The mix's level, shared audio-thread → main-thread.
|
||||
///
|
||||
/// Peaks are stored as the **bit pattern** of a non-negative `f32`, which orders identically to
|
||||
/// the float itself — so `fetch_max`/`fetch_min` on the bits are exactly max/min on the values,
|
||||
/// with no lock and no CAS loop on the render path.
|
||||
/// The mix's level, shared from the audio thread to the main thread. Peaks are stored as the bit
|
||||
/// pattern of a non-negative `f32`, which orders like the float, so `fetch_max`/`fetch_min` on
|
||||
/// the bits need no lock.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct MixLevel {
|
||||
/// Peak `|sample|` of the summed mix since the last [`Self::take`].
|
||||
@@ -51,8 +30,7 @@ pub(super) struct MixLevel {
|
||||
over: AtomicU64,
|
||||
/// The limiter's deepest gain since the last [`Self::take`] (1.0 = it never engaged).
|
||||
reduction_bits: AtomicU32,
|
||||
/// Non-finite (NaN/inf) samples since the last [`Self::take`]. Never nonzero in a healthy
|
||||
/// mix; any nonzero reading is a defect upstream, not a loud passage.
|
||||
/// Non-finite samples since the last [`Self::take`]; any is a defect upstream.
|
||||
nonfinite: AtomicU64,
|
||||
}
|
||||
|
||||
@@ -70,19 +48,14 @@ impl Default for MixLevel {
|
||||
/// One window's reading, as [`MixLevel::take`] hands it to the reporter.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct LevelReading {
|
||||
/// Peak `|sample|` the summed mix reached. `> 1.0` = the mix did not fit in full scale.
|
||||
pub(super) peak: f32,
|
||||
/// Samples past full scale in the window.
|
||||
pub(super) over: u64,
|
||||
/// The limiter's deepest gain in the window (1.0 = never engaged).
|
||||
pub(super) reduction: f32,
|
||||
/// Non-finite samples in the window. Anything but zero is a bug upstream of the mix.
|
||||
pub(super) nonfinite: u64,
|
||||
}
|
||||
|
||||
impl MixLevel {
|
||||
/// Fold one processed block's tally in (audio thread) — accumulated in locals first, so the
|
||||
/// render path pays atomics per *block*, not per sample.
|
||||
/// Fold one processed block's tally in, on the audio thread.
|
||||
#[inline]
|
||||
fn block(&self, peak: f32, over: u64, nonfinite: u64) {
|
||||
self.peak_bits.fetch_max(peak.to_bits(), Ordering::Relaxed);
|
||||
@@ -94,14 +67,14 @@ impl MixLevel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold the limiter's applied gain in (audio thread — [`super::limiter`]).
|
||||
/// Fold the limiter's applied gain in, on the audio thread.
|
||||
#[inline]
|
||||
pub(super) fn gain(&self, gain: f32) {
|
||||
self.reduction_bits
|
||||
.fetch_min(gain.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Read and reset — one window's story (main thread).
|
||||
/// Read and reset one window, on the main thread.
|
||||
pub(super) fn take(&self) -> LevelReading {
|
||||
LevelReading {
|
||||
peak: f32::from_bits(self.peak_bits.swap(0, Ordering::Relaxed)),
|
||||
@@ -115,9 +88,7 @@ impl MixLevel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the meter on `builder` and return the cell it feeds. Always on: it is three atomics on
|
||||
/// the render path, and the alternative is what the last four crackle hunts each had to start
|
||||
/// from — the director's ear and no number.
|
||||
/// Install the meter on `builder`, feeding `level`. Always on.
|
||||
pub(super) fn install(builder: &mut kira::track::MainTrackBuilder, level: &Arc<MixLevel>) {
|
||||
builder.add_effect(MeterBuilder {
|
||||
level: Arc::clone(level),
|
||||
@@ -135,7 +106,7 @@ impl EffectBuilder for MeterBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/// The audio-thread half: measure, never modify.
|
||||
/// The audio-thread half: it measures and never modifies.
|
||||
struct Meter {
|
||||
level: Arc<MixLevel>,
|
||||
}
|
||||
@@ -147,8 +118,7 @@ impl Effect for Meter {
|
||||
let mut nonfinite = 0u64;
|
||||
for f in input.iter() {
|
||||
for mag in [f.left.abs(), f.right.abs()] {
|
||||
// `is_finite` first: a NaN would otherwise vanish into `max` and compare `false`
|
||||
// against the over-scale test, i.e. register as a perfectly healthy sample.
|
||||
// `is_finite` first: a NaN vanishes into `max` and fails the over-scale test.
|
||||
if mag.is_finite() {
|
||||
peak = peak.max(mag);
|
||||
over += u64::from(mag > 1.0);
|
||||
@@ -165,8 +135,7 @@ impl Effect for Meter {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The float-bits ordering trick the lock-free peak/min rests on: for non-negative floats,
|
||||
/// `to_bits` is monotonic, so atomic max/min on the bits are max/min on the values.
|
||||
/// For non-negative floats `to_bits` is monotonic, which the lock-free peak and min rest on.
|
||||
#[test]
|
||||
fn float_bits_order_like_the_floats_for_non_negatives() {
|
||||
let mut vals = [0.0f32, 1e-30, 0.5, 0.999, 1.0, 1.0001, 4.7, 1e30];
|
||||
@@ -181,8 +150,7 @@ mod tests {
|
||||
assert_eq!(level.take().peak, 1e30);
|
||||
}
|
||||
|
||||
/// A window's reading is the window's, not all time: `take` resets peak and count, and
|
||||
/// restores the "limiter never engaged" identity for the reduction.
|
||||
/// `take` resets the peak and count, and the reduction to 1.0.
|
||||
#[test]
|
||||
fn take_resets_the_window() {
|
||||
let level = MixLevel::default();
|
||||
@@ -200,15 +168,10 @@ mod tests {
|
||||
assert_eq!(r.reduction, 1.0);
|
||||
}
|
||||
|
||||
/// The blind spot this meter exists to not have. A NaN sample is invisible to both amplitude
|
||||
/// tests — `f32::max` returns the *other* operand and `NaN > 1.0` is `false` — so a mix full
|
||||
/// of NaN would otherwise report peak 0.0, zero over-scale, and perfect health while the
|
||||
/// driver plays noise. Only an explicit finiteness test sees it.
|
||||
/// A NaN sample is invisible to both amplitude tests; only the finiteness test counts it.
|
||||
#[test]
|
||||
fn non_finite_samples_are_counted_not_silently_swallowed() {
|
||||
// The trap, stated as an executable fact rather than a comment. `black_box` keeps these
|
||||
// runtime comparisons — a literal NaN comparison is (rightly) a lint, and the point here
|
||||
// is exactly that this is what the audio thread would have been doing.
|
||||
// `black_box` keeps these runtime comparisons; a literal NaN comparison is a lint.
|
||||
let nan = std::hint::black_box(f32::NAN);
|
||||
assert_eq!(0.0f32.max(nan), 0.0, "`max` discards a NaN operand");
|
||||
assert!(
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
//! Projectile **flight sound** (`SpellVisual` field 10): the loop a missile carries while it
|
||||
//! travels — the thrown weapon's `WeaponLoop`, the fireball's `FireMissileLoop`. The client keeps
|
||||
//! a per-missile loop handle (`CMissile+0x44`) started at launch and killed at
|
||||
//! arrival; ours is a channel **tracked to the missile entity** (the tracked-loop follow in
|
||||
//! [`super::kit::pump_channels`] rides it along the flight) begun on [`MissileSound::Start`] and
|
||||
//! reaped on [`MissileSound::Stop`], which `crate::entities::missile` writes at launch/arrival.
|
||||
//!
|
||||
//! `force_loop`: the missile loop is looping by construction in the client (the loop handle), not
|
||||
//! by the `SoundEntries` 0x200 flag — so we loop unconditionally, the same authority split the
|
||||
//! creature body-loop uses ([`super::kit::play_kit_ext`]).
|
||||
//! Projectile flight sound: the `SpellVisual` field 10 loop a missile carries in flight. The
|
||||
//! reference's first flight step (`0x61e2a0`) starts it (`0x61e79e call 0x4589a0`, handle at
|
||||
//! `CMissile+0x44`), each later step moves it (`0x61e77c call 0x7a5b10`), and arrival kills it.
|
||||
//! Here it is a channel tagged to the missile entity from [`MissileSound::Start`] to
|
||||
//! [`MissileSound::Stop`]. Force-looped: the loop handle makes it loop, not the kit's `0x200`
|
||||
//! flag.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -50,8 +46,7 @@ fn route_missile_sounds(
|
||||
Some(pos),
|
||||
SoundCategory::Sfx,
|
||||
PlayExtras {
|
||||
// tag the loop to the missile — the pump follows it in flight, and the
|
||||
// loop is looping by construction rather than by the kit's own flag
|
||||
// the pump follows the tagged loop in flight
|
||||
source: Some(entity),
|
||||
force_loop: true,
|
||||
..default()
|
||||
@@ -60,7 +55,7 @@ fn route_missile_sounds(
|
||||
warn!("missile flight sound {kit_sound}: {e:#}");
|
||||
}
|
||||
}
|
||||
// The missile carries only this one channel — stop everything tagged to it.
|
||||
// The missile carries only this one channel.
|
||||
MissileSound::Stop { entity } => {
|
||||
stop_source(&mut out, entity);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,10 @@
|
||||
//! `$WOW_MIX_TAP=<path.wav>` — record the final mix to disk (decision 1112).
|
||||
//! `$WOW_MIX_TAP=<path.wav>`: record the final mix to disk.
|
||||
//!
|
||||
//! The crackle hunts (1026, 1109) kept hitting the same wall: every scheduling meter reads
|
||||
//! healthy while the director's ear still catches a crackle — because the meters watch the
|
||||
//! *pipeline* (deadlines, decoder liveness, stream position) and a crackle can live purely in
|
||||
//! the *waveform* (a clipped sum, a stepped parameter, a discontinuity none of our counters
|
||||
//! model). This closes the gap at the root: a [`kira::effect::Effect`] on the **main track**
|
||||
//! copies every processed frame into a lock-free ring, and a writer thread drains it to a
|
||||
//! standard WAV — so a reported crackle becomes data to scan, timestamp, and classify instead
|
||||
//! of a sound to argue about.
|
||||
//!
|
||||
//! Two properties matter:
|
||||
//! - **What is actually heard.** The tap is the main track's *last* effect, downstream of the
|
||||
//! meter and the limiter (decision 1551), so the capture is the audible result — the waveform
|
||||
//! to scan when a report survives everything upstream. The mix's raw, pre-limiter level is the
|
||||
//! [`super::meter`]'s job and lands in the health report as a number; the tap no longer has to
|
||||
//! double as the clipping detector it once was.
|
||||
//! - **Crash-safe file.** The writer patches the RIFF/data sizes on every flush, so the WAV is
|
||||
//! valid up to the last second even if the app exits hard; there is no finalize step to miss.
|
||||
//!
|
||||
//! The audio-thread half never allocates or blocks (a fixed ring push per sample; overflow is
|
||||
//! counted, not waited on). The ring holds [`RING_SECONDS`] of stereo audio — the writer wakes
|
||||
//! every [`FLUSH_EVERY`] and would have to stall ~50× past its cadence before a sample drops.
|
||||
//!
|
||||
//! Read a capture back with `scripts/mixsum.py <path.wav>`: what the mix was doing, when, in
|
||||
//! numbers (clipping, level, silence, steps).
|
||||
//! A [`kira::effect::Effect`] last on the main track, after the meter and the limiter, copies
|
||||
//! every processed frame into a lock-free ring, and a writer thread drains it to a float WAV
|
||||
//! whose header sizes are patched on every flush, so the file is valid up to a hard exit. The
|
||||
//! audio thread never allocates or blocks: an overflow is counted, not waited on. Read a capture
|
||||
//! back with `scripts/mixsum.py <path.wav>`.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use kira::effect::{Effect, EffectBuilder};
|
||||
@@ -38,10 +19,8 @@ const RING_SECONDS: usize = 8;
|
||||
/// Writer wake cadence. Also the upper bound on audio lost at a hard kill.
|
||||
const FLUSH_EVERY: std::time::Duration = std::time::Duration::from_millis(250);
|
||||
|
||||
/// Install the tap on `main_track_builder` if `$WOW_MIX_TAP` names a path. Returns the builder
|
||||
/// unchanged otherwise. `sample_rate` is the device rate the mixer negotiated — the WAV header
|
||||
/// must match what the renderer actually produces, so an unprobeable device (`None`) skips the
|
||||
/// tap rather than record on a guessed time axis.
|
||||
/// Install the tap if `$WOW_MIX_TAP` names a path. `sample_rate` is the negotiated device rate;
|
||||
/// without one the tap is skipped rather than recorded on a guessed time axis.
|
||||
pub(super) fn install(
|
||||
builder: kira::track::MainTrackBuilder,
|
||||
sample_rate: Option<u32>,
|
||||
@@ -57,17 +36,9 @@ pub(super) fn install(
|
||||
builder
|
||||
}
|
||||
|
||||
/// Install a tap writing `path`, and hand back its **frame clock**: a running count of the frames
|
||||
/// this tap has processed, i.e. the exact write position in the file it is producing.
|
||||
///
|
||||
/// The clock is what makes a capture *correlatable*. A WAV on its own answers "what did the mix
|
||||
/// sound like"; it cannot answer "…at the moment the director pressed the key", because the game
|
||||
/// clock and the device clock are different clocks that drift. Reading this counter from a game
|
||||
/// system converts a game-thread event into a sample offset in the file, so a mark, a kit start
|
||||
/// and a missed deadline all land on the waveform they belong to (decision 1556).
|
||||
///
|
||||
/// Returns `None` if the file cannot be created — a probe that cannot record says so and the run
|
||||
/// continues, rather than taking the client down over an instrument.
|
||||
/// Install a tap writing `path`, and return its frame clock: the count of frames processed, which
|
||||
/// is the write position in the file. A game system reads it to place an event on the waveform,
|
||||
/// since the game and device clocks drift. `None` when the file cannot be created.
|
||||
pub(super) fn install_at(
|
||||
builder: &mut kira::track::MainTrackBuilder,
|
||||
path: &std::path::Path,
|
||||
@@ -113,11 +84,10 @@ impl EffectBuilder for TapBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/// The audio-thread half: copy each frame into the ring. Push-and-count on overflow — the one
|
||||
/// thing this must never do is block or allocate on the render path.
|
||||
/// The audio-thread half: copy each frame into the ring, counting overflow.
|
||||
struct Tap {
|
||||
producer: rtrb::Producer<f32>,
|
||||
/// Frames pushed so far — the file's write position, published for the game thread to read.
|
||||
/// Frames pushed so far, published for the game thread.
|
||||
frames: Arc<AtomicU64>,
|
||||
dropped: u64,
|
||||
}
|
||||
@@ -134,9 +104,8 @@ impl Effect for Tap {
|
||||
}
|
||||
}
|
||||
|
||||
/// The writer half: drain the ring to the WAV, patching the header sizes each flush so the file
|
||||
/// on disk is always valid. Ends (and logs the tally) when the producer side is dropped — i.e.
|
||||
/// when the mixer itself is torn down at app exit.
|
||||
/// The writer half: drain the ring to the WAV, patching the header sizes each flush. Ends when
|
||||
/// the producer drops with the mixer at app exit.
|
||||
fn writer(mut file: std::fs::File, mut consumer: rtrb::Consumer<f32>, sample_rate: u32) {
|
||||
if let Err(e) = file.write_all(&wav_header(sample_rate, 0)) {
|
||||
warn!("mix tap: header write failed: {e}");
|
||||
@@ -156,7 +125,7 @@ fn writer(mut file: std::fs::File, mut consumer: rtrb::Consumer<f32>, sample_rat
|
||||
return;
|
||||
}
|
||||
samples_written += (buf.len() / 4) as u64;
|
||||
// Patch the sizes so the file is valid as-is (crash-safe: no finalize step).
|
||||
// Patch the sizes so the file is valid as it stands.
|
||||
let header = wav_header(sample_rate, samples_written);
|
||||
if file.seek(SeekFrom::Start(0)).is_ok() {
|
||||
let _ = file.write_all(&header);
|
||||
@@ -174,8 +143,7 @@ fn writer(mut file: std::fs::File, mut consumer: rtrb::Consumer<f32>, sample_rat
|
||||
}
|
||||
}
|
||||
|
||||
/// A 44-byte WAV header for stereo IEEE-float-32 at `sample_rate`, sized for `samples` samples
|
||||
/// (total across both channels). Rewritten in place on every flush.
|
||||
/// A 44-byte stereo float-32 WAV header for `samples` samples across both channels.
|
||||
fn wav_header(sample_rate: u32, samples: u64) -> [u8; 44] {
|
||||
let data_bytes = (samples * 4) as u32;
|
||||
let byte_rate = sample_rate * 2 * 4;
|
||||
@@ -200,9 +168,7 @@ fn wav_header(sample_rate: u32, samples: u64) -> [u8; 44] {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The header is a valid stereo float-32 WAV that a strict parser accepts, and the patched
|
||||
/// sizes match the sample count — the crash-safety of the tap rests on every flush leaving
|
||||
/// a well-formed file.
|
||||
/// Every flush must leave a well-formed WAV whose sizes match the sample count.
|
||||
#[test]
|
||||
fn wav_header_is_well_formed_and_patchable() {
|
||||
let h = wav_header(48_000, 96_000); // 1 s of stereo
|
||||
@@ -218,8 +184,7 @@ mod tests {
|
||||
assert_eq!(u32::from_le_bytes(h[28..32].try_into().unwrap()), 384_000);
|
||||
assert_eq!(u32::from_le_bytes(h[40..44].try_into().unwrap()), 384_000);
|
||||
|
||||
// kira's own symphonia decoder accepts the produced file — the exact consumer a
|
||||
// captured tap goes back through when we analyze it.
|
||||
// kira's own decoder accepts the produced file.
|
||||
let mut file = Vec::new();
|
||||
file.extend_from_slice(&wav_header(48_000, 4));
|
||||
for s in [0.5f32, -0.5, 0.25, -0.25] {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user