//! Shared world-model material build — the `WowModelMaterial` (a `StandardMaterial` base carrying the //! texture/alpha/cull + the `WowModelExt` shared-light extension that does the WoW shading) used for //! every M2/WMO instance: doodads, WMO buildings, creatures, and GameObjects. Deduped per owner by //! `(texture, blend, sidedness, kind, fade-variant)` so instances sharing a look batch into one draw. use benilla_formats::{ModelBlend, WmoBatchClass}; use bevy::asset::AssetId; use bevy::pbr::ExtendedMaterial; use bevy::prelude::*; use bevy::render::render_resource::{Buffer, Face}; use crate::terrain::{WowModelExt, WowModelMaterial}; /// Alpha-test reference for `Blend_AlphaKey` (M2 blend mode 1) materials — the value below which a /// fragment is discarded (`D3DCMP_GREATEREQUAL`). Per wowdev.wiki M2/Rendering this is **version /// dependent**: `224/255 ≈ 0.878` on **≤ WotLK** (our target is 1.12.1 build 5875), vs `128/255 ≈ /// 0.5` on Cata+. We initially hardcoded the Cata value (0.5), which left foliage too dense and a /// white fringe on cutout edges. The spec multiplies this by the element's animated alpha (1.0 for /// static doodads), so the bare constant is correct until we add doodad alpha fades. /// Source: § Alpha Testing. pub const VANILLA_ALPHA_KEY_REF: f32 = 224.0 / 255.0; /// Yards of transparent-pass sort bias per authored batch-order step (`MatKey::batch_order` × /// this, on `StandardMaterial::depth_bias` — bevy adds that field to the phase item's sort /// distance; positive = drawn later, the `sky_order` sign law). **Why it exists:** one model's /// coplanar transparent batches (the Naxx items' Opaque + Mod2x sheen + Blend overlay triples) /// share one mesh centre, so their sort distances TIE, and a tie is re-resolved every frame by /// whatever order the queue happens to iterate — in a churning scene (a city) the coplanar layers /// swap draw order frame to frame and the composite strobes (Mod2x-then-Blend ≠ Blend-then-Mod2x). /// The reference cannot express the bug: it ties every draw command of one instance to a single /// sort key and keeps the *authored command order* inside the tie (wow-re /// `m2-blend-promotion-zfill.md` §4/§6; MOBA file order for WMO). This epsilon reproduces that /// order through bevy's sort. Sized well under [`benilla_formats::owner_last_rung`]'s 1-yd floor /// (an effect still draws after every batch of its owner) and far over f32 noise on a ~500 yd /// sort distance (~4e-5). Not a pipeline axis: `depth_bias` participates in sorting only /// (`WowModelKey` deliberately excludes it — decision 0837). pub(crate) const BATCH_ORDER_SORT_EPS: f32 = 1e-3; /// Material-dedup key: same texture + blend + sidedness + kind + fade-variant → one shared material. #[derive(PartialEq, Eq, Hash)] pub(crate) struct MatKey { texture: Option>, blend: ModelBlend, two_sided: bool, is_wmo: bool, is_interior: bool, is_emissive: bool, is_additive: bool, fade_variant: bool, /// M2 render flag 0x10 / 0x08 — disable depth write / depth test (`specialize` honours them). no_depth_write: bool, no_depth_test: bool, /// The batch's fog COLOUR policy ([`benilla_formats::FogPolicy`]) — packed into `clutter_fade.z` /// bits 4-6 (`wow_model.wgsl`'s step-5 fog). fog_policy: benilla_formats::FogPolicy, /// The batch's static terrain-shade selector ([`ShadeSel`]). Static per placement, so it dedups a /// lit / matte / shaded material variant. shade: ShadeSel, /// Authored batch index + 1 (0 = a legacy/unordered caller). Every ordered batch (M2 and WMO) /// folds it into the material's transparent SORT bias ([`BATCH_ORDER_SORT_EPS`]); WMO batches /// additionally ride it into the vertex-stage clip-z nudge (the byte-verified MOBA draw-order /// determinism, wow-5875-re models/scratch/wmo-batch-blend-depth-state.md). batch_order: u16, /// The batch's UV-animation identity (decision 0130 phase 3): the `Arc` pointer, so /// batches scrolling on different loops never share a material (their `sun_scale.zw` offsets /// diverge every frame) while every instance of the same model batch does. Sound because the /// `Arc` lives in the loaded model asset — one allocation per model per batch, stable while /// loaded. `None` = static UVs (the overwhelming majority). uv_anim: Option, /// The batch's animated-RGB-tint identity: the `Arc` pointer (same soundness argument /// as [`Self::uv_anim`]), so batches tinting on different tracks never share a material while /// every instance of the same model batch does. `None` = a static tint (the vertex bake). rgb_anim: Option, /// The WMO batch's MOBA section (`None` for M2): an interior group's INT and TRANS batches take /// different lighting lanes (`tint.w`), so they must never dedupe onto one material. wmo_class: Option, /// The WMO MOMT SIDN night-glow colour (RGB gamma bytes; `None` = no SIDN / M2) — part of the /// material identity so glass authored with different glow colours never dedupes together. sidn: Option<[u8; 3]>, /// The WMO MOMT WINDOW flag — an interior-group batch on the brighter midpoint light. window: bool, /// This material is a **depth-prime twin** ([`zfill_material`] — the reference's `M2UseZFill` /// clone command, wow-re `m2-blend-promotion-zfill.md` §4): colour writes masked off, blend off, /// z-write on, drawn before its model's colour batches. Its own key axis so a twin can never /// dedupe onto a colour material. zfill: bool, } /// The per-material static terrain-shade **selector** baked into `sun_scale.x` — NOT an intensity /// itself. `wow_model.wgsl` thresholds the selector (≥0.85 / ≥0.5) into the byte-true INTENSITY /// family (decision 0354: 2.5 lit / 1.0 day-night / 0.5 MCSH-shadowed — `[node+0xa4]`) scaling the /// global SH eval. It is the static half of the verified per-category matrix (wow-re /// `m2-interior-doodad-base-light` §6/§8/§9, decision 0173) — dynamic entities /// (units/players/GameObjects) are always [`ShadeSel::Lit`] here and mix toward the shaded /// intensity per instance via the `MeshTag` shade byte ([`crate::entity_shade`]). #[derive(Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum ShadeSel { /// Lit ground, the boosted intensity family (the binary's 2.5): ADT map doodads on unshadowed /// ground, and every entity M2. /// /// Entities still select this, but what it *means* for them changed in 0809. The selector is /// only the static half — the per-instance `MeshTag` shade byte carries the rest, and /// [`crate::entity_shade`] now pins that byte to the day/night ×1.0 for units and players /// (`0x672a20`'s null-node fallback commits with no per-node intensity multiply at all), while /// GameObjects keep the real 2.5/0.5 chase. So for an entity this variant reads "on the light-node /// path"; the node decides the amplitude. Lit, /// Lit ground, intensity 1.0: an exterior WMO MODD prop — §8b, byte-verified never to reach /// the 2.5 site (a Stormwind street fountain is NOT brightened like an Elwynn tree). Matte, /// The base sits on MCSH-shadowed terrain: the dim intensity (the binary's 0.5). Shaded, /// Lit by an **authored M2 light rig** (the glue create booth, decision 0429): the lit value is /// the order-2 SH probe in slot 0 of the material's *own* light buffer — the scene's ambient + /// directional lights folded by `lighting::prop_probe_coeffs`, the same `Model2.bls` curve the /// reference's vertex program runs — plus the per-vertex ≤3-nearest point term (the scene's /// authored point lights ride the buffer's point table; a rig material is neither WMO nor /// interior, so the vertex stage computes it). The world's sun/intensity machinery never /// applies: a glue scene has no day/night, no MCSH, no storm band. Rig, } impl ShadeSel { /// The `sun_scale.x` encoding `wow_model.wgsl` thresholds (≥1.5 ⇒ authored-rig probe; /// ≥0.85 ⇒ ADT lit; ≥0.5 ⇒ matte; else shaded). pub(crate) fn selector(self) -> f32 { match self { ShadeSel::Lit => 1.0, ShadeSel::Matte => 0.6, ShadeSel::Shaded => 0.2, ShadeSel::Rig => 2.0, } } } /// A material-dedup cache. Each model-spawning subsystem (terrain doodads/WMOs, streamed entities) /// keeps its own so its handles drop with it — and, since decision 0793, so its entries expire by /// **distance** ([`crate::art_scope::SpatialCache`]) instead of living until the next map change. A /// cache nobody sweeps (the `Local`s in the glue booth and the portrait bake) behaves /// exactly as it did: a plain dedup map. pub(crate) type MaterialCache = crate::art_scope::SpatialCache>; /// Build (or fetch the deduped) [`WowModelMaterial`] for a model batch: a `StandardMaterial` base /// carrying the texture/alpha/cull, plus the `WowModelExt` shared-light extension. `fade_variant` /// marks the `AlphaMode::Blend` twin every feather pass rides — the doodad distance fade AND the /// entity appear/despawn/aura fades (`entities::display` builds its twins with it too); steady /// materials pass `false`. `specialize` keys depth-write-ON on this bit for the twin. /// /// A twin builder passes the **SOURCE batch's blend mode** (never `ModelBlend::Blend`): the twin /// always blends — the reference's promotion is transparent-pass membership, not a per-mode state /// (`m2-blend-promotion-zfill.md` §1) — but the alpha TEST under promotion keys on the *stored* /// blend mode (§2): AlphaKey keeps its 224/255 cutout while blending, Opaque runs with no alpha /// test at all. The source blend is what sets [`TWIN_CUTOUT_MARKER`] right (decision 0842: a twin /// built as `Blend` cut every texel under 224/255 out of a stealthed Opaque batch, which erased /// Gressil's blade body and left only its high-alpha rune pattern). #[allow(clippy::too_many_arguments)] pub(crate) fn model_material( cache: &mut MaterialCache, materials: &mut Assets, texture: Option>, blend: ModelBlend, two_sided: bool, is_wmo: bool, is_interior: bool, is_emissive: bool, is_additive: bool, fade_variant: bool, no_depth_write: bool, no_depth_test: bool, fog_policy: benilla_formats::FogPolicy, shade: ShadeSel, // Authored batch index + 1 (0 = unordered): the transparent-pass ORDER of this batch among its // model's other batches — see [`MatKey::batch_order`] and [`BATCH_ORDER_SORT_EPS`]. batch_order: u16, uv_anim: Option<&std::sync::Arc>, rgb_anim: Option<&std::sync::Arc>, // The WMO batch's MOBA section (`None` for M2 batches) — with `is_interior`, it picks the // batch's lighting lane (`tint.w`; see `WowModelExt::tint`). wmo_class: Option, // The WMO window/glass law (`None`/`false` for M2): the MOMT SIDN night-glow colour and the // WINDOW midpoint-light flag (`WowModelExt::sidn`). sidn: Option<[u8; 3]>, window: bool, light: &Buffer, ) -> Handle { let key = MatKey { texture: texture.as_ref().map(Handle::id), blend, two_sided, is_wmo, is_interior, is_emissive, is_additive, fade_variant, no_depth_write, no_depth_test, fog_policy, shade, batch_order, uv_anim: uv_anim.map(|a| std::sync::Arc::as_ptr(a) as usize), rgb_anim: rgb_anim.map(|a| std::sync::Arc::as_ptr(a) as usize), wmo_class, sidn, window, zfill: false, }; if let Some(h) = cache.fetch(&key) { return h; } // Additive batches (M2 glow cards) ADD their colour to the framebuffer — so the warm glow isn't // muted by the (cool, at night) background bleeding through. They go in the transparent pass // (`AlphaMode::Blend`); the shader folds the radial alpha into the colour IN GAMMA SPACE // (decision 0160) and `specialize` overrides the blend STATE to a pure (ONE, ONE) add. The // additive marker is `clutter_fade.z` bit 2 — the shader gates the gamma-premultiply on the // SAME bit specialize keys on (a stale "model_flags.w == 2.0" claim here once desynced the // two: blend went pure-add while the premultiply never fired — the flat-square regression). // `WOW_NO_ALPHATEST=1` draws every cutout batch opaque — the A/B for "is the alpha test // itself discarding this surface?", which is otherwise indistinguishable in the pixels // from the surface losing a depth test or never being submitted. (B38: the flip // survives it unchanged, so the cutout is not what removes the awning.) It suppresses the // fade twin's cutout marker too, so the A/B answers the same question mid-fade. let source_cutout = blend == ModelBlend::AlphaTest && std::env::var("WOW_NO_ALPHATEST").is_err(); let alpha_mode = if is_additive || fade_variant { // A fade twin blends whatever its source blend is — the reference's promotion is // transparent-pass membership (`m2-blend-promotion-zfill.md` §1); the source blend the // caller passed decides only the cutout marker below. AlphaMode::Blend } else { match blend { ModelBlend::Opaque => AlphaMode::Opaque, ModelBlend::AlphaTest if source_cutout => AlphaMode::Mask(VANILLA_ALPHA_KEY_REF), ModelBlend::AlphaTest => AlphaMode::Opaque, // Mod/Mod2x ride the transparent pass (they multiply what's already drawn, so the // scene under them must exist); `specialize` swaps the actual blend state to the // byte-verified multiply factors via the marker bits below (decision 0528). ModelBlend::Blend | ModelBlend::Mod | ModelBlend::Mod2x => AlphaMode::Blend, } }; // Single-sided unless the M2's 0x04 flag is set (many canopy planes are one-directional). let cull_mode = if two_sided { None } else { Some(Face::Back) }; // The authored batch order as a transparent sort bias ([`BATCH_ORDER_SORT_EPS`]): one model's // coplanar batches draw in file order instead of re-flipping a sort tie every frame. let depth_bias = f32::from(batch_order) * BATCH_ORDER_SORT_EPS; let base = match texture { Some(image) => StandardMaterial { base_color_texture: Some(image), alpha_mode, double_sided: two_sided, cull_mode, depth_bias, ..default() }, // No resolved texture → the reference DISABLES the texture stage and draws the batch in // its flat vertex/material colour — i.e. WHITE modulate, byte-verified for the no-source // runtime-texture case (wow-re m2-runtime-texture-null-bind.md, their 914a1abd: slot 0 → // glDisable, no default texture, alpha test passes everywhere — the Westfall lamppost's // "bulb on" pane). The old muted-brown debug tint was unfaithful. Blend/cull state still // honoured — an untextured fade twin must keep its per-instance alpha path. None => StandardMaterial { base_color: Color::WHITE, alpha_mode, double_sided: two_sided, cull_mode, depth_bias, ..default() }, }; let handle = materials.add(ExtendedMaterial { base, extension: WowModelExt { // `clutter_fade.z` is unread by the shader, so it carries the per-batch pipeline markers // `specialize` keys on: bit0 = no-depth-write (M2 0x10), bit1 = no-depth-test (0x08), // bit2 = additive blend. (`x`/`y`/`w` stay the ground-clutter distance fade — `0` here.) clutter_fade: Vec4::new( 0.0, 0.0, // Bit 3 = OPAQUE-INTENT: an opaque/alpha-key steady batch whose output alpha is // semantically meaningless (opaque/mask pipelines ignore it — only blend pipelines // read it). The shader pins such batches' output alpha to 1.0: a spec-level no-op // that armors against a multi-view pipeline-state mixup observed on macOS/Metal // (opaque WMO/M2 draws intermittently bound with blending enabled when an extra // camera exists, bleeding the garbage BLP alpha channel — the "pale film on // buildings" regression; full measured chain in the fix commit). // Bits 4-6 = the per-batch FOG POLICY (`FogPolicy` discriminant, wow-re // rf-weather-emission-timeline ROUND 4): 0 = scene so clutter/water materials — // which leave the byte's high bits 0 (see `WorldAssets::model_material`, // `water_fx`) — keep ordinary scene fog; 1/2/3 = the additive/Mod/Mod2x BLACK/ // WHITE/GREY fog colours; 4 = fog disabled outright (render flag 0x02). // Bits 7/8 = the MULTIPLY blends (decision 0528): `specialize` swaps the pipeline // to the byte-verified factors — Mod DST_COLOR/ZERO, Mod2x DST_COLOR/SRC_COLOR // (exact on the 0161 gamma lane: the framebuffer holds gamma, like the reference). // Bit 10 = TWIN CUTOUT (decision 0842): this fade twin's SOURCE batch alpha-tests, // so the shader re-applies the hard 224/255 cutout on the unfaded alpha (the // reference's promoted-AlphaKey ALPHAREF = A×224 — the same fixed silhouette). An // Opaque source sets no bit: the reference disables its alpha test outright, steady // and promoted alike (`m2-blend-promotion-zfill.md` §2 keys ALPHAREF on the STORED // blend mode, mode 0 → ref 0). f32::from( u16::from(no_depth_write) | (u16::from(no_depth_test) << 1) | (u16::from(is_additive) << 2) | (u16::from( matches!(blend, ModelBlend::Opaque | ModelBlend::AlphaTest) && !fade_variant && !is_additive, ) << 3) | (u16::from(fog_policy as u8) << 4) | (u16::from(blend == ModelBlend::Mod) << 7) | (u16::from(blend == ModelBlend::Mod2x) << 8) | (u16::from(fade_variant && source_cutout) * TWIN_CUTOUT_MARKER), ), 0.0, ), // x = WMO (FFP N·L × MOCV, not the M2 SH probe); y = distance-fade blend variant; z = WMO // interior group (sun off, baked MOCV carries the room); w = **unlit fullbright** (>0.5 ⇒ // bypass lighting in wow_model.wgsl): the M2 UNLIT (0x01) flag, or WMO UNLIT on an // exterior-group batch (the interior drawer ignores it — section law, `wmo-lit-selector`). // Additive is NOT fullbright: the real client *lights* additive batches unless 0x01 is set // (wow-re `m2-no-envmap-texgen`), so an un-flagged additive (e.g. ArmorReflect shine) is lit. // M2 Mod/Mod2x ARE fullbright regardless of 0x01 — the lighting table // `DAT_00811fa8 = {1,1,1,1,1,0,0}` clears GL_LIGHTING for modes 5/6 (wow-re // `m2-depth-blend-state`); WMO lighting stays flag-driven only (decision 0528). model_flags: Vec4::new( if is_wmo { 1.0 } else { 0.0 }, if fade_variant { 1.0 } else { 0.0 }, if is_interior { 1.0 } else { 0.0 }, if is_emissive || (!is_wmo && matches!(blend, ModelBlend::Mod | ModelBlend::Mod2x)) { 1.0 } else { 0.0 }, ), // x = the static MCSH terrain-shade SELECTOR ([`ShadeSel`]: 1.0 ADT-lit / 0.6 matte / // 0.2 shaded; shader thresholds at 0.85 and 0.5) that chooses which live sun LEVEL scales the // FFP matte's diffuse term. Static per material (a doodad doesn't move), so it dedups the // variants; moving entities are Lit here and mix per instance via the `MeshTag` shade byte. // y = the WMO authored batch order, read by `wow_model.wgsl`'s VERTEX stage: it scales // clip z by (1 + y·2⁻²³) so a later coplanar batch wins the reverse-Z depth test in any // draw order — the byte-verified MOBA draw-order determinism. Uniform data by design: // as a `WowModelKey` axis driving a fixed-function depth bias it made every batch index // its own PIPELINE, and a first city sight compiled ~3000 of them synchronously on the // render thread (decision 0837). `WOW_WMO_BIAS=0` (B38's A/B diagnostic) zeroes it here. // zw = the batch's live **UV-animation offset** (decision 0130 phase 3, wow-re // `m2-texanim-uv`: the real client adds the sampled translation to the stage UVs — // translation is un-pivoted, and no placed doodad uses rotation/scaling). Seeded at // t = 0 here; `doodad_anim::tick_anim_materials` re-samples it per drawn frame on the // shared clock (frozen in captures). sun_scale: { let uv0 = uv_anim.map_or([0.0, 0.0], |a| a.sample(0.0)); // The clip-z nudge stays WMO-only: M2 coplanar layers pass GreaterEqual at exactly // equal depth (same mesh, same transform, same vertex path), and their ORDER is // the sort bias above — nudging their depth would be an unverified extra. let order = if !is_wmo || matches!(std::env::var("WOW_WMO_BIAS").as_deref(), Ok("0")) { 0.0 } else { f32::from(batch_order) }; Vec4::new(shade.selector(), order, uv0[0], uv0[1]) }, // The animated M2Color tint's first key (identity white for static batches — their // constant tint rides the vertex colours instead). A lane that never re-samples this // shows exactly the old static bake; the effect lane clones + ticks it per instance. // `w` = the WMO interior batch-class lane: an interior group's INT batches draw UNLIT // (pure tex × MOCV) and its TRANS batches lerp lit↔bake by the MOCV alpha (wow-re // `trace-forensics-abbey-interior-d3d` §2 — observed on the abbey at close range, the // northshire "lit interior batch" datum having been a mis-identified unit). Exterior // groups' batches (and every M2) ride 0 = the exterior law. tint: { let t0 = rgb_anim.map_or([1.0, 1.0, 1.0], |a| a.sample(0.0)); let class_lane = match (is_interior && is_wmo, wmo_class) { (true, Some(WmoBatchClass::Int)) => 1.0, (true, Some(WmoBatchClass::Trans)) => 2.0, _ => 0.0, }; Vec4::new(t0[0], t0[1], t0[2], class_lane) }, // The WMO window/glass law (`WowModelExt::sidn`): xyz = the authored SIDN emissive // (gamma bytes /255 — the shader ramps it by the live night fraction on lit lanes), // w = the WINDOW midpoint-light flag. sidn: { let c = sidn.unwrap_or([0, 0, 0]); Vec4::new( f32::from(c[0]) / 255.0, f32::from(c[1]) / 255.0, f32::from(c[2]) / 255.0, if window { 1.0 } else { 0.0 }, ) }, light_buf: light.clone(), }, }); cache.insert(key, handle.clone()); handle } /// `clutter_fade.z` marker bit 9: this material is a depth-prime twin ([`zfill_material`]). pub(crate) const ZFILL_MARKER: u16 = 1 << 9; /// `clutter_fade.z` marker bit 10: this twin's SOURCE batch alpha-tests, so the shader re-applies /// the hard 224/255 cutout while blending (`wow_model.wgsl`). Set from the source blend mode by /// [`model_material`] (fade twins) and [`zfill_material`] (depth-prime twins); never on an /// Opaque-source twin — the reference runs mode 0 with the alpha test disabled, steady and /// promoted alike (`m2-blend-promotion-zfill.md` §2; decision 0842). pub(crate) const TWIN_CUTOUT_MARKER: u16 = 1 << 10; /// The twin's `Transparent3d` sort bias (yards; **negative = drawn earlier** — the sign law is /// `sky_order`'s module doc). It must clear the spread of one model's part AABB centres, so **all** /// of a fading model's twins draw before **any** of its colour parts — the reference achieves the /// same by tying every command of one instance to a single sort key (`cmd+0x14 = model+0x84`) and /// putting twins first inside the tie (`m2-blend-promotion-zfill.md` §4/§6). 8 yd covers every /// humanoid and mount; the residue (a model taller than 8 yd may keep a little self-overlap /// darkening at its extremities, and transparent scene content sorting within 8 yd behind a fading /// body draws after the prime and is depth-clipped where the body covers it) is recorded in /// decision 0831. The same field doubles as the rasterizer depth bias in relative ULPs — at −8 that /// is ~2⁻²⁰ relative, and its direction (twin marginally farther) only makes the colour pass's /// GreaterEqual test safer against cross-pipeline ULP noise. pub(crate) const ZFILL_SORT_BIAS: f32 = -8.0; /// Build (or fetch) the **depth-prime twin** material for one fadeable entity batch — the /// reference's `M2UseZFill` clone (wow-re `m2-blend-promotion-zfill.md` §4, VERIFIED): while a /// model draws translucent (`0 < A < 1`), a colour-masked, blend-off, z-writing copy of each of its /// depth-writing batches draws first, so every colour fragment behind the model's own nearest /// surface fails the depth test — one blended layer everywhere, no self-overlap darkening. /// /// `cutout` = **the source batch alpha-tests** (AlphaKey). It mirrors what the part's COLOUR pass /// discards, and must keep mirroring it: a twin that writes depth where the colour pass discards /// leaves an invisible depth wall inside the cutout holes. Only an AlphaKey source rides the fade /// twin's hard 224/255 cutout ([`TWIN_CUTOUT_MARKER`]); Opaque sources never alpha-test /// (`m2-blend-promotion-zfill.md` §2/§4 — the reference's twin keeps mode 1's test, mode 0 has /// none; decision 0842) and authored-Blend sources discard nothing either. /// /// Everything that shapes only the *colour* (lighting lane, shade, fog, tint) is canonicalized so /// twins dedupe maximally — the `WOW_ZFILL` fragment returns before any of it. pub(crate) fn zfill_material( cache: &mut MaterialCache, materials: &mut Assets, texture: Option>, two_sided: bool, cutout: bool, light: &Buffer, ) -> Handle { let key = MatKey { texture: texture.as_ref().map(Handle::id), blend: if cutout { ModelBlend::AlphaTest } else { ModelBlend::Blend }, two_sided, is_wmo: false, is_interior: false, is_emissive: false, is_additive: false, fade_variant: cutout, no_depth_write: false, no_depth_test: false, fog_policy: benilla_formats::FogPolicy::Scene, shade: ShadeSel::Lit, batch_order: 0, uv_anim: None, rgb_anim: None, wmo_class: None, sidn: None, window: false, zfill: true, }; if let Some(h) = cache.fetch(&key) { return h; } let cull_mode = if two_sided { None } else { Some(Face::Back) }; let base = StandardMaterial { base_color_texture: texture, // Blend keeps the twin in the transparent pass, where the sort bias can place it before // the colour parts; `specialize` masks the colour writes off and turns blend off. alpha_mode: AlphaMode::Blend, depth_bias: ZFILL_SORT_BIAS, double_sided: two_sided, cull_mode, ..default() }; let handle = materials.add(ExtendedMaterial { base, extension: WowModelExt { // Bit 10 (the twin-cutout marker) drives the shader's hard 224/255 cutout — set // exactly when the colour pass discards, so depth and colour coverage agree. clutter_fade: Vec4::new( 0.0, 0.0, f32::from(ZFILL_MARKER | if cutout { TWIN_CUTOUT_MARKER } else { 0 }), 0.0, ), // Not a fade twin (`model_flags.y` = 0): the zfill pipeline branch forces its own // depth-write, and the cutout rides bit 10 above, so the bit has no work here. model_flags: Vec4::ZERO, sun_scale: Vec4::new(ShadeSel::Lit.selector(), 0.0, 0.0, 0.0), tint: Vec4::new(1.0, 1.0, 1.0, 0.0), sidn: Vec4::ZERO, light_buf: light.clone(), }, }); cache.insert(key, handle.clone()); handle } /// Replace the fog-policy bits (4-6) inside a packed `clutter_fade.z` marker word, preserving /// every other pipeline marker — bits 0-3 AND the Mod/Mod2x multiply markers in bits 7-8 /// (decision 0528). The mask lives here, beside the packer above: the portrait booth's rig twin /// once truncated the word to `u8` and hand-masked `& 0x0f`, silently dropping the multiply /// markers — the char-select white-blade regression. pub(crate) fn replace_fog_policy(z: f32, policy: benilla_formats::FogPolicy) -> f32 { f32::from((z as u16 & !(7 << 4)) | ((policy as u16) << 4)) } /// A model reference path (`.mdx`/`.mdl`, mixed case, backslashes) → its `mpq://…m2` load URL. /// Lowercased so case variants share one `AssetServer` handle; the physical archive file is `.m2`. pub(crate) fn m2_url(raw: &str) -> String { let p = raw.to_ascii_lowercase().replace('\\', "/"); let stem = p .strip_suffix(".mdx") .or_else(|| p.strip_suffix(".mdl")) .or_else(|| p.strip_suffix(".m2")) .unwrap_or(&p); format!("mpq://{stem}.m2") } /// A WMO root path → its `mpq://…wmo` load URL (already `.wmo`; lowercased for handle dedup). pub(crate) fn wmo_url(raw: &str) -> String { format!("mpq://{}", raw.to_ascii_lowercase().replace('\\', "/")) } /// A creature skin variation → its `mpq://…blp` URL: `\.blp`. `model_dir` is the /// directory of the creature's model path (where its `Monster1/2/3` skins live). pub(crate) fn skin_url(model_dir: &str, name: &str) -> String { let dir = model_dir.replace('\\', "/").to_ascii_lowercase(); let name = name.to_ascii_lowercase(); if dir.is_empty() { format!("mpq://{name}.blp") } else { format!("mpq://{dir}/{name}.blp") } } #[cfg(test)] mod tests { use super::replace_fog_policy; use benilla_formats::FogPolicy; /// Swapping the fog policy must preserve EVERY pipeline marker bit — 0-3 and the 0528 /// multiply markers in 7-8. The portrait booth's old hand-rolled `as u8 & 0x0f` dropped /// bits 7-8 (the char-select white-blade regression this pins against). #[test] fn replace_fog_policy_preserves_pipeline_markers() { // All marker bits set (0-3, 7-8) + fog policy Grey (3) in bits 4-6. let z = f32::from(0b1_1000_1111u16 | (3 << 4)); let out = replace_fog_policy(z, FogPolicy::Off) as u16; assert_eq!(out & 0b1_1000_1111, 0b1_1000_1111, "markers preserved"); assert_eq!((out >> 4) & 7, FogPolicy::Off as u16, "fog swapped"); // And the reverse direction: no stray bits invented. let out2 = replace_fog_policy(f32::from(0u16), FogPolicy::Grey) as u16; assert_eq!(out2, (FogPolicy::Grey as u16) << 4); } /// The authored-batch-order sort epsilon sits in its working band: any realistic model's /// last batch still biases UNDER the effect lane's owner-last rung floor (an item's glow /// particles must draw after every batch of the item — 0719/0721), and one step is far above /// f32 noise on a far-scene sort distance (~4e-5 at 500 yd), so the tie actually breaks. /// Pins the constant against a careless resize in either direction — either end failing is /// the Naxx-item strobe (coplanar layers re-flipping a sort tie every frame) or its dual /// (effects interleaving into their owner's batches). #[test] fn batch_order_sort_eps_sits_between_f32_noise_and_the_effect_rung() { // 512 batches is comfortably past any shipped model's batch table. let deepest = super::BATCH_ORDER_SORT_EPS * 512.0; assert!( deepest < benilla_formats::owner_last_rung(0.0), "a model's last batch must still sort before its own effects' rung" ); let far_noise = 500.0_f32 * f32::EPSILON; assert!( super::BATCH_ORDER_SORT_EPS > 8.0 * far_noise, "one order step must dominate f32 noise on a far sort distance" ); // And bevy's pipeline key truncates it to 0 — the bias may never mint pipelines (0837): // sub-1.0 biases all cast to the same i32 constant. assert_eq!(deepest as i32, 0); } }