Files
benilla-octo/crates/benilla-app/src/footprints.rs
T
SamandClaude Fable 5.1 e259854a4d an item on the cursor is a sale, and an animation's sound has a place
- right-clicking a vendor with an item on the cursor sells it: `0x5df5d0` asks `GetCursorItem` before anything else and a non-zero answer takes the arm to `CMSG_SELL_ITEM` instead of `CMSG_LIST_INVENTORY`, where benilla sent the list unconditionally — the whole stack, to the clicked vendor, with no merchant-window gate anywhere in the arm; the clear is `SetCursorItem(0, …)` rather than `ClearCursor`, which fires `CURSOR_UPDATE` and leaves the source slot greyed until the server's inventory update (1914); and the NPC you are talking to is not clickable: `0x5f0130` refuses a right-click on the NPC whose window is already open, against a global with exactly two writers — the interaction setter, whose fourteen callers are every one a window opener, and its clear — so the first click always gets through and no double-click is eaten, where benilla re-sent the arm's opcode on every click (1866)
- a Hated or Hostile questgiver's `!` now disappears, every `!` blinks off and back on when you resurrect, and the minimap no longer dots a corpse, your own pet or an untrackable unit: the sweep tears down on HOSTILITY, not on the flag — `0x5eb134` is a convergent block reached from two branches, so three incompatible readings had each seen one path — and an escort giver's `!` clearing with its flag is the field watch instead, which B257 now rests on; the dots gain `0x4eaa90`'s three preconditions (1906); the quest log's rebuild is all-or-nothing — the reference makes every rebuild a no-op until its template misses are answered, so a cold log shows empty and then lands in one step, never our `"..."` row per in-flight quest — sorted level then title, case-insensitively, with the `id == 0` group first under the reference's own "Missing header! (quest designers)"; and `GetQuestLogTitle`'s `isHeader`/`isCollapsed` answer 1/nil, not booleans
- an animation event has a place, not just a time: the M2 event kernel (`0x719370`) places each fired key through its bone, and benilla carried the when and the what and never the where, so every animation-event sound played at the model root — 149 of 244 `$DSL` records sit off their model's origin, out to 67.6 yd on `Maraudon_Waterfall01.m2` — and the point belongs to the record, not the tag, since 75 models author one tag twice; doodad, GameObject and shake sounds now sound where the key is (1904); and the unit dispatcher's arms do not agree about the point they are handed — footsteps and emotes at the event point (the footstep's material foley stays at the origin), `$TRD` at the origin, `$CSD` at attachment 17 rather than its head-mounted records, `$CSS` at the event point on a hit and attachment 1 on a miss — with `0x623b90`'s attachment-else-bias resolver transcribed table and all (1915)
- `GetSkillLineInfo` out of range answers the reference's thirteen-value tuple, numbers in the rank slots, not a single nil — the stock skill window adds two of those slots unguarded straight off `SetSelectedSkill(0)`, so one nil raises the moment it drives us; one shape serves every out-of-range case because the bounds test at `0x4d3675` is a single unsigned compare, and our source had asserted the opposite (1919); the outbound wire is traced by name (`out`, mirroring `in`), a line pushed only after a successful write — a 5-second visit to the Goldshire poster reads as 87 lines (1901); the binding shape gate seats a player and checks the `Unit*` verbs it had been skipping, coverage 220 → 249, all agreeing with the reference (1911); and a UI capture scenario opts the UI in itself instead of photographing a UI-less world and exiting 0, the opt-in answering `false` in a player build that has no harness

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:51:17 +07:00

395 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! **Footprint decals** — the prints a walking unit leaves on snow and sand (B212, decisions
//! 1006/1012): the fourth client of the shared surface-decal projector ([`benilla_world::decal`]), drawn
//! on the shared effect stream like the blob shadow, but **spawn-once**: a print is projected the
//! frame its foot plants and the cached triangles replay every frame until the fade retires it —
//! exactly the reference's own shape (baked once at spawn over collector-gathered ground
//! triangles; the per-frame draw `0x69a3e0` only re-copies with the current alpha).
//!
//! **The mechanism — wow-re §5 cross-checked** (`footprint-decals.md`, folded back in 1012):
//! - **Surface gate**: `TerrainType.Flags & 1` (`0x699eb6`) — set on exactly **Snow** and
//! **Sand** — on the surface [`benilla_world::surface`] resolves under the unit, the *same* value the
//! footstep sound uses. The reference resolves it once per unit into `CGUnit+0xc60` and the
//! decal, the spray and `$FSD` all read that one dword (wow-re `wmo-footstep-surface.md` F3), so
//! indoors the gate reads the building's own floor: a tavern's floorboards take no prints while
//! the snow outside its door does (decision 1161).
//! - **Trigger**: each per-foot animation event tag (`$xL*`/`$xR*`; the same [`AnimSoundEvent`]
//! stream the sounds read). **`$FSD` is sound-only** — a quadruped whose run authors only
//! `$FSD` leaves no prints, faithfully. Position = the event record's authored offset through
//! the live bone matrix ([`BoneAttach::markers`], `0x7196df`); yaw = the unit's facing.
//! - **Ink + size**: `CreatureModelData.FootprintTextureID` → `FootprintTextures.dbc` →
//! `textures\Footsteps\*` (32×32 pure-black-RGB under soft alpha), sized Length/Width ×(1/36)
//! inches→yards (`0x5fc310`/`0x607a00`) × the unit's wire `OBJECT_FIELD_SCALE_X` **only**
//! (`0x469f10` — no display scale multiplies in). `0xFFFFFFFF` = printless (133 of 430
//! models). A mounted composite prints the MOUNT's ink/dims (the mount model's own event
//! stream) at the RIDER's scale. **The texture is authored as the LEFT foot** — right-foot
//! prints mirror (`0x5fc07f`, scale(−1,1,1) before the yaw).
//! - **Fade**: lifetime **6000 ms**, `t = 1 − age/6000`, `alpha = min(127, ⌊255·t⌋)` — ≈3.0 s
//! hold at ~50 % opacity then a ≈3.0 s linear fade, no fade-in ([`fade`], byte-diffed
//! `PRIMITIVE:footprint_alpha` @`0x69a3e0`).
//! - **Caps**: ring pools of **64 local-player + 512 everyone-else** slots, unconditional
//! rotation (ring-select = GUID == local player, `0xca05f0`).
//! - **Suppressions**: hover (`MOVEFLAG 0x4000_0000`) · stealth (`BYTES_1` byte 3 bit 0x2 —
//! NOT death) · player ghost (`PLAYER_FLAGS & 0x10`) · farther than **50 yd** from the
//! **camera eye** ([`footfall_culls`] — the handler's own gate, shared with the footstep
//! camera shake, and applied to the local player's feet like anyone else's; decision 1856) ·
//! the terrain flag / printless id / no ground triangles. Water does NOT suppress
//! the decal (only the spray branch wades). The reference's `showfootprints` cvar (default on)
//! is the **decal's** toggle and only the decal's: `0x5fc023` skips the decal block alone, so
//! the footstep camera shake and the spray branch both still run with prints switched off. We
//! are always-on until a settings page wires the knob (the cvar-policy line, like the blob
//! shadow's `shadowLOD`).
//!
//! Draw state per the RE: src-alpha blend, depth-write off, unlit, white vertex RGB with the
//! fade in vertex alpha — the ink darkness lives in the texture. Still open there (none
//! load-bearing, note §13): the forward-axis sign convention inside the shared UV basis, the
//! uv1 64×8 edge-fade ramp's combine mode (not modeled here — prints are small; the blob
//! shadow's vertical trapezoid is that ramp's other consumer), and the frame order vs the blob
//! shadow (our rung 2048 under its 4096 is a deterministic stand-in).
use std::collections::VecDeque;
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use crate::creature_anim::{
footfall_culls, footfall_side, move_flags, AnimSoundEvent, MovementState,
};
use crate::entities::Creatures;
use crate::net::{Embodied, NetEntity, ObjectStore};
use crate::sound::footsteps::Footsteps;
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
use benilla_world::decal::{DecalFrame, WorldDecal};
use benilla_world::particles::buffer::{begin_effect_frame, EffectVertex};
use benilla_world::schedule::WorldStage;
use benilla_world::view::WorldCamera;
/// Print lifetime, spawn to gone — the reference's 6000 ms (byte-verified, wow-re
/// `footprint-decals.md`: `t = 1 − age/6000`, die at `t < 0`).
const LIFETIME: f32 = 6.0;
/// The local player's own ring pool: 64 slots (the reserved head of the reference's 576-slot
/// table @`0xca05f0`), unconditional rotation.
const OWN_CAP: usize = 64;
/// Everyone else shares 512 slots (the table's tail), same rotation.
const SHARED_CAP: usize = 512;
/// Vertical reach of the projection slab about the planted foot: enough to catch the ground
/// through a slightly-lifted foot bone and drape a step edge, small enough not to paint a
/// terrace below (the blob shadow's slab is the model box; a print has no box to read).
const SLAB_HALF_HEIGHT: f32 = 1.0;
/// One live print: the projected world-space triangles (vertex alpha 1.0 — the fade multiplies
/// at push time), its spawn stamp, and its ink texture.
struct Print {
verts: Vec<EffectVertex>,
spawned: f32,
texture: AssetId<Image>,
/// The draw's sort anchor (the planted-foot spot).
anchor: Vec3,
}
/// The live prints — the reference's two ring pools ([`OWN_CAP`] local player /
/// [`SHARED_CAP`] everyone else), each spawn-ordered (front = oldest; spawn order is expiry
/// order — one shared lifetime). Resources, not per-print entities: prints are inert after
/// spawn — no identity, queries, or despawn wiring needed.
#[derive(Resource, Default)]
struct Footprints {
own: VecDeque<Print>,
shared: VecDeque<Print>,
}
/// The lane's one identity entity — the phase probe's `main_entity` for every print draw (prints
/// have no entity of their own; a probe line still needs a producer to name).
#[derive(Resource)]
struct FootprintLane(Entity);
/// `FootprintTextures.dbc` id → the loaded ink texture. Loaded once at startup (six rows).
#[derive(Resource, Default)]
struct FootprintInk(HashMap<u32, Handle<Image>>);
pub(crate) struct FootprintsPlugin;
impl Plugin for FootprintsPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<Footprints>()
.add_systems(Startup, load_ink.after(AssetSet::Open))
// Present, like the footstep sounds: the same event stream, after the frame's
// animation drive + transforms have settled the foot bones.
.add_systems(Update, spawn_footprints.in_set(WorldStage::Present))
.add_systems(PostUpdate, push_footprints.after(begin_effect_frame));
}
}
/// Read `FootprintTextures.dbc` and start the six ink textures loading.
fn load_ink(
mut commands: Commands,
assets: Option<Res<WorldAssets>>,
asset_server: Res<AssetServer>,
) {
let lane = commands.spawn(Name::new("footprint-lane")).id();
commands.insert_resource(FootprintLane(lane));
let Some(assets) = assets else { return };
let table = {
let mut chain = assets.chain.lock_recover();
benilla_formats::load_footprint_textures(&mut chain)
};
match table {
Ok(table) => {
let ink = table
.into_iter()
.map(|(id, path)| {
// DBC paths are extensionless (`textures\Footsteps\BaseFootprint`); the
// `mpq://` source wants forward slashes + the `.blp`.
let url = format!("mpq://{}.blp", path.replace('\\', "/"));
(id, asset_server.load::<Image>(url))
})
.collect();
commands.insert_resource(FootprintInk(ink));
}
Err(e) => warn!("footprints: FootprintTextures.dbc failed to load: {e:#}"),
}
}
/// The spawner's ROOT-unit reads (see [`spawn_footprints`]'s `roots` param).
type RootState = (
Has<Embodied>,
Option<&'static NetEntity>,
Option<&'static ObjectStore>,
Option<&'static MovementState>,
);
/// Spawn a print for each per-foot plant on a footprint surface: the state gates (hover /
/// stealth / ghost / distance), the terrain-flags gate, the unit's ink + params, the event
/// marker's live bone position, yaw to the facing, project once, cache the triangles.
#[allow(clippy::too_many_arguments)]
fn spawn_footprints(
mut events: MessageReader<AnimSoundEvent>,
time: Res<Time>,
// GlobalTransform for the same reason as the footstep sounds (0441): a mounted unit's steps
// are the MOUNT child's tags, whose local Transform is the seat-relative ~origin.
units: Query<(&NetEntity, &GlobalTransform)>,
// The spawner's ROOT (the rider for a mount child): the pool select, the print scale (the
// rider's SCALE_X — RE-corrected), and the state gates all read the root.
parents: Query<&ChildOf>,
roots: Query<RootState>,
camera: Query<&GlobalTransform, With<WorldCamera>>,
footsteps: Option<Res<Footsteps>>,
creatures: Option<Res<Creatures>>,
ink: Option<Res<FootprintInk>>,
world: benilla_world::world_point::WorldPoint,
decals: WorldDecal,
mut prints: ResMut<Footprints>,
) {
if events.is_empty() {
return;
}
let (Some(footsteps), Some(creatures), Some(ink)) = (footsteps, creatures, ink) else {
return;
};
// The distance gate's origin — the same read `camera_shake::fire_shakes` makes for the same
// gate. No world camera, no world to print into.
let Ok(eye) = camera.single().map(|t| t.translation()) else {
return;
};
let now = time.elapsed_secs();
for ev in events.read() {
// The VISUAL footfall channel: the per-foot side tags only (`$FSD` is the sound handler's,
// decision 1080). **Right** prints get the mirrored texture — the shipped art is the left
// foot (the `0x5fc07f` `side==0` mirror, corrected from 1006's guess in 1012).
let Some(side) = footfall_side(&ev.ident) else {
continue;
};
let Ok((net, transform)) = units.get(ev.entity) else {
continue;
};
// The ink + dims come from the EVENT's model (the mount for a mounted composite); the
// scale and every state gate from the ROOT unit (the rider) — the RE's split.
let Some(params) = net.display_id.and_then(|d| creatures.footprint(d)) else {
continue;
};
let Some(texture) = ink.0.get(&params.texture_id) else {
continue;
};
let mut root = ev.entity;
while let Ok(child_of) = parents.get(root) {
root = child_of.parent();
}
let (is_self, root_net, store, movement) = match roots.get(root) {
Ok(r) => r,
Err(_) => continue,
};
// The state gates (each byte-verified, module docs): hover · stealth · player ghost.
if movement.is_some_and(|m| m.flags & move_flags::HOVER != 0) {
continue;
}
if let Some(store) = store {
if store.0.unit_is_stealthed() || store.0.player_is_ghost() {
continue;
}
}
// The planted foot: the FIRED key's own point, resolved once by the scanner as the kernel
// snapshots it (decision 1904). It used to be a by-4CC re-find of the marker table, which
// is the *launch-point* mechanism (`0x7130e0`, first match) and not this one — a model that
// authors a side tag twice would have printed both feet at the first record's bone.
let foot = ev.pos.unwrap_or_else(|| transform.translation());
// The handler's own 50 yd radius about the CAMERA EYE — every unit's feet, the
// player's included ([`footfall_culls`]).
if footfall_culls(eye, foot) {
continue;
}
// The surface gate, on the SAME terrain type the footstep sound used: the reference
// resolves it once per unit (`CGUnit+0xc60`) and the decal, the spray and `$FSD` all read
// that one dword (wow-re `wmo-footstep-surface.md` F3). So this reads the UNIT's surface,
// not a per-foot sample of the ground — indoors that is the building's own floor, which is
// why a tavern's floorboards take no prints while the snow outside its door does.
let terrain = world.terrain_type(
&footsteps.0,
benilla_world::world_point::Subject::Unit(ev.entity),
transform.translation(),
);
if !terrain.is_some_and(|t| footsteps.0.terrain_leaves_footprints(t)) {
continue;
}
// Print frame: length along the unit's facing, width across, yawed to the facing.
// With `(sin, cos) = (sin θ, cos θ)` the facing maps to the frame's −z′ axis, so v = 0
// is the toe end (the absolute sign convention is the RE's open §13 item — a
// backwards-pointing print flips one sign here).
let yaw = transform
.to_scale_rotation_translation()
.1
.to_euler(EulerRot::YXZ)
.0;
// The rider's wire SCALE_X for a mounted composite, the unit's own otherwise (RE: the
// print scale is SCALE_X alone — display scales never multiply in).
let scale = root_net.unwrap_or(net).scale.max(0.0);
let (half_len, half_wid) = (params.length * scale * 0.5, params.width * scale * 0.5);
if half_len <= 0.0 || half_wid <= 0.0 {
continue;
}
let frame = DecalFrame {
center: foot,
sin: yaw.sin(),
cos: yaw.cos(),
min_x: -half_wid,
max_x: half_wid,
min_z: -half_len,
max_z: half_len,
min_y: -SLAB_HALF_HEIGHT,
max_y: SLAB_HALF_HEIGHT,
};
let mirror = side == b'R';
let mut verts = Vec::new();
let projected = decals.project(
&mut verts,
&frame,
|_| 1.0,
|x, z| {
let [u, v] = frame.rect_uv(x, z);
[if mirror { 1.0 - u } else { u }, v]
},
);
if !projected {
continue; // no receiving surface under the foot (the projector's no-ground gate)
}
let n_verts = verts.len();
let (pool, cap) = if is_self {
(&mut prints.own, OWN_CAP)
} else {
(&mut prints.shared, SHARED_CAP)
};
if pool.len() >= cap {
pool.pop_front(); // the reference's unconditional ring rotation
}
pool.push_back(Print {
verts,
spawned: now,
texture: texture.id(),
anchor: foot,
});
// The lane's census line (`RUST_LOG=benilla_app::footprints=debug`): every spawn names
// its tag, ink, spot and triangle count — the first question of any "no prints under X"
// report, answerable from a log (the blob shadow census pattern).
debug!(
"footprint: {} ink {} at ({:.2}, {:.2}, {:.2}), {} verts ({} own + {} shared live)",
ev.ident.map(char::from).iter().collect::<String>(),
params.texture_id,
foot.x,
foot.y,
foot.z,
n_verts,
prints.own.len(),
prints.shared.len(),
);
}
}
/// The reference's fade at age seconds (byte-diffed `PRIMITIVE:footprint_alpha` @`0x69a3e0`):
/// `t = 1 − age/6 s`, `alpha_byte = min(127, ⌊255·t⌋)` — ≈3.0 s hold at 127/255 (~50 %
/// opacity), then linear to 0; no fade-in; dead past [`LIFETIME`].
fn fade(age: f32) -> f32 {
let t = 1.0 - age / LIFETIME;
if t <= 0.0 {
return 0.0;
}
(255.0 * t).floor().min(127.0) / 255.0
}
/// Retire expired prints, then replay every live print's cached triangles onto the effect
/// stream — src-alpha blend of the black-RGB ink, the fade multiplied into the vertex alpha at
/// push time (the cache stays untouched).
fn push_footprints(
time: Res<Time>,
cam: Query<Entity, With<WorldCamera>>,
lane: Option<Res<FootprintLane>>,
mut draw: benilla_world::particles::buffer::WorldEffectDraw,
mut prints: ResMut<Footprints>,
) {
let now = time.elapsed_secs();
let prints = &mut *prints;
for pool in [&mut prints.own, &mut prints.shared] {
while pool.front().is_some_and(|p| now - p.spawned >= LIFETIME) {
pool.pop_front();
}
}
if prints.own.is_empty() && prints.shared.is_empty() {
return;
}
let Ok(cam) = cam.single() else { return };
let Some(lane) = lane else { return };
for print in prints.own.iter().chain(prints.shared.iter()) {
let alpha = fade(now - print.spawned);
let mut batch = draw
.batch(cam, print.texture)
.anchored(print.anchor)
.rung(
benilla_world::sky_order::Rung::FOOTPRINT,
benilla_world::sky_order::Rung::DECAL_RASTER,
)
.owner(lane.0);
batch.extend(print.verts.iter().map(|v| EffectVertex {
color: [v.color[0], v.color[1], v.color[2], v.color[3] * alpha],
..*v
}));
batch.tris();
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The byte-verified ramp: no fade-in — 127/255 from age 0, held while `255·t ≥ 127`
/// (through age = 6·(1 − 127/255) ≈ 3.012 s), then the raw `⌊255·t⌋/255` line to zero at
/// 6 s, dead after.
#[test]
fn fade_matches_the_reference_ramp() {
assert_eq!(fade(0.0), 127.0 / 255.0);
assert_eq!(fade(1.0), 127.0 / 255.0);
assert_eq!(fade(3.0), 127.0 / 255.0);
// Past the hold knee: the raw line. age 4.5 → t = 0.25 → ⌊63.75⌋ = 63.
assert_eq!(fade(4.5), 63.0 / 255.0);
// age 5.988 → t = 0.002 → ⌊0.51⌋ = 0 — the line reaches the floor before death.
assert_eq!(fade(5.988), 0.0);
assert_eq!(fade(6.0), 0.0);
assert_eq!(fade(7.0), 0.0);
}
}