the midnight fence hunt begins, and a spell's ground glow comes back

- Sun-lit fences rendered noon-bright at midnight. The hunt starts here: the
  model sun response switches to the curve read out of the real client's own
  shipped vertex program, the terrain-shadow bit under any doodad became a
  one-command query, and a new golden frame holds a fence lying across the
  shadow boundary — both states of the sun term in a single image (with a
  night twin). The full resolution lands in the next snapshot.
- Arcane Explosion's ground decal drew as a black tile: the additive blend
  was lost when a decal hopped render lanes. A regression, bisected with a
  human grading each build, and fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-29 19:52:55 +07:00
co-authored by Claude Fable 5
parent 011d0b3e23
commit 73646cbeaf
8 changed files with 244 additions and 39 deletions
@@ -381,6 +381,22 @@ enum Command {
/// Case-insensitive substring of the model path (e.g. `instanceportal`).
filter: String,
},
/// Print the terrain MCSH baked-shadow bit at a world position — the per-instance
/// terrain-shade selector an exterior M2 doodad samples once at its base (sun intensity
/// 2.5 lit / 0.5 shadowed, `0x69e4ad`; decisions 0063/0747) — plus an ASCII texel
/// neighborhood. A doodad base near a shadow edge sits one ~0.52 yd texel from the OTHER
/// intensity family, so two adjacent fence pieces can land on opposite sides — the
/// "why is ONE of these blown out" instrument.
Shadeat {
/// Map directory name (e.g. `Azeroth`).
map: String,
/// World-space X (raw WoW yards; may be negative).
#[arg(allow_hyphen_values = true)]
x: f32,
/// World-space Y.
#[arg(allow_hyphen_values = true)]
y: f32,
},
}
/// MPQ internal paths are backslash-separated; accept `/` for convenience.
@@ -561,6 +577,7 @@ fn main() -> Result<()> {
center_y,
tile_radius,
} => scan::doodadscan(&mut chain, &map, center_x, center_y, tile_radius)?,
Command::Shadeat { map, x, y } => scan::shadeat(&mut chain, &map, x, y)?,
Command::Spellvis { spell_id } => spellvis::run(&mut chain, spell_id)?,
}
@@ -2472,3 +2472,45 @@ pub fn fxordercensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
}
Ok(())
}
/// The terrain MCSH shadow bit at a world position + an ASCII texel neighborhood (`#` shadowed,
/// `.` lit, `?` off-tile/no-chunk). One MCSH texel is `TILE_SIZE/1024` ≈ 0.52 yd; the grid spans
/// ±8 texels so a doodad base sitting one texel from a shadow edge — the 2.5-vs-0.5 intensity
/// cliff — is visible at a glance.
pub fn shadeat(chain: &mut Chain, map: &str, x: f32, y: f32) -> Result<()> {
let tiles = benilla_formats::load_tiles_around(chain, map, x, y, 0)
.with_context(|| format!("loading the tile under ({x}, {y}) on {map}"))?;
let Some((_, tile)) = tiles.first() else {
anyhow::bail!("no tile exists under ({x}, {y}) on {map}");
};
let texel = benilla_formats::TILE_SIZE / 1024.0;
let word = |s: Option<bool>| match s {
Some(true) => "SHADOWED (doodad sun intensity 0.5)",
Some(false) => "lit (doodad sun intensity 2.5)",
None => "off-tile / no chunk",
};
println!(
"MCSH at ({x:.2}, {y:.2}): {}",
word(benilla_formats::mcsh_shadowed_at(&tile.chunks, [x, y, 0.0]))
);
println!(
"neighborhood, texel {texel:.3} yd — rows +X (north) up, cols +Y (west) left; center marked:"
);
for dx in (-8i32..=8).rev() {
let mut row = String::new();
for dy in (-8i32..=8).rev() {
let p = [x + dx as f32 * texel, y + dy as f32 * texel, 0.0];
let mut c = match benilla_formats::mcsh_shadowed_at(&tile.chunks, p) {
Some(true) => '#',
Some(false) => '.',
None => '?',
};
if dx == 0 && dy == 0 {
c = if c == '#' { 'S' } else { 'O' };
}
row.push(c);
}
println!("{row}");
}
Ok(())
}
+42 -22
View File
@@ -503,10 +503,10 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp
// `dot(L, that normal)` × a per-vertex colour, MODULATE × texture — so a tuft darkens with the
// ground it stands on (shaded/sloped tufts go darker, like the dirt beneath). We bake that normal
// in clutter.rs (`terrain_normal_at`). World-up was an earlier flat-ground approximation, removed.
// **Exterior M2 doodads use the same FFP `ambient + diffuse·max(N·L,0)`** as clutter/WMO, but with the
// diffuse/sun term scaled by the terrain-shade at the doodad's base (lit vs MCSH-shadowed ground). No
// separate lobe — the same directional matte, just a per-instance sun multiplier. Clutter keeps its own
// FFP N·L path lit by the ground normal (a different reference program).
// **Exterior M2 doodads take the verified `Model2.bls` sun curve** (0747, below) with the
// diffuse/sun term scaled by the terrain-shade at the doodad's base (lit vs MCSH-shadowed ground);
// clutter and WMO keep the plain FFP `ambient + diffuse·max(N·L,0)` — clutter lit by the ground
// normal, WMO by its own (both genuinely fixed-function reference programs).
let is_clutter = m.clutter_fade.w > 0.5;
let L = -normalize(wow_light.light_sun.xyz);
let n_m2 = normalize(pbr_input.world_normal);
@@ -527,13 +527,15 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp
let quad = vec4<f32>(n_lit.x * n_lit.y, n_lit.y * n_lit.z, n_lit.z * n_lit.z, n_lit.x * n_lit.z);
let n1 = vec4<f32>(n_lit, 1.0);
let x2y2 = n_lit.x * n_lit.x - n_lit.y * n_lit.y;
// Exterior world doodad/entity: a hard-cutoff directional matte, scaled by the per-instance
// INTENSITY, the byte-verified `[node+0xa4]`: 2.5 on lit ground / 0.5 on MCSH-shadowed
// ground / 1.0 day-night (`0x69e4ad`, `0x69e280`). Lane history: the `Model2.bls` SH lobe
// (0354/0358, the mainstream VS-path curve) → the FFP matte with a hard cutoff (the SH wrap
// fed warm sun onto the anti-sun side; director's call: a sunlit character's shadow side
// must read the SAME as the skin standing in shade, so both sit exactly at the ambient
// level) → the same hard cutoff on an UNCLAMPED source (0706, below).
// Exterior world doodad/entity: the VERIFIED `Model2.bls` sun response, scaled by the
// per-instance INTENSITY, the byte-verified `[node+0xa4]`: 2.5 on lit ground / 0.5 on
// MCSH-shadowed ground / 1.0 on the interior/WMO-prop leg (`0x69e4ad`, `0x69e280`; the leg
// that force-writes 1.0 is `0x69e36b`). Lane history: the `Model2.bls` SH lobe (0354/0358)
// → the FFP matte with a hard cutoff (the SH wrap fed warm sun onto the anti-sun side;
// director's call: a sunlit character's shadow side must read the SAME as the skin standing
// in shade, so both sit exactly at the ambient level) → the same hard cutoff on an
// UNCLAMPED source (0706) → the verified closed-form curve on the sun side, cutoff kept
// (0747, below).
//
// The per-material `sun_scale.x` selector has THREE states (model_render::ShadeSel): ≥0.85 =
// the lit-ground family (ADT doodads and every entity M2 — intensity 2.5, mixed toward 0.5 by
@@ -546,18 +548,36 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp
let shade_t = max(mat_shade, inst_shade);
let mid_band = m.sun_scale.x >= 0.5 && m.sun_scale.x < 0.85;
let intensity = select(mix(2.5, 0.5, shade_t), 1.0, mid_band);
// Hard sun cutoff on an UNCLAMPED source: `clamp01(ambient + I·D·max(N·L,0))` — ONE
// saturating clamp, at the sum (decision 0706, which keeps the earlier director ruling:
// the anti-sun side sits exactly at ambient, no SH wrap). The old per-channel source
// pre-clamp `clamp01(I·D)` was faithful to NEITHER reference leg — the FFP leg
// peak-NORMALIZES (`(I·D)/max(1,maxch)`, hue-preserving, glue-model-lighting §4) and the
// mainstream Model2.bls SH path consumes the moments unclamped — and it bleached the sun:
// the EK dawn diffuse (254,123,51) at I=2.5 pre-clamped to (1,1,.5), which is bug B33/B97
// (bone-white fences, pale-olive beams, where the reference renders both dark-warm). The
// unclamped sum matches the SH fidelity target's colour response to ≤10% on the sun side
// (equal at the pole and at μ≈0.2) while the hard cutoff stands in for the rejected wrap.
// The VERIFIED sun response (decision 0747): `clamp01(ambient + I·D·max(0, f(μ)))` with
// `f(μ) = (3 + 16μ + 15μ²)/34` — the closed form of the shipped `Model2.bls` vertex program
// (wow-re model2-bls-vertex-sh.md §4: three independent decodes convergent to ≤2.4e-8; the
// moments are consumed UNCLAMPED, so 0706's one saturating clamp at the sum stands). Peak
// f(1) = 1 (= the FFP `N·L` peak, the 16/17 accumulate scale exists for exactly that), and
// vs the previous linear `max(N·L,0)` the curve sits up to ~15% lower through the mid
// angles (f(0.5) = 0.434), which is where a near-saturating sun (any I·D channel > 1 — all
// three at night, R at dawn) stopped clipping ~15% too early. `max(0, ·)` keeps the
// director's anti-sun ruling (the hard-cutoff history above): f crosses zero at μ = −0.243,
// so the deep shadow hemisphere sits exactly at ambient — the reference's back-side wrap
// (f(−1) = 2/34) and its small negative dip are deliberately NOT taken, only the sun-side
// curve is. μ is the RAW dot (not `ndotl`): clamping first would pin the terminator band
// μ ∈ (−0.243, 0) at f(0) = 3/34 instead of letting it fade out.
let mu = dot(n_lit, L);
let sun_curve = max((3.0 + 16.0 * mu + 15.0 * mu * mu) / 34.0, 0.0);
// PEAK-NORMALIZE the committed sun (decision 0750, provisional — the byte verdict on which
// representation the reference's moment accumulate reads is a dispatched wow-re question):
// `C = I·D / max(1, max_ch(I·D))` — hue-preserving, capped at white. The raw over-gamut
// product (night: 2.5·[97,130,162]/255 = (0.95, 1.27, 1.59)) clips every moon-facing doodad
// face to flat white — the director's "day-lit fence at midnight" / "one Silverpine fence
// blown out at every hour" reports; the normalized form keeps the night's blue hue and
// softens the ×5 MCSH lit/shadowed cliff to the smooth blend the reference shows. Gain-1
// lanes (WMO, clutter, mid-band, shaded 0.5) never exceed white, so this touches ONLY the
// lit-intensity family. The client's own light encoder (`0x71ca80`) demonstrably produces
// exactly this peak-normalized form; whether the raw peak is re-applied on the sun's path
// is the open byte question — if the verdict says raw, this reverts on the record.
let sun_c = wow_light.light_diffuse.rgb * intensity;
let sun_peak = max(max(sun_c.r, sun_c.g), max(sun_c.b, 1.0));
let lit_doodad = clamp(
wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb * intensity * ndotl,
wow_light.light_ambient.rgb + (sun_c / sun_peak) * sun_curve,
vec3<f32>(0.0),
vec3<f32>(1.0),
);
+48 -4
View File
@@ -140,12 +140,14 @@ pub(super) const HOUSE_EYE: [f32; 3] = [-9439.1, 71.2, 68.0];
pub(super) const MAP_AZEROTH: u32 = 0;
pub(super) const MAP_KALIMDOR: u32 = 1;
/// THE golden baseline: five spots FRAMED BY THE DIRECTOR in the live client ([`/shot`], the
/// THE golden baseline: six spots FRAMED BY THE DIRECTOR in the live client ([`/shot`], the
/// numbers below verbatim from their `~/.benilla/shots.txt`) — the Northshire overlook toward the
/// Abbey, a Stormwind canal view, Elwynn water, the Lion's Pride Inn common room, and a Felwood
/// hollow — each at noon and at night, except the inn (which the clock barely reaches; see below).
/// Abbey, a Stormwind canal view, Elwynn water, the Lion's Pride Inn common room, a Felwood hollow,
/// and an Elwynn fence lying across the sun's shadow line — each at noon and at night, except the
/// inn (which the clock barely reaches) and the fence, whose DAY cell keeps the director's own
/// 10:24 because its subject is where the shadow line falls; see both below.
///
/// Nine shots of maximal mutual difference. The old set was thirty, and the director judged the
/// Ten landscape shots of maximal mutual difference. The old set was thirty, and the director judged the
/// design bad: low-value repetition (four compass looks at one farmhouse, five frames from one
/// Northshire camera, sixteen UI windows over the same backdrop), each one a real window popping
/// open on their screen while they worked. The law is **a few spots chosen with the director, times
@@ -238,6 +240,31 @@ pub(super) const SCENARIOS: &[Scenario] = &[
minute: 0,
ui: None,
},
// The day cell keeps the director's own minute (10:24) rather than the sweep's noon: the subject
// IS the shadow boundary, and where it falls is a function of the sun's angle, so at noon it
// slides off the rails and the shot degrades into an ordinary fence.
Scenario {
name: "fence-shadowline-day",
map: MAP_AZEROTH,
eye: FENCE_EYE,
look: FENCE_LOOK,
minute: 624,
ui: None,
},
// The night twin, at the director's call — this record's own first draft argued against it on
// the grounds that there is no shadow line without a sun, which was reasoning from the mechanism
// and never measured. The bit it photographs is BAKED, present at every hour; what the night
// frame pins is the other half of the mechanism — how much of the shade term survives once the
// sun's contribution is gone and the ambient/moon palette carries the frame. That is a question
// about the light curves, and a baseline is the right place to hold the answer (0749 addendum).
Scenario {
name: "fence-shadowline-night",
map: MAP_AZEROTH,
eye: FENCE_EYE,
look: FENCE_LOOK,
minute: 0,
ui: None,
},
Scenario {
name: "creature-sun-front",
map: MAP_AZEROTH,
@@ -380,6 +407,23 @@ pub(super) const WATER_LOOK: [f32; 3] = [-9499.4, -351.3, 61.4];
pub(super) const INN_EYE: [f32; 3] = [-9471.4, 39.4, 59.9];
pub(super) const INN_LOOK: [f32; 3] = [-9458.8, -7.5, 48.2];
/// Director shot 6 (decision 0749) — an Elwynn rail fence lying ACROSS the sun's shadow boundary,
/// at 10:24: the right-hand span is in full sun, the left-hand span in shade, and the same edge
/// runs on across the road behind it. One frame holding **both** states of the MCSH sun term, on
/// terrain and on a doodad at once, with the ramp visible as the hard line between them.
///
/// The lighting matrix (0746) samples the lit and shadowed lanes as SEPARATE cells at separate
/// positions, so a regression that scaled both equally could pass both. Here the two states share
/// one frame, one model and one texture, so only their DIFFERENCE can carry the shot — the control
/// is inside the picture. It also lights the third lane neither matrix subject touches: a
/// world-placed **doodad**, which takes its shade per-vertex at bake rather than through
/// `entity_shade`'s per-object ramp.
///
/// Found by the director, who framed it because they had already spotted it was "both lit and unlit
/// from sun, half half".
pub(super) const FENCE_EYE: [f32; 3] = [-9511.9, -4.0, 61.9];
pub(super) const FENCE_LOOK: [f32; 3] = [-9552.0, 18.6, 42.4];
/// Director shot 5 (decision 0743) — a Felwood hollow on **Kalimdor** (`MAP_KALIMDOR`): the
/// corrupted forest floor's root mat, a stand of emissive `felwoodmushroom` doodads, a pool of
/// green sludge (a liquid type no other golden shot contains), the vast trunks behind, and the
+10
View File
@@ -57,6 +57,14 @@ pub(super) struct EntityPart {
/// (creatures + M2 GameObjects, the CGObjects that appear-fade); `None` for WMO-display parts.
pub(super) fade_blend: Option<Handle<WowModelMaterial>>,
pub(super) blend: ModelBlend,
/// Whether this part blends **additively** (M2 blend mode 3 `NoAlphaAdd` / 4 `Add`).
/// [`ModelBlend`] deliberately folds "alpha-blended / additive" into its single `Blend`
/// variant, so [`Self::blend`] alone CANNOT answer this — the material path recovers it from
/// [`crate::model_render`]'s separate `is_additive` input (marker bit 2 of `clutter_fade.z`,
/// which `specialize` turns into the pure-add state). Any consumer that re-derives a blend
/// state from `blend` needs this alongside it, or every additive batch silently draws
/// alpha-blended — decision 0748, the black ground-decal tile.
pub(super) additive: bool,
/// Whether this part renders two-sided (M2 material `0x04`). Retained so a per-appearance material
/// swap (the character hair, decision 0045 — hair cards are two-sided) preserves it; `part.material`
/// already bakes it for the un-swapped parts.
@@ -544,6 +552,7 @@ pub(super) fn build_parts(
material_interior_bake_blend: interior_bake_blend,
fade_blend,
blend: sub.blend,
additive: sub.additive,
two_sided: sub.two_sided,
geoset_id: sub.geoset_id,
char_slot: sub.char_slot,
@@ -598,6 +607,7 @@ pub(super) fn build_parts(
material_interior_bake_blend: None,
fade_blend: None, // WMO-display GameObjects don't appear-fade (rare; M2 only)
blend: sub.blend,
additive: false, // WMO MOMT carries no additive mode (`RenderSubmesh::additive`)
two_sided: sub.two_sided,
billboard: None, // WMO groups have no billboard bones
alpha_anim: None, // …nor colour/weight loops
+1 -1
View File
@@ -481,7 +481,7 @@ pub(super) fn attach_effect_visuals(
let decal = crate::ground_fx::spawn_ground_fx_decal(
commands,
texture,
crate::particles::buffer::EffectBlend::from_model(part.blend),
crate::particles::buffer::EffectBlend::from_model(part.blend, part.additive),
fog,
part.rgb_anim.as_ref().map(|a| (a.clone(), now)),
&quad,
+11 -7
View File
@@ -456,13 +456,17 @@ mod tests {
assert!((rgb[2] - 0.4).abs() < 1e-4, "blue commits raw: {rgb:?}");
}
/// GOLDEN — the exterior M2 lane's contract, end to end through the packed rows: mirroring
/// `wow_model.wgsl`'s eval (DC lane + grade.yzw × I + I × (linear + quad + x²−y²)) over the
/// rows this packer writes must reproduce the disassembled `Model2.bls` closed form
/// `clamp01(ambient + D·I·(3 + 16μ + 15μ²)/34)` at every intensity rung (2.5 lit / 1.0
/// mid-band / 0.5 MCSH-shadowed). Pins the "every sun band scales by I, never I²" law and the
/// row homes (ambient in the DC lanes, the sun's DC redistribution on grade.yzw) — the two
/// ways this lane has silently drifted.
/// GOLDEN — the PACKER's SH block: evaluating the rows this packer writes (DC lane +
/// grade.yzw × I + I × (linear + quad + x²−y²)) must reproduce the disassembled `Model2.bls`
/// closed form `clamp01(ambient + D·I·(3 + 16μ + 15μ²)/34)` at every intensity rung (2.5 lit /
/// 1.0 mid-band / 0.5 MCSH-shadowed). Pins the "every sun band scales by I, never I²" law and
/// the row homes (ambient in the DC lanes, the sun's DC redistribution on grade.yzw).
///
/// NB (0747): no shader currently READS rows 6-12.xyz / 17.yzw — the live exterior lane in
/// `wow_model.wgsl` implements the same closed form inline (sun side only, `max(0, f(μ))`;
/// the full block's back-side wrap stays out per the anti-sun ruling). This golden pins the
/// packed block itself; the flagged cleanup is either wiring a lane to the rows or retiring
/// the dead fold together with this test.
#[test]
fn exterior_lane_reproduces_the_closed_form_at_every_intensity_rung() {
let ambient = [0.30, 0.32, 0.38];
+73 -5
View File
@@ -72,11 +72,25 @@ impl From<ParticleBlend> for EffectBlend {
}
impl EffectBlend {
/// The ground-fx mapping from a part's authored [`ModelBlend`] — `model_render.rs`'s law
/// with the lane's two named approximations: `AlphaTest` folds to `Alpha` (no Mask variant
/// here; the groundscan census says flat `Spells\` quads are blend batches), and the part
/// renders unlit (spell fx are; a lit ground quad would differ — none observed).
pub fn from_model(blend: ModelBlend) -> Self {
/// The ground-fx mapping from a part's authored blend — `model_render.rs`'s law with the
/// lane's two named approximations: `AlphaTest` folds to `Alpha` (no Mask variant here; the
/// groundscan census says flat `Spells\` quads are blend batches), and the part renders unlit
/// (spell fx are; a lit ground quad would differ — none observed).
///
/// `additive` is a SECOND, non-optional input because [`ModelBlend`] cannot express additive:
/// M2 blend modes 3/4 fold into its `Blend` variant (see its own doc, "Alpha-blended /
/// additive"), and the material path recovers them from `model_render`'s separate
/// `is_additive` flag. Taking only the enum made this function *unable* to be right for an
/// additive batch — every `Spells\` ground quad is mode 4, so Arcane Explosion / Blast Wave /
/// Battle Shout drew their black-backed additive art alpha-blended: an opaque black tile
/// (decision 0748). Keeping it in the signature is what stops the next caller repeating it.
pub fn from_model(blend: ModelBlend, additive: bool) -> Self {
if additive {
// `BLEND_ADD` is byte-for-byte the material path's additive: the shader gamma-
// premultiplies (`rgb·α`) and returns α = 0, turning `(One, 1−srcα)` into pure
// addition — the same fold `specialize` gates on marker bit 2 (0160/0161).
return EffectBlend::Add;
}
match blend {
ModelBlend::Opaque => EffectBlend::Opaque,
ModelBlend::AlphaTest | ModelBlend::Blend => EffectBlend::Alpha,
@@ -271,3 +285,57 @@ pub fn begin_effect_frame(mut quads: ResMut<EffectQuads>) {
quads.verts.clear();
quads.draws.clear();
}
#[cfg(test)]
mod tests {
use super::*;
/// The additive flag OVERRIDES the folded enum (decision 0748). `ModelBlend::Blend` means
/// "alpha-blended **or** additive" — M2 modes 2/3/4 all land there — so a mapping that reads
/// only the enum cannot be right. Every `Spells\` flat ground quad in the 1.12.1 corpus is
/// mode 4 (`m2batch`: ArcaneExplosion_Base, BattleShout_Cast_Base, …), and drawing those
/// alpha-blended painted their black-backed additive art as an opaque black tile.
#[test]
fn additive_wins_over_the_folded_blend_enum() {
for blend in [
ModelBlend::Opaque,
ModelBlend::AlphaTest,
ModelBlend::Blend,
ModelBlend::Mod,
ModelBlend::Mod2x,
] {
assert_eq!(
EffectBlend::from_model(blend, true),
EffectBlend::Add,
"{blend:?} + additive must reach the pure-add state, not {:?}",
EffectBlend::from_model(blend, false),
);
}
}
/// The non-additive law is unchanged — `model_render.rs`'s mapping, with `AlphaTest` folded
/// to `Alpha` (the lane has no Mask variant; flat `Spells\` quads are blend batches).
#[test]
fn non_additive_keeps_the_material_paths_law() {
assert_eq!(
EffectBlend::from_model(ModelBlend::Opaque, false),
EffectBlend::Opaque
);
assert_eq!(
EffectBlend::from_model(ModelBlend::AlphaTest, false),
EffectBlend::Alpha
);
assert_eq!(
EffectBlend::from_model(ModelBlend::Blend, false),
EffectBlend::Alpha
);
assert_eq!(
EffectBlend::from_model(ModelBlend::Mod, false),
EffectBlend::Multiply
);
assert_eq!(
EffectBlend::from_model(ModelBlend::Mod2x, false),
EffectBlend::Mod2x
);
}
}