Files
benilla-octo/crates/benilla-app/src/blob_shadow.rs
T
SamandClaude Fable 5.1 00150c55b5 a crash leaves a report, and the wire's counts are capped
- a panic writes benilla-config/Diagnostics/crash-<time>.txt with the build id, the location, a backtrace and the last 200 log lines, and names the file as its last line, so a bug report can attach it (2266)
- every wire-count allocation hint is capped and the compressed update object reads through the cursor, so a malformed packet can no longer ask for gigabytes (2266)
- the 23 in-world run conditions are one set, the 45 probe variables one registry, the install-gated tests say when they skip, and every gate is timed (2266)
- one workspace-level lint allowance replaces 413 per-site attributes, and the decision corpus gets a lint in the gate (2266)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 16:38:59 +07:00

614 lines
30 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.
//! The **unit blob shadow** — the soft dark oval under every unit (the player, every NPC, every
//! creature), the reference's per-frame shadow pass rebuilt on the shared surface-decal projector
//! ([`benilla_world::decal`]), drawn on the shared effect stream (0733).
//!
//! **The byte-verified mechanism** (wow-re `unit-blob-shadow.md`, a §5 cross-check; the "cloud
//! shadow" label on `0x6d7920` was corrected — it IS the unit shadow draw):
//! - **Draw path**: a per-frame pass over registered model nodes (`0x683dd0`, list `[0xc7cb10]`)
//! → gate `0x6d78f0` (model streamed, master toggle) → `0x6d7920` → the **same decal chain the
//! selection ring uses** (`0x6d7330 → 0x6d6fa0 → 0x6d7480`), collector flags `0x2f0122` = the
//! ring's `0x200122` **+ the liquid receivers** (a gap here: liquid isn't in the
//! [`GroundDecalSurface`] set yet — the shadow lands on terrain + WMO faces only).
//! - **Frame slot**: PHASE 1, among the opaque drains — `0x6812c5 call 0x683dd0`, fifth of the
//! row `0x6812b1`–`0x6812ca` inside `0x681070`, which the driver `0x483460` calls at
//! `0x48361d` (wow-re `water-frame-straddle.md` §1 + `unit-blob-shadow.md` Q1). `0x683dd0` is
//! the **M2 node drain**, and the same loop body ticks each node's object first
//! (`0x48160c call [obj vt+0x38]` → the selection ring) and draws its shadow second
//! (`0x683ec3`) — so per unit the additive ring goes down and this modulate darkens it, never
//! the other way round (wow-re `decal-frame-slot.md`). That second step is **not** a gate:
//! both exits of the tick `0x481540` return 1, so `0x683ea5`'s `je` is dead — an earlier
//! reading of it as "the callback suppresses a hidden object's shadow" was wrong at the bytes
//! and is corrected in wow-re (2026-09-06). What the tick DOES do is write the alpha this draw
//! is about to read, 30 bytes later — which is the whole of §"Appearance" below. So the shadow
//! lands after terrain and WMO and **before** everything else: the footprint decals
//! (`0x483654`), the M2 opaque pass (`0x4836a6`), the water surfaces (phase 3, drawn *between*
//! the two M2 transparent passes) and both of those passes. Every transparent in the world
//! paints over it — which is what [`Rung::SHADOW_SORT`](benilla_world::sky_order::Rung) now
//! expresses, and what it did not until B347: at the old positive rung the shadow was the one
//! thing in the scene a river or a portal effect could not attenuate.
//! - **Texture**: `Textures\ShadowBlob.blp` — a 32×32 grayscale radial blob (flat gray-160 core,
//! linear rim to white) under a binary alpha disc. The reference multitextures a procedural 64×8
//! trapezoid ramp on a second stage (`0x6d81a0`/`0x6d82d0`, blend-mode-selected); its combine
//! wiring is an open RE item (apitrace) — here the ramp is the vertex-alpha vertical fade below.
//! - **Box law** (`0x711a20` + the `0x6d7920` corner build): a sequence CAaBox, clamped INTO ±5
//! per axis (a cap, never a floor), scaled by the world matrix, yaw-rotated with the unit's
//! facing then **axis-aligned-bounded**. Vertical about the model origin: `+1.0·(zExt/2)` up,
//! `−(5/3)·(zExt/2)` down. A degenerate horizontal box is the reference's no-op exit (no
//! shadow). **No** `OBJECT_FIELD_SCALE_X` re-read (the transform scale already carries it), no
//! ring-style `sqrt` compression, no floor. **WHICH sequence — settled at bytes + pixels**
//! (decision 0316; wow-re `27406d9b`, Q3-ORACLE): the draw re-reads
//! `playableAnimationLookup[0]` every frame — **slot 0 = Stand for characters, from the file
//! image, so the value never changes** (not the playing sequence: the director's gait-stable
//! observation falsified that first reading, and the trace oracle confirmed — 1,682 measured
//! draws, six bit-stable box sizes, HumanMale 0.9134 × 1.0805 yd permanently, Walk/Run extents
//! never appear). Full extents, no missing half/scale factor — the standing size IS the law.
//! - **Appearance**: multiplicative darken — `GL_DST_COLOR/GL_ZERO` with the fade riding the
//! combine, which is exactly the lane's `EffectBlend::Multiply` (`dst × lerp(1, src, α)`).
//! Vertex diffuse is **white** with α = the model's **base** alpha — `CM2Model+0x180`, read
//! through the accessor `0x710ca0` (`fld [ecx+0x180]`) at `0x6d7fd6`, then clamped to `[0,1]`,
//! ×255, `__ftol`, packed `(a<<24)|0x00FFFFFF`. VERIFIED, and it is `+0x180` and not `+0x19c`:
//! over the whole draw every `0x19c` operand is the stack local `[ebp-0x19c]`, and `this` is
//! touched at exactly three `mov ecx,ebx; call` sites (box `0x711a20`, matrix `0x710600`,
//! alpha `0x710ca0`), which makes that list the complete field census.
//!
//! **What rides in, at the bytes** (wow-re, 2026-09-06): the 2 s appear ramp and the despawn
//! ramp (`obj+0xf4`, `0x613b1e`'s `0x7d0`), the self first-person fade, **and the CharProc-14
//! aura transition** — stealth's `0.3`, ghost/invisibility's `0.5`. The aura is not a second
//! channel: `0x60d180` drives the SAME `StartAlphaFade` (`0x614f80`) the appear fade uses, and
//! `0x614a90` writes `model+0x180 = obj+0x100 · obj+0xf4` unconditionally given a model handle.
//! A stealthed unit's blob shadow dims with its body, on the same 1000 ms cubic.
//!
//! **What does not**: the M2 animated colour / texture-weight tracks. The body's per-batch
//! alpha is `+0x19c · M2Color[i].alpha · M2TextureWeight[j].value` (`0x707aea`–`0x707b33`);
//! this draw reads `+0x180` raw, so an asset-authored transparency track fades the body and
//! leaves its shadow at full strength. [`benilla_world::model_fade::UnitRenderAlpha`] is that
//! same product and stops at the same place.
//!
//! The darkness lives in the texture RGB. Unlit, no fog, no depth write. The texture loads as the default `WorldArt`
//! `Rgba8Unorm`, so the modulate multiplies raw bytes in the gamma lane — the reference's own
//! arithmetic (0161).
//! - **Gating**: the reference's `shadowLOD` cvar {0,1} is the master toggle (default on) — we are
//! always-on; `shadowBias` (default 0.1) is its depth-bias knob — [`SHADOW_DEPTH_BIAS`] plays
//! that role here. No dead/mount/kind test exists on the draw path, and **which** objects
//! register is now settled (wow-re, 2026-09-06 — the old `HANDOFF(-> object-layer)` is closed):
//! `[node+0x90]` bit `0x400` is `NOT(arg bit1)` (`0x670e94`), and `0x613e10` takes that arg off
//! `OBJECT_FIELD_TYPE` — `0xb` GAMEOBJECT, `2` DYNAMICOBJECT, `0` otherwise. **GameObjects and
//! DynamicObjects never cast a blob shadow; units, players and corpses always do.** Which is
//! the v1 policy this lane already ran (every Player/Unit with a built animated model), now a
//! byte-fact rather than a guess.
//!
//! **One benilla divergence, deliberate.** `0x6d78f0`/`0x6d7920` read no visibility state at
//! all — the body's draw flag `[CM2Model+0x50]` comes from `ShouldRender` at `0x48174e`, which
//! is consumed at `0x48161d`, *after* the tick — so the reference can and does draw a shadow
//! under an undrawn body (a unit whose skin composite is still unbaked, `0x477860`). We gate on
//! `InheritedVisibility` anyway, because benilla's exterior-scene election is not a mechanism
//! the reference has and left Tanaris mobs' shadows on the Caverns of Time floor (decision
//! 1277). Keeping that gate is the 1277 call; it is named here so it reads as a choice.
//!
//! One shadow record per unit, its projected triangles rebuilt only when the inputs move
//! ([`ShadowKey`]) and pushed onto the effect stream every shown frame — an idle unit costs a
//! key compare plus one memcpy of its cached slice. (The stream has no per-draw frustum cull;
//! an off-screen shadow's triangles are vertex-clipped GPU-side — dozens of ~50-vert slices,
//! below any ledger line.)
use benilla_assets::ModelAnimations;
use benilla_protocol::EntityKind;
use bevy::ecs::entity::EntityHashSet;
use bevy::prelude::*;
use crate::creature_anim::AnimData;
use crate::net::{Embodied, NetEntity};
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;
/// The reference's shadow disc (`Textures\ShadowBlob.blp`, wow-re unit-blob-shadow RE): grayscale
/// radial blob (gray-160 core → white rim) under a binary alpha disc, multiplied onto the ground.
const SHADOW_TEXTURE: &str = "mpq://textures/shadowblob.blp";
/// The byte clamp on the animation box: each corner component is clamped INTO ±5 yd pre-scale
/// (`0x6992c0` MAX(−5) / `0x699250` MIN(+5) — a cap on huge authored boxes, never a floor).
const BOX_CLAMP: f32 = 5.0;
/// Degenerate-box epsilon (the reference's `[0x8029d4]` = 2.384e-7): a zero horizontal extent is
/// the no-op exit — no shadow.
const DEGENERATE_EPS: f32 = 2.384e-7;
/// One unit's shadow record (a top-level entity — no render components; the cached projection
/// rides the effect stream). Despawned when the owner goes.
#[derive(Component)]
struct BlobShadow {
owner: Entity,
}
/// Last frame's rebuild inputs — the projection is redone only when one moves. `surfaces` counts
/// the [`GroundDecalSurface`] colliders: a tile streaming in under a *standing* unit changes it,
/// re-arming the rebuild its stillness would otherwise skip.
#[derive(Component, Default)]
struct ShadowKey {
feet: Vec3,
rotation: Quat,
box_min: Vec3,
box_max: Vec3,
alpha: f32,
surfaces: usize,
shown: bool,
}
/// The cached projection: world-space effect triangles (white × the ramp/fade alpha), pushed
/// onto the stream every shown frame, rebuilt on [`ShadowKey`] change. Empty = hidden.
#[derive(Component, Default)]
struct ShadowVerts(Vec<EffectVertex>);
/// The one shadow texture (kept so the census can report the image's load state — a texture
/// that never arrives withholds every shadow draw at the render-side residency gate, silently).
#[derive(Resource)]
struct ShadowAssets {
texture: Handle<Image>,
}
pub(crate) struct BlobShadowPlugin;
impl Plugin for BlobShadowPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, setup_shadow_assets)
.add_systems(
Update,
(sync_shadows, update_shadows)
.chain()
// After net motion + input: the decal follows this frame's unit transforms.
.after(WorldStage::Input),
)
// The stream push: after the frame's stream clear (the caches were rebuilt in
// `Update`, so this is a pure copy).
.add_systems(PostUpdate, push_shadows.after(begin_effect_frame));
}
}
/// Load the shadow disc once.
fn setup_shadow_assets(mut commands: Commands, asset_server: Res<AssetServer>) {
let texture = asset_server.load::<Image>(SHADOW_TEXTURE);
commands.insert_resource(ShadowAssets { texture });
}
/// Keep one shadow record per eligible unit: spawn for new Player/Unit entities whose model has
/// built (an animated model — [`ModelAnimations`] arrives with it), despawn orphans (owner
/// destroyed / streamed out). The registration *policy* is the open RE item; this is the v1 set
/// (see module docs).
#[allow(clippy::type_complexity)] // the filtered spawn-gate query, commented inline
fn sync_shadows(
mut commands: Commands,
// A mount child never gets its own decal: its `Transform` is parent-relative (a shadow
// keyed on it would project at the world origin) — the mounted composite casts ONE shadow,
// the unit's, which reads the mount's box while mounted (`update_shadows`, decision 0441).
units: Query<
(Entity, &NetEntity),
(
With<ModelAnimations>,
Without<crate::entities::mount::MountBody>,
),
>,
shadows: Query<(Entity, &BlobShadow)>,
// The reconciler's inputs move only when a unit's animated model arrives or leaves, or a
// unit becomes or stops being a mount child (the `units` filter's two terms); a frame with
// none of those has the same answer it had last frame, and used to pay a set build plus a
// walk of every unit for it.
grew: Query<(), Added<ModelAnimations>>,
mut shrank: RemovedComponents<ModelAnimations>,
mounted: Query<(), Added<crate::entities::mount::MountBody>>,
mut unmounted: RemovedComponents<crate::entities::mount::MountBody>,
) {
if grew.is_empty()
&& mounted.is_empty()
&& shrank.read().next().is_none()
&& unmounted.read().next().is_none()
{
return;
}
let mut shadowed = EntityHashSet::default();
for (entity, shadow) in &shadows {
// Owner gone or no longer eligible (model torn down) → the decal goes with it.
if units.get(shadow.owner).is_err() {
commands.entity(entity).despawn();
} else {
shadowed.insert(shadow.owner);
}
}
for (owner, net) in &units {
if !matches!(net.kind, EntityKind::Player | EntityKind::Unit) || shadowed.contains(&owner) {
continue;
}
commands.spawn((
BlobShadow { owner },
ShadowKey::default(),
ShadowVerts::default(),
));
}
}
/// Re-project each shadow whose inputs moved; clear it when the box degenerates, the fade
/// reaches zero, or no receiving surface is in the box (the reference's no-ground gate).
#[allow(clippy::type_complexity)]
fn update_shadows(
time: Res<Time>,
catalog: Option<Res<AnimData>>,
shadow_assets: Option<Res<ShadowAssets>>,
images: Res<Assets<Image>>,
decals: WorldDecal,
// The unit's render alpha, asked of the unit — see the alpha block below.
unit_alpha: benilla_world::model_fade::UnitRenderAlpha,
owners: Query<
(
&Transform,
&ModelAnimations,
Has<Embodied>,
Option<&crate::entities::mount::MountChild>,
// …and whether the owner is drawn at all. A body the exterior-scene election sent to
// pass 2 is not in the reference's scene, so it casts nothing (decision 1277). The
// election writes the ROOT's `Visibility`; this is that verdict after propagation.
Option<&InheritedVisibility>,
),
Without<BlobShadow>,
>,
// The mounted box source: the composite's one shadow reads the MOUNT's Stand box at the
// mount's rendered scale while a mount model is attached (the mount IS the footprint on the
// ground; the rider's box would undersize it). The mount-vs-body source of the client's own
// shadow box is untraced — this is the named approximation of decision 0441's P2, carried
// until a wow-re shadow-consumer trace pins it.
mount_anims: Query<(&NetEntity, &ModelAnimations), With<crate::entities::mount::MountBody>>,
mut shadows: Query<(&BlobShadow, &mut ShadowKey, &mut ShadowVerts)>,
// Once-a-second census at debug level (`RUST_LOG=benilla_app::blob_shadow=debug` — the lib
// target is `benilla_app`; a `benilla::` filter silently matches nothing): how many
// shadows exist and why the hidden ones hid — the first question of any "no shadow under X"
// report, answerable from a log instead of a debugger.
mut census_at: Local<f32>,
) {
let now = time.elapsed_secs();
let census = now >= *census_at;
if census {
*census_at = now + 1.0;
}
let (mut n_total, mut n_shown, mut n_no_owner, mut n_no_clip, mut n_degen, mut n_no_ground) =
(0u32, 0u32, 0u32, 0u32, 0u32, 0u32);
// Counted apart from `n_no_owner`: a body the exterior election sent to pass 2 HAS an owner,
// and folding the two would make the census answer "why did it hide" with a lie. This is the
// instrument's whole job (decision 1283).
let mut n_undrawn = 0u32;
let surface_count = decals.receiver_count();
for (shadow, mut key, mut verts) in &mut shadows {
n_total += 1;
let Ok((unit, anims, is_self, mount_child, drawn)) = owners.get(shadow.owner) else {
// sync_shadows despawns next frame; keep it cleared meanwhile.
hide(&mut key, &mut verts);
n_no_owner += 1;
continue;
};
// An owner that is not drawn casts nothing. The director's report from inside Caverns of
// Time (decision 1277): the exterior election had correctly stopped drawing the Tanaris
// mobs overhead, and their shadows carried on being projected onto the cavern floor,
// because this lane keys off the unit's `Transform` and never asked whether the unit was
// in the scene. The census counts it separately, so "why is there a shadow with no
// creature" is answerable from the log rather than from a debugger.
if !drawn.is_none_or(|v| v.get()) {
hide(&mut key, &mut verts);
n_undrawn += 1;
continue;
}
// Mounted: the box and the extra scale column come from the mount child (the
// `mount_anims` doc above); until its model lands, the rider's own box carries the frame.
let (anims, extra_scale) = match mount_child.and_then(|mc| mount_anims.get(mc.0).ok()) {
Some((mnet, manims)) => (manims, mnet.scale),
None => (anims, 1.0),
};
// The byte+pixel law (0316, wow-re 27406d9b): the box is playableAnimationLookup[0]'s
// sequence — Stand, permanently (the reference re-reads it per frame from the file image;
// the value can't change). resolve(0) walks the same baked table, so Stand-less models
// land on their substitute exactly like the binary's row-0 fast path.
let stand = catalog.as_deref().map_or(0, |c| anims.resolve(0, &c.0).id);
let clip = anims.find(stand);
let Some(clip) = clip else {
hide(&mut key, &mut verts);
n_no_clip += 1;
continue;
};
// The box law (see module docs): clamp INTO ±5 pre-scale, scale, yaw-rotate + AA-bound.
let s = (unit.scale.x * extra_scale).max(0.0);
let bmin = clip
.bounds_min
.clamp(Vec3::splat(-BOX_CLAMP), Vec3::splat(BOX_CLAMP))
* s;
let bmax = clip
.bounds_max
.clamp(Vec3::splat(-BOX_CLAMP), Vec3::splat(BOX_CLAMP))
* s;
if bmax.x - bmin.x <= DEGENERATE_EPS || bmax.z - bmin.z <= DEGENERATE_EPS {
// The reference's degenerate-box no-op exit (`0x61e9c0`): no shadow.
hide(&mut key, &mut verts);
n_degen += 1;
continue;
}
// **The unit's render alpha, asked of the unit** — the shadow rides the model's fade slot
// (module docs), so it takes the engine's answer rather than reconstructing one.
//
// It used to gather the live `RenderFade`s off this unit's *part* entities and read "no
// part is fading" as `1.0`. That is true of a settled unit and exactly backwards for the
// state a unit spends its whole arrival in: a streamed unit holds `PendingAppearFade` —
// no `RenderFade` anywhere yet — until the world is actually shown, so its parts are
// deliberately invisible while this walk found nothing to attribute and drew the shadow
// **fully opaque**. A dark oval on the ground under no creature for the length of the
// load (2.9 s on a probe login), snapping to zero the instant the ramp armed, then easing
// back in with the body. `UnitRenderAlpha` asks the root, where `UnitAppearFade` tells
// pending from settled — the distinction a part-side walk cannot make.
//
// The self first-person fade comes with it (the reference rides the same slot), which is
// why there is no `is_self` term here any more.
let alpha = unit_alpha.get(shadow.owner);
if alpha <= 0.0 {
hide(&mut key, &mut verts);
continue;
}
let next = ShadowKey {
feet: unit.translation,
rotation: unit.rotation,
box_min: bmin,
box_max: bmax,
alpha,
surfaces: surface_count,
shown: true,
};
if key.shown && !key_changed(&key, &next) {
n_shown += 1;
continue;
}
// Horizontal: the model box's 4 rect corners through the unit's rotation with the
// vertical column dead (`rot × (x, 0, z)`, XZ taken — the byte build zeroes the z-terms),
// then axis-aligned-bounded.
let (mut min_x, mut max_x) = (f32::MAX, f32::MIN);
let (mut min_z, mut max_z) = (f32::MAX, f32::MIN);
for (x, z) in [
(bmin.x, bmin.z),
(bmin.x, bmax.z),
(bmax.x, bmin.z),
(bmax.x, bmax.z),
] {
let w = unit.rotation * Vec3::new(x, 0.0, z);
(min_x, max_x) = (min_x.min(w.x), max_x.max(w.x));
(min_z, max_z) = (min_z.min(w.z), max_z.max(w.z));
}
// Vertical about the model origin: `+1.0·(zExt/2)` up, `−(5/3)·(zExt/2)` down (the byte
// constants `[0xcea60c]`/`[0xcea610]`).
let half_v = (bmax.y - bmin.y) * 0.5;
let frame = DecalFrame {
center: unit.translation,
sin: 0.0,
cos: 1.0,
min_x,
max_x,
min_z,
max_z,
min_y: -half_v * (5.0 / 3.0),
max_y: half_v,
};
let span_v = frame.max_y - frame.min_y;
verts.0.clear();
let projected = span_v > 0.0
&& decals.project(
&mut verts.0,
&frame,
|p| {
// The trapezoid ramp over the vertical span (the reference's second texture
// stage, `0x6d81a0`: rise x<2, flat, fall x≥10 over x = 12·u). *Interim
// seat*: that the ramp runs vertically is inferred from the box's asymmetric
// vertical reach; the combine wiring is the flagged apitrace item.
alpha * shadow_ramp((p.y - frame.min_y) / span_v)
},
|x, z| frame.rect_uv(x, z),
);
*key = next;
key.shown = projected;
if projected {
n_shown += 1;
} else {
verts.0.clear();
n_no_ground += 1;
}
if census && is_self {
// The census's self row: where the OWN shadow's projection actually went — the
// "shadow missing under me" report's second question (the first is the gate line
// below). Y-extents vs feet split "landed on the surface I stand on" from "fell
// through to a receiver below" in one read.
let (mut y_min, mut y_max) = (f32::MAX, f32::MIN);
let (mut x_min, mut x_max) = (f32::MAX, f32::MIN);
let (mut z_min, mut z_max) = (f32::MAX, f32::MIN);
for v in &verts.0 {
y_min = y_min.min(v.pos[1]);
y_max = y_max.max(v.pos[1]);
x_min = x_min.min(v.pos[0]);
x_max = x_max.max(v.pos[0]);
z_min = z_min.min(v.pos[2]);
z_max = z_max.max(v.pos[2]);
}
// World-XZ extents of what was actually emitted vs the frame rect: if these disagree,
// the projector inflated/displaced the box (the -8844,669 hunt).
debug!(
"self shadow world span: x [{:.3}, {:.3}] ({:.3}), z [{:.3}, {:.3}] ({:.3}); \
feet ({:.3}, {:.3}), yaw {:.1} deg",
x_min,
x_max,
x_max - x_min,
z_min,
z_max,
z_max - z_min,
unit.translation.x,
unit.translation.z,
unit.rotation
.to_euler(bevy::math::EulerRot::YXZ)
.0
.to_degrees(),
);
debug!(
"self shadow: feet y {:.3}, box y [{:.3}, {:.3}], {} verts, vert y [{:.3}, \
{:.3}]",
unit.translation.y,
unit.translation.y + frame.min_y,
unit.translation.y + frame.max_y,
verts.0.len(),
y_min,
y_max
);
// The -8844,669 hunt's uv probe: a span check alone can't see a DEGENERATE mapping
// (all uv.y at the rim still spans 0..1) — print the actual per-vert uv pairs.
let uvs: Vec<String> = verts
.0
.iter()
.map(|v| format!("({:.3},{:.3})", v.uv[0], v.uv[1]))
.collect();
debug!("self shadow uvs: {}", uvs.join(" "));
// And the box/rect numbers: the oracle says HumanFemale's footprint is 0.77x0.74 yd
// nearly centred; a bigger or offset rect indicts the box math, not the projector.
debug!(
"self shadow box: bmin {:?} bmax {:?} rect x [{:.3}, {:.3}] z [{:.3}, {:.3}] \
(extent {:.3}x{:.3}, centre offset ({:.3}, {:.3}))",
bmin,
bmax,
min_x,
max_x,
min_z,
max_z,
max_x - min_x,
max_z - min_z,
(min_x + max_x) * 0.5,
(min_z + max_z) * 0.5,
);
}
}
if census && n_total > 0 {
// Not just "loaded": the CONTENT. A white-decoded blob multiplies to a no-op — an
// invisible shadow whose every draw-side reading looks healthy (the -8844,669 hunt).
let tex = shadow_assets.map_or("no-resource".into(), |a| {
images.get(&a.texture).map_or("MISSING".into(), |img| {
let (w, h) = (img.width(), img.height());
let center = img
.data
.as_ref()
.and_then(|d| {
let i = ((h / 2) * w + w / 2) as usize * 4;
d.get(i..i + 4).map(|p| format!("{p:?}"))
})
.unwrap_or_else(|| "no-data".into());
// The -8844,669 hunt: the whole centre ROW, not one texel — the ink radius and
// alpha reach decide how much of the box the disc visibly fills.
if let Some(d) = img.data.as_ref() {
let row: Vec<String> = (0..w as usize)
.map(|x| {
let i = ((h / 2) as usize * w as usize + x) * 4;
d.get(i..i + 4)
.map(|p| format!("{}/{}", p[0], p[3]))
.unwrap_or_default()
})
.collect();
debug!("blob row {}: {}", h / 2, row.join(" "));
}
format!("loaded {w}x{h} center {center} id {:?}", a.texture.id())
})
});
debug!(
"blob shadows: {n_total} ({n_shown} shown, {n_no_owner} ownerless, {n_undrawn} \
undrawn, {n_no_clip} no-clip, {n_degen} degenerate, {n_no_ground} no-ground; \
{surface_count} surfaces; texture {tex})"
);
}
}
/// Push every shown shadow's cached triangles onto the stream — one Multiply draw per unit at
/// the shadow rung (the pre-water decal band's floor, the module header's frame slot), fog off:
/// the reference's shadow pass state.
fn push_shadows(
assets: Option<Res<ShadowAssets>>,
cam: Query<Entity, With<WorldCamera>>,
mut draw: benilla_world::particles::buffer::WorldEffectDraw,
shadows: Query<(Entity, &ShadowKey, &ShadowVerts)>,
) {
let Some(assets) = assets else { return };
let Ok(cam) = cam.single() else { return };
for (entity, key, verts) in &shadows {
if verts.0.is_empty() {
continue;
}
// Multiply: the modulate decal is its own darkening — the scene light is already in the
// ground it multiplies, which is why the lane's unlit default is right here.
let mut batch = draw
.batch(cam, assets.texture.id())
.multiply()
.anchored(key.feet)
.rung(
benilla_world::sky_order::Rung::SHADOW_SORT,
benilla_world::sky_order::Rung::DECAL_RASTER,
)
.owner(entity);
batch.vertices(&verts.0);
batch.tris();
}
}
/// Clear the record and drop the cache key so the next eligible frame rebuilds from scratch.
fn hide(key: &mut ShadowKey, verts: &mut ShadowVerts) {
key.shown = false;
verts.0.clear();
}
/// Did any rebuild input move beyond noise? Position/box at a millimetre, rotation at ~0.05°,
/// alpha at under a colour step.
fn key_changed(a: &ShadowKey, b: &ShadowKey) -> bool {
const POS_EPS: f32 = 1e-3;
a.feet.distance_squared(b.feet) > POS_EPS * POS_EPS
|| a.rotation.angle_between(b.rotation) > 1e-3
|| (a.box_min - b.box_min).abs().max_element() > POS_EPS
|| (a.box_max - b.box_max).abs().max_element() > POS_EPS
|| (a.alpha - b.alpha).abs() > 1.0 / 255.0
|| a.surfaces != b.surfaces
}
/// The reference's trapezoid alpha ramp (`0x6d81a0`/`0x6d82d0`, diffed bit-exact in wow-re:
/// `x = 12·u` — rise `x<2 → x/2`, flat `2≤x<10 → 1`, fall `x≥10 → (12−x)/2`, clamped at 0).
fn shadow_ramp(u: f32) -> f32 {
let x = 12.0 * u.clamp(0.0, 1.0);
if x < 2.0 {
0.5 * x
} else if x < 10.0 {
1.0
} else {
(0.5 * (12.0 - x)).max(0.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The ramp's byte-verified shape: rise to 1 at x=2 (u=1/6), flat through x=10 (u=5/6), fall
/// to 0 at x=12 (u=1).
#[test]
fn ramp_matches_reference_trapezoid() {
assert_eq!(shadow_ramp(0.0), 0.0);
assert!((shadow_ramp(1.0 / 12.0) - 0.5).abs() < 1e-6); // x=1 → 0.5
assert!((shadow_ramp(1.0 / 6.0) - 1.0).abs() < 1e-6); // x=2 → 1.0
assert_eq!(shadow_ramp(0.5), 1.0);
assert!((shadow_ramp(5.0 / 6.0) - 1.0).abs() < 1e-6); // x=10 → 1.0
assert!((shadow_ramp(11.0 / 12.0) - 0.5).abs() < 1e-6); // x=11 → 0.5
assert_eq!(shadow_ramp(1.0), 0.0);
// Out-of-range clamps, never negative.
assert_eq!(shadow_ramp(-1.0), 0.0);
assert_eq!(shadow_ramp(2.0), 0.0);
}
/// The box law: clamp INTO ±5 pre-scale (a cap, not a floor), then scale.
#[test]
fn box_clamp_caps_pre_scale() {
let raw = Vec3::new(-7.0, 0.0, 3.0);
let clamped = raw.clamp(Vec3::splat(-BOX_CLAMP), Vec3::splat(BOX_CLAMP));
assert_eq!(clamped, Vec3::new(-5.0, 0.0, 3.0));
// A scale-2 unit's clamped box still doubles — the cap is pre-scale.
assert_eq!(clamped * 2.0, Vec3::new(-10.0, 0.0, 6.0));
}
}