From 73646cbeaff4132bdc58ad7d21396b47ff62778b Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 29 Jul 2026 19:52:55 +0700 Subject: [PATCH] the midnight fence hunt begins, and a spell's ground glow comes back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/bin/benilla-extract/main.rs | 17 ++++ .../src/bin/benilla-extract/scan.rs | 42 ++++++++++ crates/benilla/assets/shaders/wow_model.wgsl | 64 +++++++++------ crates/benilla/src/capture/scenarios.rs | 52 ++++++++++++- crates/benilla/src/entities/display.rs | 10 +++ crates/benilla/src/entities/spell_fx.rs | 2 +- crates/benilla/src/lighting/global_light.rs | 18 +++-- crates/benilla/src/particles/buffer.rs | 78 +++++++++++++++++-- 8 files changed, 244 insertions(+), 39 deletions(-) diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index 6885fd8a..f7c3561a 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -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)?, } diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan.rs b/crates/benilla-formats/src/bin/benilla-extract/scan.rs index 9abc9278..19eaf5ad 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan.rs @@ -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| 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(()) +} diff --git a/crates/benilla/assets/shaders/wow_model.wgsl b/crates/benilla/assets/shaders/wow_model.wgsl index b1429b3a..146458a9 100644 --- a/crates/benilla/assets/shaders/wow_model.wgsl +++ b/crates/benilla/assets/shaders/wow_model.wgsl @@ -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(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(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(0.0), vec3(1.0), ); diff --git a/crates/benilla/src/capture/scenarios.rs b/crates/benilla/src/capture/scenarios.rs index 4ca268cf..dab0aca8 100644 --- a/crates/benilla/src/capture/scenarios.rs +++ b/crates/benilla/src/capture/scenarios.rs @@ -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 diff --git a/crates/benilla/src/entities/display.rs b/crates/benilla/src/entities/display.rs index 8f436e85..1b9b92bc 100644 --- a/crates/benilla/src/entities/display.rs +++ b/crates/benilla/src/entities/display.rs @@ -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>, 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 diff --git a/crates/benilla/src/entities/spell_fx.rs b/crates/benilla/src/entities/spell_fx.rs index 3827d186..a266330a 100644 --- a/crates/benilla/src/entities/spell_fx.rs +++ b/crates/benilla/src/entities/spell_fx.rs @@ -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, diff --git a/crates/benilla/src/lighting/global_light.rs b/crates/benilla/src/lighting/global_light.rs index e7aad13e..ca79b72d 100644 --- a/crates/benilla/src/lighting/global_light.rs +++ b/crates/benilla/src/lighting/global_light.rs @@ -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]; diff --git a/crates/benilla/src/particles/buffer.rs b/crates/benilla/src/particles/buffer.rs index a7bf6d12..491c8674 100644 --- a/crates/benilla/src/particles/buffer.rs +++ b/crates/benilla/src/particles/buffer.rs @@ -72,11 +72,25 @@ impl From 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) { 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 + ); + } +}