From 0fdc8e1ad4f859cd60fb8806fab83b84252b3308 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 05:38:55 +0100
Subject: [PATCH] MONKEY (wind): sway classified tree foliage
Classify static alpha-tested tree and bush leaf batches, log accepted model paths, and apply gentle anchor-phased crown sway and flutter in both retained and fade-twin render paths. Keep animated doodads and tree shadows unchanged, and add wind capture instruments.
Co-Authored-By: GPT-5.6 Sol (Codex)
---
LIGHTING.md | 6 ++
crates/benilla-app/src/capture/fixtures.rs | 59 ++++++++++++
crates/benilla-app/src/capture/mod.rs | 3 +-
crates/benilla-app/src/capture/scenarios.rs | 30 +++++++
crates/benilla-app/src/lib.rs | 13 +--
.../benilla-assets/src/shaders/wind_hook.wgsl | 33 +++++++
.../benilla-assets/src/shaders/wow_model.wgsl | 9 ++
crates/benilla-world/src/mesh_tag.rs | 25 ++++++
.../benilla-world/src/shaders/static_gx.wgsl | 15 +++-
crates/benilla-world/src/static_gx/bake.rs | 7 +-
crates/benilla-world/src/static_gx/cull.rs | 6 +-
crates/benilla-world/src/static_gx/mod.rs | 89 +++++++++++++++++++
12 files changed, 283 insertions(+), 12 deletions(-)
diff --git a/LIGHTING.md b/LIGHTING.md
index bb879614..02b9b775 100644
--- a/LIGHTING.md
+++ b/LIGHTING.md
@@ -25,6 +25,7 @@ behind each constant.
| Ground-effect spells | Flamestrike, Rain of Fire, Consecration, Flare and fire traps light the ground for their duration; frost and nature areas stay dark | `spellLightGain` |
| Volumetric fog | near-field haze that converges on the zone fog colour (clear within 10 yd, full by 150 yd; mistier at dawn and in bad weather, faint indoors) and sun/moon light shafts through gaps, sampled from the shadow map; own fullscreen pass after the main pass (`benilla-app/src/volumetric_fog.rs`) | Advanced Graphics → Volumetric Fog (Off/Low/High), cvar `volumetricFog`, env `WOW_VOLFOG=0\|1\|2` |
| Sky dithering | a faint screen-space dither in the FFXGlow combine so smooth sky and fog gradients do not band (MONKEY p0; was env-only) | Advanced Graphics → Sky Dithering, cvar `skyDither` 0/1 (default 0, Graphics preset High = 1), env `WOW_DITHER=1` still forces it on; bridge in `benilla-app/src/monkey_gfx.rs` |
+| Foliage wind | one weather-fed gust/veer field drives grass and classified tree/bush foliage; grass also parts around the player and nearby units | Advanced Graphics → Foliage Wind, cvar `foliageWind` 0 Off / 1 Grass / 2 Grass + Trees (High = 2), capture override `WOW_FOLIAGE_WIND`; field and benders in `benilla-world/src/wind/` |
| Night and interior level | global dimming of the night sky term and of interior ambient | `nightGain`, `interiorGain`, `interiorBakeFloor` |
Players reach all of it from **Options -> Advanced Graphics** (a Lighting Quality preset Off / Low / Medium / High plus the individual rows; Off is the original client look). The dev build has a panel for all of it: **Ctrl+Shift+D → Lighting & shadows**, with Dim / Default /
@@ -52,6 +53,9 @@ is its own module, documented in `WATER.md`.
`character_shadow.rs`, `world_shadow.rs`, `blob_shadow.rs`, `entities/carried_light.rs`,
`entities/spell_fx/lifecycle.rs`, `dynamic_interior.rs`, `debug_panel/lighting_controls.rs`, and
the cvars in `cvars.rs` / `video.rs`.
+- **Wind**: `wind/mod.rs` resolves the WXL-derived shared field and nearest-unit benders;
+ `clutter.rs` authors blade weights/phases; `static_gx` classifies and marks alpha-tested leaf
+ batches. `wind_hook.wgsl` owns the displacement shared by clutter, retained trees and fade twins.
- **Tools**: `benilla-extract wmolights [--verts ]`, `wmolamps`, `m2firescan`
print the inputs the system works from (groups, batch classes, portals, claims, flame emitters).
@@ -80,6 +84,8 @@ is its own module, documented in `WATER.md`.
(`benilla-world/src/lighting/monkey_frame.rs`); `global_light::pack_monkey_frame` packs it after
the point table and fills `time_of_day` / `night` itself. All zero = no visual change.
- World lights use upstream's `WorldPointLight`, never Bevy's `PointLight`.
+- Foliage wind is vertex-only. Tree and leaf shadow meshes stay static; the small mismatch is the
+ accepted first-stage cost/complexity tradeoff.
- WGSL only fails at pipeline creation, so a shader edit is verified by running a world scene, not
by `cargo check`.
- Exterior point lights are selected per draw unit (12 slots, ranked against the chunk's box); the
diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs
index 595a64f2..756b8968 100644
--- a/crates/benilla-app/src/capture/fixtures.rs
+++ b/crates/benilla-app/src/capture/fixtures.rs
@@ -29,6 +29,65 @@ const CHEST_GUID: u64 = (0xF110u64 << 48) | 0x744;
/// face, `rear` the tail — the same body, its lit and unlit sides.
const SUBJECT_YAW: f32 = 2.36;
+/// MONKEY (wind): stand one real player in the grass-parting world capture without loading the
+/// player UI. `UiFixture::Subject` intentionally opts the whole HUD in; parting needs the live
+/// entity path but a pristine world framebuffer. `look` is the scenario's documented feet point.
+pub(super) fn seed_wind_player(
+ mut commands: Commands,
+ ctx: Res,
+ progress: Res,
+ mut seeded: Local,
+) {
+ let Some(scenario) = ctx.scenario else {
+ return;
+ };
+ if *seeded
+ || scenario.name != "wind-player-parting"
+ || !(progress.total > 0 && progress.ready == progress.total)
+ {
+ return;
+ }
+ *seeded = true;
+ use benilla_protocol::messages::ObjectFields;
+ commands.spawn((
+ crate::net::Guid(0x51),
+ crate::net::NetEntity {
+ kind: benilla_protocol::EntityKind::Player,
+ // HumanMale.m2; the normal entity visual path supplies the body.
+ display_id: Some(49),
+ scale: 1.0,
+ },
+ crate::net::ObjectStore(ObjectFields::from_pairs(&[
+ (22, 100), // UNIT_FIELD_HEALTH
+ (28, 100), // UNIT_FIELD_MAXHEALTH
+ (34, 12), // UNIT_FIELD_LEVEL
+ (35, 1), // UNIT_FIELD_FACTIONTEMPLATE
+ (36, 1 | 1 << 8), // UNIT_FIELD_BYTES_0: human warrior
+ ])),
+ // A player visual waits for its equipment resolution handshake before attaching. This
+ // fixture is intentionally naked, so its final resolved set is known immediately.
+ crate::entities::Equipment {
+ settled: true,
+ ..default()
+ },
+ // Mark this visible remote body as the capture's viewer. The real client publishes the
+ // local body through `Viewer::at`; this server-less instrument has no active Player.
+ benilla_world::world_unit::ViewerUnit,
+ benilla_world::world_unit::WorldUnit {
+ wades: true,
+ scale: 1.0,
+ height: crate::entities::CollisionHeight::default().0,
+ bound: None,
+ },
+ Transform {
+ translation: wow_to_bevy(scenario.look),
+ rotation: Quat::from_rotation_y(SUBJECT_YAW),
+ ..default()
+ },
+ Visibility::default(),
+ ));
+}
+
/// Seed the fixture window's state resources once, right after the scene goes resident — the real
/// feeds then push it into the VM during the settle window exactly as live wire data would. Item
/// icons resolve through the offline `ItemDisplayCatalog` (display ids chosen from entries that
diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs
index 51e87b91..226cc85d 100644
--- a/crates/benilla-app/src/capture/mod.rs
+++ b/crates/benilla-app/src/capture/mod.rs
@@ -119,7 +119,7 @@ mod probes;
mod scenarios;
use crate::run_mode::CaptureMode;
pub(crate) use depth_probe::DepthProbePlugin;
-use fixtures::seed_ui_fixture;
+use fixtures::{seed_ui_fixture, seed_wind_player};
pub(crate) use live_shot::LiveShotPlugin;
pub(crate) use phase_probe::PhaseProbePlugin;
pub(crate) use pick_probe::PickProbePlugin;
@@ -904,6 +904,7 @@ impl Plugin for CapturePlugin {
bailed: false,
})
.add_systems(Update, pin_scene.in_set(WorldStage::Present))
+ .add_systems(Update, seed_wind_player)
// Before the UnitFeed pass: the seed stands in for wire data that in live play
// filled the app caches on EARLIER frames, so the same frame's feeds (item-template
// / player-req pushes, then the merchant paint) must all see it. Unordered, the
diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs
index d970a9fd..c0290f5a 100644
--- a/crates/benilla-app/src/capture/scenarios.rs
+++ b/crates/benilla-app/src/capture/scenarios.rs
@@ -1379,6 +1379,36 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 0,
ui: None,
},
+ // MONKEY wind: keep these at the END of the table. They are the three visual instruments for
+ // W1: authored grass weight/lean, static-tree classification/sway, and the viewer bender.
+ Scenario {
+ name: "wind-grass-elwynn",
+ map: Some(MAP_AZEROTH),
+ // Inside the grassy hill that the earlier long shot put beyond clutter's 70 yd horizon.
+ eye: [-9460.0, 45.0, 60.5],
+ look: [-9442.0, 27.0, 57.5],
+ minute: 720,
+ ui: None,
+ },
+ Scenario {
+ name: "wind-forest-elwynn",
+ map: Some(MAP_AZEROTH),
+ // East of Goldshire looking south-west across the lake and Elwynn forest.
+ eye: [-9380.0, -30.0, 80.0],
+ look: [-9600.0, -200.0, 62.0],
+ minute: 720,
+ ui: None,
+ },
+ Scenario {
+ name: "wind-player-parting",
+ map: Some(MAP_AZEROTH),
+ eye: [-9472.0, 57.0, 60.5],
+ look: [-9460.0, 45.0, 57.5],
+ minute: 720,
+ // The capture plugin stands a real player at `look` without opting into the HUD: this is
+ // a world-image instrument, and UI script errors must not cover the grass it measures.
+ ui: None,
+ },
];
/// The owner's reported vantage for the lava-glow report: `(-7048.8, -1000.6, 242.0)` facing
diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs
index cece103d..8b9ea057 100644
--- a/crates/benilla-app/src/lib.rs
+++ b/crates/benilla-app/src/lib.rs
@@ -311,12 +311,13 @@ pub fn run(build: BuildId) -> AppExit {
// never fights the director's screen (decision 0703; `WOW_BG` overrides). See `bgwin`.
let background = benilla_world::bgwin::background_run();
if capturing {
- // Ground clutter scatters with per-run randomness, so disable it for byte-stable baselines
- // — clutter isn't what the lighting rework validates, and the regression diff must not be
- // masked by grass wobble. Set before plugins build so `ClutterConfig::from_env` reads it.
- // It is not the only source of per-run drift, though it was long documented as such: the
- // other is the frame clock itself, frozen in `capture` (decision 0723).
- std::env::set_var("WOW_CLUTTER_DENSITY", "0");
+ // Ordinary baselines omit clutter, but a feature-specific capture may explicitly ask for
+ // it (MONKEY wind does). Scatter is now chunk-seeded and deterministic; the default stays
+ // zero so unrelated lighting diffs are not masked by grass. Set before plugins build so
+ // `ClutterConfig::default` reads it.
+ if std::env::var_os("WOW_CLUTTER_DENSITY").is_none() {
+ std::env::set_var("WOW_CLUTTER_DENSITY", "0");
+ }
// Third source of per-run drift, and the one the lighting matrix (decision 0746) hit: the
// anim-LOD park/wake gate (`creature_anim::lod::gate_rig_animation`). Whether a rig is
// parked, and which pose it wakes into, depends on when its model finished loading relative
diff --git a/crates/benilla-assets/src/shaders/wind_hook.wgsl b/crates/benilla-assets/src/shaders/wind_hook.wgsl
index 762cceab..6347f55a 100644
--- a/crates/benilla-assets/src/shaders/wind_hook.wgsl
+++ b/crates/benilla-assets/src/shaders/wind_hook.wgsl
@@ -59,3 +59,36 @@ fn grass_offset(
}
return vec3(xz.x, 0.0, xz.y);
}
+
+// MONKEY (wind): our tree/bush design (WarcraftXL has no tree wind). Only leaf-card batches call
+// this: a low-frequency crown sway, small leaf flutter, stable anchor phase and distance fade.
+fn tree_offset(
+ world: vec3,
+ anchor: vec3,
+ camera: vec3,
+ frame: MonkeyFrame,
+) -> vec3 {
+ if (frame.wind1.w <= 0.0) {
+ return vec3(0.0);
+ }
+ let h = max(world.y - anchor.y, 0.0);
+ // A one-yard bush still needs a crown; tree leaf cards generally begin above this range and
+ // therefore reach full weight, while the first 0.15 yd remains a planted base.
+ let crown = smoothstep(0.15, 1.5, h);
+ let weight = crown * crown;
+ let dir = normalize(frame.wind0.xy + vec2(1.0e-6, 0.0));
+ let cross = vec2(-dir.y, dir.x);
+ let phase = fract(sin(dot(anchor.xz, vec2(0.173, 0.317))) * 43758.5453) * WIND_TAU;
+ let travel = frame.wind0.z * 0.08 * frame.wind1.x;
+ let slow = sin(dot(dir, world.xz) * (WIND_TAU / 34.0) - travel + phase);
+ let side = sin(dot(cross, world.xz) * (WIND_TAU / 21.0) - travel * 0.61 + phase * 1.7);
+ let flutter = sin(frame.wind1.x * 2.3 + dot(world.xz, vec2(1.7, 2.1)) + phase * 2.0);
+ let gust = 0.75 + frame.wind0.w * 0.5;
+ let fade = 1.0 / (1.0 + distance(camera, world) * 0.006);
+ let xz = (
+ dir * (0.045 + slow * 0.055)
+ + cross * side * 0.025
+ + dir * flutter * 0.012
+ ) * weight * gust * fade * frame.wind1.y * frame.wind1.w;
+ return vec3(xz.x, 0.0, xz.y);
+}
diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl
index 5a0e7dea..7743445e 100644
--- a/crates/benilla-assets/src/shaders/wow_model.wgsl
+++ b/crates/benilla-assets/src/shaders/wow_model.wgsl
@@ -1091,6 +1091,15 @@ fn vertex(vertex: WowVertex) -> WowVsOut {
);
}
#endif
+ // MONKEY (wind): a retained tree inside its fade band renders here. StaticGx marks only its
+ // alpha-tested leaf batches in MeshTag bit 18; use the same anchor/field maths to avoid a pop.
+ let wind_tag = mesh_functions::get_tag(vertex.instance_index);
+ if ((wind_tag & 262144u) != 0u && m.clutter_fade.w <= 0.5) {
+ let world = p_cam + view.world_position;
+ p_cam += wind_hook::tree_offset(
+ world, frame_origin, view.world_position, wow_light.monkey
+ );
+ }
out.world_position = vec4(p_cam + view.world_position, 1.0);
let view_rot = mat3x3(
view.view_from_world[0].xyz,
diff --git a/crates/benilla-world/src/mesh_tag.rs b/crates/benilla-world/src/mesh_tag.rs
index d6ddc327..85339128 100644
--- a/crates/benilla-world/src/mesh_tag.rs
+++ b/crates/benilla-world/src/mesh_tag.rs
@@ -90,6 +90,10 @@ const SHADE_SHIFT: u32 = 6;
/// Sits in the exterior payload's reserved bits, so [`with_shade`]/[`with_alpha`]/[`with_rig`]
/// carry it through and the shader's 8-bit shade decode never sees it.
pub(crate) const MATTE_INDOOR_BIT: u32 = 0x0000_4000;
+/// MONKEY (wind): bit 18 on an exterior, non-Matte static doodad exile marks a classified
+/// tree/bush leaf batch. It aliases the top lane-weight bit safely: lane weight is read only with
+/// [`MATTE_INDOOR_BIT`], which these exterior fade twins never carry.
+const FOLIAGE_WIND_BIT: u32 = 0x0004_0000;
/// Bits 15..=18 of the exterior payload (MONKEY, portal lane fade): the **lane weight**, a 4-bit
/// crossfade `0` (fully exterior-lit) … [`LANE_MAX`] (fully room-lit) that the shader mixes the
/// two lanes by. It rides beside [`MATTE_INDOOR_BIT`] — the bit says "this part is on the room
@@ -204,6 +208,16 @@ pub fn alpha_bits(alpha: f32) -> u32 {
}
}
+/// MONKEY (wind): marks or clears the fade-twin foliage receiver without disturbing its live
+/// alpha. Only exterior static doodad exiles use this payload convention.
+pub(crate) fn with_foliage_wind(tag: u32, on: bool) -> u32 {
+ if on {
+ tag | FOLIAGE_WIND_BIT
+ } else {
+ tag & !FOLIAGE_WIND_BIT
+ }
+}
+
/// Sets or clears [`INTERIOR_FOG_BIT`] alone: the probe slot says how a prop is lit, the
/// reference's `[0xca7f00]` only which fog triple its batch gets.
pub(crate) fn with_interior_fog(tag: u32, on: bool) -> u32 {
@@ -352,6 +366,17 @@ mod tests {
assert_eq!(shade_of(t, ext()), 10);
}
+ // MONKEY (wind): the fade-twin marker is orthogonal to the live fade field.
+ #[test]
+ fn foliage_marker_survives_alpha_writes_and_clears_cleanly() {
+ let alpha = alpha_bits(0.5);
+ let marked = with_foliage_wind(alpha, true);
+ assert_eq!(marked & FOLIAGE_WIND_BIT, FOLIAGE_WIND_BIT);
+ assert_eq!(marked & ALPHA_MASK, alpha);
+ assert_eq!(with_alpha(marked, 0.25) & FOLIAGE_WIND_BIT, FOLIAGE_WIND_BIT);
+ assert_eq!(with_foliage_wind(marked, false), alpha);
+ }
+
#[test]
fn probe_bits_compose_with_the_alpha_field() {
let t = probe_bits(6660);
diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl
index a626e709..7914e132 100644
--- a/crates/benilla-world/src/shaders/static_gx.wgsl
+++ b/crates/benilla-world/src/shaders/static_gx.wgsl
@@ -14,6 +14,8 @@
#import benilla::monkey_frame
// MONKEY (p0 fog hook): the one distance-fog law every receiver calls.
#import benilla::fog_hook
+// MONKEY (wind): shared foliage vertex displacement; tree shadows intentionally stay static.
+#import benilla::wind_hook
// Group 0 is Bevy's standard mesh-view bind group (view matrices, directional-light records and
// the shadow textures the retained pass reads).
@@ -103,6 +105,8 @@ const WORD_HAS_VC: u32 = 134217728u; // 1 << 27: the batch authors vertex colo
// INTERIOR without WMO is an interior M2 prop, the entity shader's `interior_prop =
// flags.z && !flags.x`: probe lighting, interior fog, no point lights.
const WORD_MATTE: u32 = 268435456u; // 1 << 28: ShadeSel::Matte, fixed intensity 1.0
+// MONKEY (wind): alpha-tested leaf batch of a classified static tree/bush model.
+const WORD_FOLIAGE_WIND: u32 = 536870912u; // 1 << 29
// MONKEY (room gate): record column `w` bits 15..=26 — this item's ROOM KEY, packed as
// `group + 1`, so **0 means the item names no room** and takes every fixture (a terrain-cell item,
@@ -1282,8 +1286,15 @@ fn vertex(v: GxVertex) -> GxVsOut {
return out;
}
// Camera-relative for f32 precision: the recentred vertex plus (cell origin - camera).
- let p_cam = v.position + (cell.origin.xyz - view.world_position);
- let world = v.position + cell.origin.xyz;
+ var p_cam = v.position + (cell.origin.xyz - view.world_position);
+ var world = v.position + cell.origin.xyz;
+ // MONKEY (wind): placement anchor is already baked per vertex. Only classified leaf-card
+ // batches carry the bit; animated doodads were rejected before this retained path.
+ if ((v.word & WORD_FOLIAGE_WIND) != 0u) {
+ let offset = wind_hook::tree_offset(world, v.anchor, view.world_position, wow_light.monkey);
+ world += offset;
+ p_cam += offset;
+ }
out.world_position = vec4(world, 1.0);
let view_rot = mat3x3(
view.view_from_world[0].xyz,
diff --git a/crates/benilla-world/src/static_gx/bake.rs b/crates/benilla-world/src/static_gx/bake.rs
index d4fe063a..e7df0540 100644
--- a/crates/benilla-world/src/static_gx/bake.rs
+++ b/crates/benilla-world/src/static_gx/bake.rs
@@ -12,8 +12,9 @@ use super::{
MAX_DIRTY_FRAMES, REBAKE_FRAMES,
};
use super::{
- WORD_CLASS_INT, WORD_CLASS_TRANS, WORD_FOG_OFF, WORD_HAS_VC, WORD_INTERIOR, WORD_MATTE,
- WORD_SHADE_LIT, WORD_TEXTURED, WORD_UNLIT, WORD_WINDOW, WORD_WMO, WORD_WRAP_X, WORD_WRAP_Y,
+ WORD_CLASS_INT, WORD_CLASS_TRANS, WORD_FOG_OFF, WORD_FOLIAGE_WIND, WORD_HAS_VC, WORD_INTERIOR,
+ WORD_MATTE, WORD_SHADE_LIT, WORD_TEXTURED, WORD_UNLIT, WORD_WINDOW, WORD_WMO, WORD_WRAP_X,
+ WORD_WRAP_Y,
};
/// Print the declined-batch census, beside the accepted count, once the counts have sat still for
@@ -208,6 +209,8 @@ fn bake_cell(items: &[GxItem], meshes: &mut Assets) -> render::GxCellDraw
| (u32::from(item.matte) * WORD_MATTE)
| (u32::from(item.texture.is_some()) * WORD_TEXTURED)
| (u32::from(has_vc) * WORD_HAS_VC)
+ // MONKEY (wind): only classified alpha-tested tree/bush leaf cards carry this bit.
+ | (u32::from(item.foliage_wind) * WORD_FOLIAGE_WIND)
// An interior prop is WORD_INTERIOR without WORD_WMO; a slot-less prop keeps the
// exterior law, as on the entity path.
| item.prop.as_ref().map_or(0, |p| {
diff --git a/crates/benilla-world/src/static_gx/cull.rs b/crates/benilla-world/src/static_gx/cull.rs
index f1adab2c..8db109b1 100644
--- a/crates/benilla-world/src/static_gx/cull.rs
+++ b/crates/benilla-world/src/static_gx/cull.rs
@@ -100,7 +100,11 @@ fn spawn_exile(commands: &mut Commands, f: &GxFader, alpha: f32, admitted: bool)
},
crate::model_render::EntityPathWhy("exile"),
crate::interact::PickMesh(b.geometry.clone()),
- bevy::mesh::MeshTag(crate::mesh_tag::alpha_bits(alpha)),
+ // MONKEY (wind): bit 18 is the fade-twin leaf marker in exterior payload mode.
+ bevy::mesh::MeshTag(crate::mesh_tag::with_foliage_wind(
+ crate::mesh_tag::alpha_bits(alpha),
+ b.foliage_wind,
+ )),
crate::model_fade::DoodadFade {
radius: f.radius,
local_center: f.local_center,
diff --git a/crates/benilla-world/src/static_gx/mod.rs b/crates/benilla-world/src/static_gx/mod.rs
index 22858cd9..bde16956 100644
--- a/crates/benilla-world/src/static_gx/mod.rs
+++ b/crates/benilla-world/src/static_gx/mod.rs
@@ -76,6 +76,8 @@ const WORD_HAS_VC: u32 = 1 << 27;
// `ShadeSel::Matte`, fixed 1.0: a map doodad never reaches the 2.5 site (`0x69e4ad`). Not
// `WORD_SHADE_LIT`, so lifting the shader's `min(I, 1)` cap leaves this at 1.0.
const WORD_MATTE: u32 = 1 << 28;
+// MONKEY (wind): classified alpha-tested tree/bush foliage; bits 30-31 remain free.
+const WORD_FOLIAGE_WIND: u32 = 1 << 29;
// An interior M2 prop is WORD_INTERIOR with WORD_WMO clear, the entity shader's `interior_prop =
// flags.z && !flags.x`: SH-probe light from its record slot, interior fog, no live point lights.
@@ -173,6 +175,8 @@ struct GxItem {
prop: Option,
/// `Some(uid)` on a fader item: the placement it exiles with.
fader: Option,
+ /// MONKEY (wind): an alpha-tested leaf-card batch of a static tree/bush model.
+ foliage_wind: bool,
}
/// A WMO-prop item's referrer set (an index into [`GxCell::sets`]) and folded SH-probe slot;
@@ -211,6 +215,8 @@ struct GxFaderBatch {
blend: Handle,
blend_mode: ModelBlend,
geometry: Arc,
+ /// MONKEY (wind): mirror the retained batch's sway while this placement fades as an entity.
+ foliage_wind: bool,
}
/// A fader's exile state. Steady: drawn retained at fade 1. Exiled: drawn as ordinary entities,
@@ -478,6 +484,10 @@ impl StaticGx {
return false;
}
};
+ let foliage_wind = foliage_wind_batch(&b.object.label, b.blend, b.wmo.is_some());
+ if foliage_wind {
+ log_foliage_classification(&b.object.label);
+ }
let wmo_key = b.wmo.as_ref().map(|w| w.instance);
let wmo = b.wmo.map(|w| GxItemWmo {
group: w.group,
@@ -556,6 +566,7 @@ impl StaticGx {
blend: seed.blend,
blend_mode: b.blend,
geometry: b.geometry.clone(),
+ foliage_wind,
});
if is_new {
// A placement's later batches share its sphere: the caches move only on a new one.
@@ -591,6 +602,7 @@ impl StaticGx {
wmo,
prop,
fader: fader_uid,
+ foliage_wind,
});
if !entry.dirty {
entry.dirty_since = self.frame;
@@ -688,6 +700,44 @@ impl StaticGx {
}
}
+/// MONKEY (wind): conservative leaf classification. Alpha test excludes trunks/rocks even when a
+/// whole model path says tree; the name terms cover vanilla's tree/bush/plant families. Animated
+/// doodads never reach this function because `assemble.rs` excludes them before `StaticGx::divert`.
+fn foliage_wind_batch(path: &str, blend: ModelBlend, wmo_geometry: bool) -> bool {
+ if wmo_geometry || blend != ModelBlend::AlphaTest {
+ return false;
+ }
+ let p = path.to_ascii_lowercase();
+ // Substrings alone otherwise catch StreetLamp, tree huts, dead stumps/logs and painted tree
+ // facades. Those can carry alpha-tested cards, but moving the whole card is not foliage sway.
+ if ["street", "treehut", "stump", "log", "facade", "fallen"]
+ .iter()
+ .any(|term| p.contains(term))
+ {
+ return false;
+ }
+ [
+ "tree", "bush", "shrub", "foliage", "fern", "palm", "plant", "willow", "canopy", "hedge",
+ "thorn", "cactus", "vine", "reed", "kelp",
+ ]
+ .iter()
+ .any(|term| p.contains(term))
+}
+
+/// Log each classified model once so false positives can be audited from an ordinary scene load.
+fn log_foliage_classification(path: &str) {
+ use std::collections::HashSet;
+ use std::sync::{Mutex, OnceLock};
+ static SEEN: OnceLock>> = OnceLock::new();
+ let mut seen = SEEN
+ .get_or_init(|| Mutex::new(HashSet::new()))
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ if seen.insert(path.to_string()) {
+ info!("MONKEY wind: classified foliage leaf batches for {path}");
+ }
+}
+
pub struct StaticGxPlugin;
impl Plugin for StaticGxPlugin {
@@ -831,6 +881,45 @@ mod tests {
assert_eq!(gx.cells.len(), 1);
}
+ #[test]
+ fn foliage_classifier_requires_a_leaf_batch_and_a_plant_name() {
+ assert!(foliage_wind_batch(
+ "World\\Azeroth\\Elwynn\\PassiveDoodads\\Trees\\ElwynnTree01.m2",
+ ModelBlend::AlphaTest,
+ false,
+ ));
+ assert!(foliage_wind_batch(
+ "World\\Generic\\PassiveDoodads\\Bush\\Bush01.mdx",
+ ModelBlend::AlphaTest,
+ false,
+ ));
+ assert!(!foliage_wind_batch(
+ "World\\Azeroth\\Elwynn\\PassiveDoodads\\Trees\\ElwynnTree01.m2",
+ ModelBlend::Opaque,
+ false,
+ ));
+ assert!(!foliage_wind_batch(
+ "World\\Stormwind\\StreetLamp01.m2",
+ ModelBlend::AlphaTest,
+ false,
+ ));
+ assert!(!foliage_wind_batch(
+ "World\\Azeroth\\SwampOfSorrow\\PassiveDoodads\\TreeHuts\\LostTreeHuts03.m2",
+ ModelBlend::AlphaTest,
+ false,
+ ));
+ assert!(!foliage_wind_batch(
+ "World\\Azeroth\\Elwynn\\PassiveDoodads\\Tree\\ElwynnLog02.m2",
+ ModelBlend::AlphaTest,
+ false,
+ ));
+ assert!(!foliage_wind_batch(
+ "World\\Wmo\\TreeHouse.wmo",
+ ModelBlend::AlphaTest,
+ true,
+ ));
+ }
+
#[test]
fn a_fader_divert_registers_its_placement_seed() {
let mut gx = StaticGx::default();