Fix TurtleWoW baked NPC atlases (append .blp) plus in-progress GFX work: moonlight, torch depth, water/scene-depth, skybox

This commit is contained in:
chairmanpow
2026-10-01 21:40:02 +01:00
parent 72647d4ab0
commit 031acdd8bb
78 changed files with 4498 additions and 1488 deletions
@@ -0,0 +1,16 @@
<!--
Adapters over the stock CharacterFrame.xml; loaded after it, so these win.
Deviation: `ArenaFrame`, a sixth tab page 1.12 has no version of. TurtleWoW's `patch-4.mpq`
copy of `CharacterFrame.lua` appends "ArenaFrame" to `CHARACTERFRAME_SUBFRAMES` — six names
where the reference has five (line 1) — so `CharacterFrame_ShowSubFrame` indexes all six and
raises `attempt to index field '?' a nil value` on the fifth tab's Hide. There is no
`ArenaFrame.lua` or `.xml` anywhere in the player's chain, none in the reference `_G` capture,
and benilla implements no arena frames, so the name has to exist or the character window's own
tab loop dies. An empty hidden frame is the whole of it: no tab button, no content, no script.
`id="6"` continues the reference's own numbering (PaperDoll 1, PetPaperDoll 2, Reputation 3,
Skill 4, Honor 5), which is what `ToggleCharacter` hands `PanelTemplates_SetTab`.
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Frame name="ArenaFrame" setAllPoints="true" parent="CharacterFrame" hidden="true" id="6" />
</Ui>
+2
View File
@@ -121,6 +121,8 @@ Interface\FrameXML\ReputationFrame.xml
Interface\FrameXML\SkillFrame.xml
# Brings HonorFrameTemplates.xml, whose row templates `Blizzard_InspectUI` inherits.
Interface\FrameXML\HonorFrame.xml
# Ours: the sixth tab page TurtleWoW's `CharacterFrame.lua` lists and 1.12 never shipped.
CharacterFrameAdapters.xml
Interface\FrameXML\DressUpFrame.xml
Interface\FrameXML\SpellBookFrame.xml
# Ours: the pet page's autocast widgets in the pet action button's proportions.
@@ -10,7 +10,9 @@ use super::{survey, Drew};
// so the same oracle runs without administrator symlink privileges on Windows.
pub(super) fn link_fixture(source: &Path, destination: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{ std::os::unix::fs::symlink(source, destination) }
{
std::os::unix::fs::symlink(source, destination)
}
#[cfg(not(unix))]
{
std::fs::create_dir_all(destination)?;
+21 -7
View File
@@ -41,9 +41,9 @@ use crate::net::{Embodied, NetEntity};
use benilla_world::decal::{DecalFrame, WorldDecal};
// MONKEY (moon shadows): the oval now yields to a NIGHT cast as well as a day one, so it needs the
// same two numbers the packer weights that cast with.
use benilla_world::lighting::ShadowHandover;
use crate::character_shadow::CharacterShadowReady;
use crate::shadow_core::{ShadowFrame, ShadowSet};
use benilla_world::lighting::ShadowHandover;
use benilla_world::particles::buffer::{begin_effect_frame, EffectVertex};
use benilla_world::view::WorldCamera;
use benilla_world::wmo_portal::UnitWmoRoom;
@@ -241,9 +241,14 @@ fn update_shadows(
// nothing left to draw, so that stays the early-out the hard hide used to be.
let indoors = room.is_some_and(|r| r.room().is_some());
let mut lane_w = 1.0;
if video.character_shadows && !indoors
&& shadow_frame.as_ref().is_some_and(|frame| frame.active && !frame.suspended)
&& ready.as_ref().is_some_and(|ready| ready.0.contains(&shadow.owner))
if video.character_shadows
&& !indoors
&& shadow_frame
.as_ref()
.is_some_and(|frame| frame.active && !frame.suspended)
&& ready
.as_ref()
.is_some_and(|ready| ready.0.contains(&shadow.owner))
{
let cam_dist = camera_pos.map_or(f32::INFINITY, |c| unit.translation.distance(c));
lane_w = blob_weight(lane_strength, cam_dist, video.shadow_distance);
@@ -585,7 +590,10 @@ mod tests {
for step in 0..=20 {
let height = 0.25 - 0.25 * step as f32 / 20.0; // ~14° down to the horizon
let w = blob_weight(sun_shadow_strength(height), 10.0, 70.0);
assert!(w >= prev - 1e-6, "dusk weight went backwards at step {step}");
assert!(
w >= prev - 1e-6,
"dusk weight went backwards at step {step}"
);
assert!(w - prev < 0.35, "dusk weight jumped at step {step}");
prev = w;
}
@@ -609,7 +617,10 @@ mod tests {
let night_sun = sun_shadow_strength(-0.3);
assert_eq!(night_sun, 0.0);
let full = blob_weight(night_sun, 10.0, 70.0);
assert_eq!(full, 1.0, "with the feature off the whole oval is still there");
assert_eq!(
full, 1.0,
"with the feature off the whole oval is still there"
);
let under_moon = blob_weight(night_sun + 0.35, 10.0, 70.0);
assert!(
(under_moon - 0.65).abs() < 1e-6,
@@ -625,7 +636,10 @@ mod tests {
let mut prev = 1.0;
for step in 0..=20 {
let w = blob_weight(night_sun + 0.35 * step as f32 / 20.0, 10.0, 70.0);
assert!(w <= prev + 1e-6, "the oval brightened as the moon rose, at step {step}");
assert!(
w <= prev + 1e-6,
"the oval brightened as the moon rose, at step {step}"
);
prev = w;
}
assert!((prev - 0.65).abs() < 1e-6);
+7 -2
View File
@@ -1044,7 +1044,9 @@ pub(super) fn seed_ui_fixture(
}
// MONKEY (volumetric fog): exercise the rendered Advanced Graphics dropdown and layout.
UiFixture::OptionsAdvancedGraphics => {
let Some(script) = script else { return; };
let Some(script) = script else {
return;
};
script.register_cvars(crate::cvars::registered_pairs());
if let Err(e) = script.run(
"ShowUIPanel(BenillaOptionsFrame); BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()",
@@ -1685,7 +1687,10 @@ pub(super) fn seed_perf_crowd(
spatial: avian3d::prelude::SpatialQuery,
mut seeded: Local<bool>,
) {
let Some(n) = std::env::var("WOW_PERF_CROWD").ok().and_then(|v| v.parse::<u32>().ok()) else {
let Some(n) = std::env::var("WOW_PERF_CROWD")
.ok()
.and_then(|v| v.parse::<u32>().ok())
else {
return;
};
let Some(scenario) = ctx.scenario else {
+197 -68
View File
@@ -289,29 +289,48 @@ pub(super) const SUBJECT_INDOOR: [f32; 3] = [-9469.4, 31.9, 57.9];
pub(super) const ON_DEMAND: &[Scenario] = &[
// MONKEY (volumetric fog): UI proof uses the same live page selection as a player.
Scenario {
name: "ui-options-advanced", map: Some(MAP_AZEROTH),
eye: GROUND_EYE, look: GROUND_LOOK, minute: 720,
name: "ui-options-advanced",
map: Some(MAP_AZEROTH),
eye: GROUND_EYE,
look: GROUND_LOOK,
minute: 720,
ui: Some(UiFixture::OptionsAdvancedGraphics),
},
// MONKEY (volumetric fog): reproducible dawn trees, moonlit lamps and the inn's common room.
Scenario {
name: "volfog-dawn", map: Some(MAP_AZEROTH),
eye: [-9460.0, 70.0, 58.0], look: [-9410.0, 120.0, 67.0], minute: 390, ui: None,
name: "volfog-dawn",
map: Some(MAP_AZEROTH),
eye: [-9460.0, 70.0, 58.0],
look: [-9410.0, 120.0, 67.0],
minute: 390,
ui: None,
},
Scenario {
name: "volfog-night", map: Some(MAP_AZEROTH),
eye: [-9460.0, 70.0, 58.0], look: [-9450.0, 20.0, 61.0], minute: 0, ui: None,
name: "volfog-night",
map: Some(MAP_AZEROTH),
eye: [-9460.0, 70.0, 58.0],
look: [-9450.0, 20.0, 61.0],
minute: 0,
ui: None,
},
// MONKEY (volumetric fog): celestial_sun_direction(420) has WoW azimuth
// 45 degrees and elevation 12.20778 degrees: dz = hypot(70,70)*tan(elevation).
// The Goldshire lake bank has sunlit air behind the gaps between the trees.
Scenario {
name: "volfog-sun", map: Some(MAP_AZEROTH),
eye: WATER_EYE, look: [-9457.0, -240.6, 92.21753], minute: 420, ui: None,
name: "volfog-sun",
map: Some(MAP_AZEROTH),
eye: WATER_EYE,
look: [-9457.0, -240.6, 92.21753],
minute: 420,
ui: None,
},
Scenario {
name: "volfog-inn", map: Some(MAP_AZEROTH),
eye: INN_EYE, look: INN_LOOK, minute: 390, ui: None,
name: "volfog-inn",
map: Some(MAP_AZEROTH),
eye: INN_EYE,
look: INN_LOOK,
minute: 390,
ui: None,
},
// WOW_CAPTURE_WATER_T=<seconds>: fixed Enhanced water phase (read once, default 0).
// Compare 0 and 1.5; Classic and non-water animation stay frozen.
@@ -1090,47 +1109,79 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
look: LAVA_RIVER_LOOK,
minute: 0,
ui: None,
}, // MONKEY (daylight): city interiors by day and terrain torch shadows at night. World coords
}, // MONKEY (daylight): city interiors by day and terrain torch shadows at night. World coords
// from the census (`lighting::daylight::census`, Stormwind uid 10047, Ironforge uid 7706).
// The Gilded Rose ground floor (group g268, portal + aperture seeds).
Scenario {
name: "daylight-sw-inn", map: Some(MAP_AZEROTH),
eye: [-8871.0, 681.0, 99.8], look: [-8858.0, 668.0, 98.6], minute: 720, ui: None,
name: "daylight-sw-inn",
map: Some(MAP_AZEROTH),
eye: [-8871.0, 681.0, 99.8],
look: [-8858.0, 668.0, 98.6],
minute: 720,
ui: None,
},
// The Cathedral of Light nave (groups g135/g146, lit only by two EXT-class window batches).
Scenario {
name: "daylight-sw-cathedral", map: Some(MAP_AZEROTH),
eye: [-8556.0, 826.0, 109.0], look: [-8515.0, 862.0, 112.0], minute: 720, ui: None,
name: "daylight-sw-cathedral",
map: Some(MAP_AZEROTH),
eye: [-8556.0, 826.0, 109.0],
look: [-8515.0, 862.0, 112.0],
minute: 720,
ui: None,
},
// A Trade District house (group g68 NEH02, one exterior portal).
Scenario {
name: "daylight-sw-shop", map: Some(MAP_AZEROTH),
eye: [-8799.0, 696.0, 104.3], look: [-8786.0, 707.0, 103.0], minute: 720, ui: None,
name: "daylight-sw-shop",
map: Some(MAP_AZEROTH),
eye: [-8799.0, 696.0, 104.3],
look: [-8786.0, 707.0, 103.0],
minute: 720,
ui: None,
},
// Ironforge: the Great Forge hall (g64, no opening to the sky) and the gate hall (g7, the
// city's one exterior portal).
Scenario {
name: "daylight-if-forge", map: Some(MAP_AZEROTH),
eye: [-4930.0, -945.0, 503.5], look: [-4890.0, -985.0, 503.0], minute: 720, ui: None,
name: "daylight-if-forge",
map: Some(MAP_AZEROTH),
eye: [-4930.0, -945.0, 503.5],
look: [-4890.0, -985.0, 503.0],
minute: 720,
ui: None,
},
Scenario {
name: "daylight-if-gate", map: Some(MAP_AZEROTH),
eye: [-4975.0, -895.0, 503.5], look: [-5005.0, -852.0, 506.0], minute: 720, ui: None,
}, // Goldshire's lamps at midnight: terrain receiving (and, with `torchTerrainShadows`, casting)
name: "daylight-if-gate",
map: Some(MAP_AZEROTH),
eye: [-4975.0, -895.0, 503.5],
look: [-5005.0, -852.0, 506.0],
minute: 720,
ui: None,
}, // Goldshire's lamps at midnight: terrain receiving (and, with `torchTerrainShadows`, casting)
// torch cube shadows.
Scenario {
name: "daylight-torch-goldshire", map: Some(MAP_AZEROTH),
eye: [-9460.0, 70.0, 58.0], look: [-9450.0, 20.0, 61.0], minute: 0, ui: None,
}, // Two Elwynn road lampposts whose own light the terrain blocks most (the census's
name: "daylight-torch-goldshire",
map: Some(MAP_AZEROTH),
eye: [-9460.0, 70.0, 58.0],
look: [-9450.0, 20.0, 61.0],
minute: 0,
ui: None,
}, // Two Elwynn road lampposts whose own light the terrain blocks most (the census's
// `lamp_terrain_occlusion_scan`: 18 % and 16 % of the ground within 25 yd), at midnight, framed
// across the lamp toward the blocked side.
Scenario {
name: "daylight-torch-hill-a", map: Some(MAP_AZEROTH),
eye: [-9314.8, 134.1, 70.0], look: [-9320.4, 165.6, 64.0], minute: 0, ui: None,
name: "daylight-torch-hill-a",
map: Some(MAP_AZEROTH),
eye: [-9314.8, 134.1, 70.0],
look: [-9320.4, 165.6, 64.0],
minute: 0,
ui: None,
},
Scenario {
name: "daylight-torch-hill-b", map: Some(MAP_AZEROTH),
eye: [-9163.1, 181.1, 77.0], look: [-9147.1, 153.4, 71.0], minute: 0, ui: None,
name: "daylight-torch-hill-b",
map: Some(MAP_AZEROTH),
eye: [-9163.1, 181.1, 77.0],
look: [-9147.1, 153.4, 71.0],
minute: 0,
ui: None,
},
// MONKEY (post): lane-specific A/B and perf viewpoints. Kept at the end for merge isolation.
Scenario {
@@ -1171,62 +1222,110 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// scenes above). Raw WoW coords, map per scenario; noon = 720, dusk = 1170, night = 0.
// Elwynn from the Goldshire road toward the lake and the forest: sky, fog and trees.
Scenario {
name: "elwynn-noon", map: Some(MAP_AZEROTH),
eye: GFX_ELWYNN_EYE, look: GFX_ELWYNN_LOOK, minute: 720, ui: None,
name: "elwynn-noon",
map: Some(MAP_AZEROTH),
eye: GFX_ELWYNN_EYE,
look: GFX_ELWYNN_LOOK,
minute: 720,
ui: None,
},
Scenario {
name: "elwynn-dusk", map: Some(MAP_AZEROTH),
eye: GFX_ELWYNN_EYE, look: GFX_ELWYNN_LOOK, minute: 1170, ui: None,
name: "elwynn-dusk",
map: Some(MAP_AZEROTH),
eye: GFX_ELWYNN_EYE,
look: GFX_ELWYNN_LOOK,
minute: 1170,
ui: None,
},
Scenario {
name: "elwynn-night", map: Some(MAP_AZEROTH),
eye: GFX_ELWYNN_EYE, look: GFX_ELWYNN_LOOK, minute: 0, ui: None,
name: "elwynn-night",
map: Some(MAP_AZEROTH),
eye: GFX_ELWYNN_EYE,
look: GFX_ELWYNN_LOOK,
minute: 0,
ui: None,
},
// The same Elwynn view for rain. Capture has no weather field: run it with
// `WOW_WEATHER=1,0.8` (kind 1 rain, grade 0.8; `weather::parse_env_script`).
Scenario {
name: "elwynn-rain", map: Some(MAP_AZEROTH),
eye: GFX_ELWYNN_EYE, look: GFX_ELWYNN_LOOK, minute: 720, ui: None,
name: "elwynn-rain",
map: Some(MAP_AZEROTH),
eye: GFX_ELWYNN_EYE,
look: GFX_ELWYNN_LOOK,
minute: 720,
ui: None,
},
// Westfall: a farmstead, fields and the dry palette.
Scenario {
name: "westfall-farm", map: Some(MAP_AZEROTH),
eye: [-10080.0, 1000.0, 58.0], look: [-10140.0, 1070.0, 38.0], minute: 720, ui: None,
name: "westfall-farm",
map: Some(MAP_AZEROTH),
eye: [-10080.0, 1000.0, 58.0],
look: [-10140.0, 1070.0, 38.0],
minute: 720,
ui: None,
},
// Redridge: Lake Everstill from above Lakeshire, a wider frame than `water-lake`.
Scenario {
name: "redridge-lake", map: Some(MAP_AZEROTH),
eye: [-9350.0, -2340.0, 82.0], look: [-9500.0, -2650.0, 50.0], minute: 720, ui: None,
name: "redridge-lake",
map: Some(MAP_AZEROTH),
eye: [-9350.0, -2340.0, 82.0],
look: [-9500.0, -2650.0, 50.0],
minute: 720,
ui: None,
},
// Duskwood: the road into Darkshire, the zone's short dark fog.
Scenario {
name: "duskwood-road", map: Some(MAP_AZEROTH),
eye: [-10560.0, -1000.0, 50.0], look: [-10570.0, -1200.0, 32.0], minute: 720, ui: None,
name: "duskwood-road",
map: Some(MAP_AZEROTH),
eye: [-10560.0, -1000.0, 50.0],
look: [-10570.0, -1200.0, 32.0],
minute: 720,
ui: None,
},
// Burning Steppes: the ash plain and the sky a skybox would replace.
Scenario {
name: "burning-steppes", map: Some(MAP_AZEROTH),
eye: [-7700.0, -2100.0, 200.0], look: [-7900.0, -1700.0, 150.0], minute: 720, ui: None,
name: "burning-steppes",
map: Some(MAP_AZEROTH),
eye: [-7700.0, -2100.0, 200.0],
look: [-7900.0, -1700.0, 150.0],
minute: 720,
ui: None,
},
// Blasted Lands: the Dark Portal from the north.
Scenario {
name: "blasted-lands-portal", map: Some(MAP_AZEROTH),
eye: [-11700.0, -3200.0, 20.0], look: [-11900.0, -3208.0, 0.0], minute: 720, ui: None,
name: "blasted-lands-portal",
map: Some(MAP_AZEROTH),
eye: [-11700.0, -3200.0, 20.0],
look: [-11900.0, -3208.0, 0.0],
minute: 720,
ui: None,
},
// Mount Hyjal (Kalimdor), inside Light.dbc sphere 270.
Scenario {
name: "hyjal-mount", map: Some(MAP_KALIMDOR),
eye: GFX_HYJAL_EYE, look: GFX_HYJAL_LOOK, minute: 720, ui: None,
name: "hyjal-mount",
map: Some(MAP_KALIMDOR),
eye: GFX_HYJAL_EYE,
look: GFX_HYJAL_LOOK,
minute: 720,
ui: None,
},
// Stormwind: the Cathedral of Light's nave (interior WMO lighting).
Scenario {
name: "stormwind-cathedral-interior", map: Some(MAP_AZEROTH),
eye: GFX_CATHEDRAL_EYE, look: GFX_CATHEDRAL_LOOK, minute: 720, ui: None,
name: "stormwind-cathedral-interior",
map: Some(MAP_AZEROTH),
eye: GFX_CATHEDRAL_EYE,
look: GFX_CATHEDRAL_LOOK,
minute: 720,
ui: None,
},
// Ironforge: the Great Forge (lava, fire light, a huge interior).
Scenario {
name: "ironforge-forge", map: Some(MAP_AZEROTH),
eye: GFX_FORGE_EYE, look: GFX_FORGE_LOOK, minute: 720, ui: None,
name: "ironforge-forge",
map: Some(MAP_AZEROTH),
eye: GFX_FORGE_EYE,
look: GFX_FORGE_LOOK,
minute: 720,
ui: None,
},
// MONKEY (sky): the sky lane's fixtures, from 80 yd above Northshire (clear of the canopy).
// Dusk faces the low sun (az 45°, elev ≈5° at 20:00); night faces away from the moon at 01:00
@@ -1342,38 +1441,68 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// `WOW_WEATHER=1,0.8 WOW_WETNESS=1 WOW_WET_T=3`, and A/B with `WOW_RAIN_SURFACES=0|1`.
// Goldshire's square: the road, the grass, the inn's roofs and walls.
Scenario {
name: "wet-goldshire", map: Some(MAP_AZEROTH),
eye: [-9460.0, 70.0, 59.5], look: [-9452.0, 30.0, 55.0], minute: 720, ui: None,
name: "wet-goldshire",
map: Some(MAP_AZEROTH),
eye: [-9460.0, 70.0, 59.5],
look: [-9452.0, 30.0, 55.0],
minute: 720,
ui: None,
},
// The Elwynn river bank, close enough (< 25 yd) for the rain rings.
Scenario {
name: "wet-river", map: Some(MAP_AZEROTH),
eye: [-9514.0, -330.0, 64.0], look: [-9500.0, -352.0, 61.4], minute: 720, ui: None,
name: "wet-river",
map: Some(MAP_AZEROTH),
eye: [-9514.0, -330.0, 64.0],
look: [-9500.0, -352.0, 61.4],
minute: 720,
ui: None,
},
// A Stormwind Trade District street: exterior WMO paving, walls, eaves.
Scenario {
name: "wet-stormwind", map: Some(MAP_AZEROTH),
eye: [-8833.38, 628.63, 98.5], look: [-8815.0, 662.0, 93.5], minute: 720, ui: None,
name: "wet-stormwind",
map: Some(MAP_AZEROTH),
eye: [-8833.38, 628.63, 98.5],
look: [-8815.0, 662.0, 93.5],
minute: 720,
ui: None,
},
// MONKEY (fix-wet): the wet-stormwind framing at midnight: lit windows must stay lit in rain.
Scenario {
name: "wet-stormwind-night", map: Some(MAP_AZEROTH),
eye: [-8833.38, 628.63, 98.5], look: [-8815.0, 662.0, 93.5], minute: 0, ui: None,
name: "wet-stormwind-night",
map: Some(MAP_AZEROTH),
eye: [-8833.38, 628.63, 98.5],
look: [-8815.0, 662.0, 93.5],
minute: 0,
ui: None,
},
// MONKEY (ao): contact-shadow subjects. Goldshire's street (props against walls, eaves),
// an Elwynn forest floor (trunks, bushes, grass cutouts) and a close unit on open ground.
Scenario {
name: "ao-goldshire", map: Some(MAP_AZEROTH),
eye: [-9430.0, 50.0, 61.0], look: [-9462.0, 30.0, 57.5], minute: 720, ui: None,
name: "ao-goldshire",
map: Some(MAP_AZEROTH),
eye: [-9430.0, 50.0, 61.0],
look: [-9462.0, 30.0, 57.5],
minute: 720,
ui: None,
},
Scenario {
name: "ao-forest", map: Some(MAP_AZEROTH),
eye: [-9560.0, 60.0, 62.0], look: [-9600.0, 90.0, 58.0], minute: 720, ui: None,
name: "ao-forest",
map: Some(MAP_AZEROTH),
eye: [-9560.0, 60.0, 62.0],
look: [-9600.0, 90.0, 58.0],
minute: 720,
ui: None,
},
Scenario {
name: "ao-character", map: Some(MAP_AZEROTH),
eye: [-9495.2, 60.8, 59.0], look: [-9500.00, 56.00, 56.9], minute: 720,
ui: Some(UiFixture::Subject { kind: SubjectKind::Creature, at: SUBJECT_SUN }),
name: "ao-character",
map: Some(MAP_AZEROTH),
eye: [-9495.2, 60.8, 59.0],
look: [-9500.00, 56.00, 56.9],
minute: 720,
ui: Some(UiFixture::Subject {
kind: SubjectKind::Creature,
at: SUBJECT_SUN,
}),
},
// MONKEY (lampfog): lampFog 0/2 A/B set. Kept at the END so parallel lane tables merge cleanly.
// Goldshire square: the Lion's Pride fixtures and outdoor lamps share one night frame.
+5 -2
View File
@@ -6,8 +6,8 @@
//! the per-frame facts the rig publishes ([`ShadowFrame`]). It does not know [`super::world_shadow`]
//! exists — remove either lane and the other is untouched.
use bevy::pbr::MeshMaterial3d;
use bevy::ecs::entity::EntityHashSet;
use bevy::pbr::MeshMaterial3d;
use bevy::prelude::*;
use benilla_assets::materials::WowModelMaterial;
@@ -126,7 +126,10 @@ fn collect_character_shadows(
// whose idle animation is breathing has an unchanged `GlobalTransform` and changed VERTICES.
// Gating on the transform would freeze exactly the poses this lane exists to draw. The rate cap
// is the honest saving, and it is bounded work rather than a guess about content.
if !lane.rate.due(time.elapsed_secs(), video.character_shadow_rate) {
if !lane
.rate
.due(time.elapsed_secs(), video.character_shadow_rate)
{
return;
}
if let Some(handle) = lane.mesh.clone() {
+64 -16
View File
@@ -2684,15 +2684,33 @@ mod tests {
assert_eq!(cvars.get("shadowDistance"), Some("120"));
assert_eq!(cvars.get("interiorShadowSoft"), Some("2.5"));
// MONKEY (volumetric fog): Off must remove atmosphere as well as enhanced water.
for member in ["fireLightGain", "waterQuality", "volumetricFog", "lavaLightGain", "nightGain", "interiorGain"] {
for member in [
"fireLightGain",
"waterQuality",
"volumetricFog",
"lavaLightGain",
"nightGain",
"interiorGain",
] {
let expected = if *name == "Off" {
if matches!(member, "fireLightGain" | "waterQuality" | "volumetricFog" | "lavaLightGain") { "0" } else { "1.0" }
} else if *name == "Ultra" && matches!(member, "waterQuality" | "volumetricFog") {
if matches!(
member,
"fireLightGain" | "waterQuality" | "volumetricFog" | "lavaLightGain"
) {
"0"
} else {
"1.0"
}
} else if *name == "Ultra" && matches!(member, "waterQuality" | "volumetricFog")
{
"2" // MONKEY (presets): Ultra's two maxima over the defaults.
} else {
cvars.default_of(member).unwrap()
};
assert!(same_value(cvars.get(member).unwrap(), expected), "{name}/{member}");
assert!(
same_value(cvars.get(member).unwrap(), expected),
"{name}/{member}"
);
}
}
}
@@ -3010,7 +3028,11 @@ mod tests {
assert!(cvars.reapply_saved_presets() > 0);
assert_eq!(cvars.get("shadowMapSize"), Some("4096"));
assert_eq!(cvars.get("interiorShadowCasters"), Some("16"));
assert_eq!(cvars.get("volumetricFog"), Some("1"), "a saved member stays");
assert_eq!(
cvars.get("volumetricFog"),
Some("1"),
"a saved member stays"
);
// No Graphics rung saved: the graphics rows keep their defaults.
assert_eq!(
cvars.get("ambientOcclusion"),
@@ -3154,20 +3176,25 @@ mod tests {
for seeded in [false, true] {
for flush_preset_first in [false, true] {
let mut app = cvar_app();
app.world_mut().non_send_resource_mut::<UiScript>()
app.world_mut()
.non_send_resource_mut::<UiScript>()
.register_cvars(registered_pairs());
// Run the production Update schedule without Startup's on-disk config load.
if seeded {
app.world_mut().run_schedule(Update);
}
app.world_mut().non_send_resource_mut::<UiScript>()
.run("SetCVar('lightingQuality', 'Low')").unwrap();
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("SetCVar('lightingQuality', 'Low')")
.unwrap();
if flush_preset_first {
app.world_mut().run_schedule(Update);
assert_eq!(res::<VideoConfig>(&app).shadow_map_size, 1024);
}
app.world_mut().non_send_resource_mut::<UiScript>()
.run("SetCVar('shadowMapSize', '4096')").unwrap();
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("SetCVar('shadowMapSize', '4096')")
.unwrap();
for _ in 0..3 {
app.world_mut().run_schedule(Update);
let cvars = res::<Cvars>(&app);
@@ -3176,7 +3203,10 @@ mod tests {
assert_eq!(res::<VideoConfig>(&app).shadow_map_size, 4096);
let script = app.world().non_send_resource::<UiScript>();
assert_eq!(script.cvar("shadowMapSize").as_deref(), Some("4096"));
assert_eq!(script.cvar("lightingQuality").as_deref(), Some(LIGHTING_CUSTOM));
assert_eq!(
script.cvar("lightingQuality").as_deref(),
Some(LIGHTING_CUSTOM)
);
}
}
}
@@ -3199,7 +3229,12 @@ mod tests {
#[test]
fn water_and_lava_observers_clamp_and_water_high_is_opt_in() {
let mut app = cvar_app();
for (value, water, lava) in [("-1", 0, 0.0), ("1.75", 1, 1.75), ("2", 2, 2.0), ("9", 2, 4.0)] {
for (value, water, lava) in [
("-1", 0, 0.0),
("1.75", 1, 1.75),
("2", 2, 2.0),
("9", 2, 4.0),
] {
apply(&mut app, "waterQuality", value);
apply(&mut app, "lavaLightGain", value);
assert_eq!(res::<VideoConfig>(&app).water_quality, water);
@@ -3572,7 +3607,11 @@ mod tests {
let player: Vec<&String> = back
.cvars
.keys()
.filter(|k| !GRAPHICS_PRESETS.iter().any(|(g, _)| g.eq_ignore_ascii_case(k)))
.filter(|k| {
!GRAPHICS_PRESETS
.iter()
.any(|(g, _)| g.eq_ignore_ascii_case(k))
})
.collect();
assert_eq!(player.len(), 1, "a diff, not a dump: {text}");
assert!(!text.contains("graphicsQuality"), "{text}");
@@ -4176,7 +4215,10 @@ mod tests {
.unwrap();
let _h = EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap());
// MONKEY (reviewfix-a): the fixture is named by its own variable, not BENILLA_HOME.
let _x = EnvGuard::set("WOW_CAPTURE_CVARS", tmp.join("config.toml").to_str().unwrap());
let _x = EnvGuard::set(
"WOW_CAPTURE_CVARS",
tmp.join("config.toml").to_str().unwrap(),
);
let _c = EnvGuard::set("WOW_CAPTURE", "post-lava-searing");
assert_eq!(boot_cvar("bloom").as_deref(), Some("0"));
@@ -4313,7 +4355,10 @@ mod tests {
if k.eq_ignore_ascii_case("lightingQuality") {
continue; // High on that ladder IS the registered defaults (its own test)
}
let row = REGISTERED.iter().find(|r| r.name == *k).expect("governed row registered");
let row = REGISTERED
.iter()
.find(|r| r.name == *k)
.expect("governed row registered");
let declared = SEEDED_DEVIATIONS.iter().find(|(n, _, _)| n == k);
match reference_boot_value(row) {
Some(reference) if !same_value(values[col], reference) => {
@@ -4324,7 +4369,10 @@ mod tests {
values[col]
)
});
assert!(same_value(value, reference), "{k}: declared reference is stale");
assert!(
same_value(value, reference),
"{k}: declared reference is stale"
);
assert!(!why.trim().is_empty(), "{k}: a deviation owes a reason");
}
_ => assert!(
@@ -10,7 +10,9 @@ use benilla_ui::script::UiScript;
use bevy_egui::egui;
use crate::cvars::REGISTERED;
use crate::shadow_core::{CASTER_REACH_RANGE, MAX_SHADOW_RATE, SHADOW_DISTANCE_RANGE, SHADOW_MAP_SIZES};
use crate::shadow_core::{
CASTER_REACH_RANGE, MAX_SHADOW_RATE, SHADOW_DISTANCE_RANGE, SHADOW_MAP_SIZES,
};
use crate::video::VideoConfig;
enum Widget {
@@ -34,10 +36,20 @@ struct Knob {
// the descriptor read-only without inventing a parallel configuration type.
macro_rules! knob {
($name:literal, $field:ident, $widget:expr, $help:literal) => {
Knob { name: $name, read: |v| v.$field as f32, widget: $widget, help: $help }
Knob {
name: $name,
read: |v| v.$field as f32,
widget: $widget,
help: $help,
}
};
(flag $name:literal, $field:ident, $help:literal) => {
Knob { name: $name, read: |v| v.$field as u32 as f32, widget: Widget::Toggle, help: $help }
Knob {
name: $name,
read: |v| v.$field as u32 as f32,
widget: Widget::Toggle,
help: $help,
}
};
}
@@ -46,67 +58,213 @@ use Widget::{Choice, Integer, Slider};
// This developer-only panel intentionally keeps its labels and help text in English.
const INTERIOR: &[Knob] = &[
knob!(flag "interiorLight", interior_light, "Fixture-lit interiors; off restores the baked path."),
knob!("interiorGain", interior_gain, Slider(0.2..=1.5), "Overall room input brightness."),
knob!("interiorAmbient", interior_ambient, Slider(0.0..=1.0), "Base room ambient."),
knob!("interiorFill", interior_fill, Slider(0.0..=2.0), "Per-fixture bounce gain."),
knob!(
"interiorGain",
interior_gain,
Slider(0.2..=1.5),
"Overall room input brightness."
),
knob!(
"interiorAmbient",
interior_ambient,
Slider(0.0..=1.0),
"Base room ambient."
),
knob!(
"interiorFill",
interior_fill,
Slider(0.0..=2.0),
"Per-fixture bounce gain."
),
// MONKEY (bake floor): index 4, immediately after fill, because the three presets below take
// `INTERIOR[1..5]` as one group — this dial belongs with gain/ambient/fill, not with the
// exposure and reach knobs the presets deliberately leave alone.
knob!("interiorBakeFloor", interior_bake_floor, Slider(0.0..=1.0), "Share of a room's baked light kept where no fixture reaches; 0 = fixtures only."),
knob!("interiorExposure", interior_exposure, Slider(0.25..=8.0), "Light budget before soft rolloff."),
knob!("interiorAttenScale", interior_atten_scale, Slider(0.0..=8.0), "Pool radius scale; 0 disables the authored window."),
knob!("interiorDaylight", interior_daylight, Slider(0.0..=1.0), "Sun-driven day floor in enclosed rooms."),
knob!(
"interiorBakeFloor",
interior_bake_floor,
Slider(0.0..=1.0),
"Share of a room's baked light kept where no fixture reaches; 0 = fixtures only."
),
knob!(
"interiorExposure",
interior_exposure,
Slider(0.25..=8.0),
"Light budget before soft rolloff."
),
knob!(
"interiorAttenScale",
interior_atten_scale,
Slider(0.0..=8.0),
"Pool radius scale; 0 disables the authored window."
),
knob!(
"interiorDaylight",
interior_daylight,
Slider(0.0..=1.0),
"Sun-driven day floor in enclosed rooms."
),
knob!(flag "interiorRoomGate", interior_room_gate, "Restrict fixtures to the rooms they claim."),
];
const GROUPS: &[(&str, &[Knob])] = &[
("Water", &[
knob!("waterQuality", water_quality, Choice(&[0, 1, 2]), "0 Classic, 1 Enhanced, 2 High; High adds mirror reflections and costs the most."),
]),
(
"Water",
&[knob!(
"waterQuality",
water_quality,
Choice(&[0, 1, 2]),
"0 Classic, 1 Enhanced, 2 High; High adds mirror reflections and costs the most."
)],
),
("Interior light", INTERIOR),
("Fire", &[
knob!("fireLightGain", fire_light_gain, Slider(0.0..=4.0), "Brightness of lights synthesized from flame emitters."),
// MONKEY (spellLightGain): in the "Fire" group because a spell light IS one of the invented
// sources this group tunes — it is just the one whose brightness is a combat setting rather
// than a scenery one, which is exactly why it needs its own dial and not the one above it.
knob!("spellLightGain", spell_light_gain, Slider(0.0..=4.0), "Brightness of spell, missile and impact lights."),
knob!("lavaLightGain", lava_light_gain, Slider(0.0..=4.0), "How strongly lava lights its surroundings; 0 turns the glow off."),
knob!("fireFlicker", fire_flicker, Slider(0.0..=2.0), "0 steady, 1 default, 2 pronounced."),
]),
("Torch shadows", &[
knob!(flag "interiorShadows", interior_shadows, "Interior fixture shadows; requires interiorLight."),
knob!(flag "exteriorShadows", exterior_shadows, "Outdoor fire shadows at night."),
knob!("interiorShadowCasters", interior_shadow_casters, Integer(1.0..=16.0), "Resident fixture shadow maps."),
knob!("interiorShadowDynamic", interior_shadow_dynamic, Integer(1.0..=crate::torch_shadow::MAX_TORCH_DYNAMIC as f32), "Nearest fixtures with moving entity shadows."),
knob!("interiorShadowSoft", interior_shadow_soft, Slider(0.5..=3.0), "Shadow edge softness at a contact; the penumbra grows from it."),
knob!("torchShadowStrength", torch_shadow_strength, Slider(0.0..=1.0), "Shadow darkness: how much direct light a shadow removes. 1 = black."),
knob!("interiorShadowEntityRate", interior_shadow_entity_rate, Integer(0.0..=240.0), "Moving caster regather Hz; 0 = every frame."),
]),
("Sun shadows", &[
knob!(flag "characterShadows", character_shadows, "Realtime character silhouettes instead of blobs."),
knob!(flag "worldShadows", world_shadows, "Realtime static-world shadows instead of baked terrain shadows."),
knob!("shadowDistance", shadow_distance, Slider(SHADOW_DISTANCE_RANGE), "Shadow distance in yards."),
knob!("shadowMapSize", shadow_map_size, Choice(&SHADOW_MAP_SIZES), "Map edge in texels; larger maps cost more GPU time and memory."),
knob!("shadowFilter", shadow_filter, Widget::Toggle, "0 hardware 2x2, 1 Gaussian PCF."),
knob!("characterShadowRate", character_shadow_rate, Integer(0.0..=MAX_SHADOW_RATE as f32), "Character shadow update Hz; 0 = every frame."),
knob!("worldShadowRate", world_shadow_rate, Integer(0.0..=MAX_SHADOW_RATE as f32), "World shadow update Hz; 0 = every frame."),
knob!("shadowCasterReach", shadow_caster_reach, Slider(CASTER_REACH_RANGE), "Caster collection reach multiplier."),
]),
("Night", &[
knob!("nightGain", night_gain, Slider(0.2..=1.5), "Exterior night brightness; inert by day."),
// MONKEY (moon shadows): in "Night" rather than beside the sun dials, because that is
// where both its cost and its taste question live — it is inert by day, and what it buys
// is "the world is not flat after dark" for a second shadowed directional term.
knob!("moonShadowStrength", moon_shadow_strength, Slider(0.0..=1.0), "How dark a moon-shadowed fragment gets at night; 0 = no moon shadow."),
]),
("Debug", &[
knob!("interiorDebug", interior_debug, Choice(&[0, 1, 2, 3, 4]), "0 off, 1 classification, 2 shadow, 3 caster count, 4 WMO lane map."),
]),
(
"Fire",
&[
knob!(
"fireLightGain",
fire_light_gain,
Slider(0.0..=4.0),
"Brightness of lights synthesized from flame emitters."
),
// MONKEY (spellLightGain): in the "Fire" group because a spell light IS one of the invented
// sources this group tunes — it is just the one whose brightness is a combat setting rather
// than a scenery one, which is exactly why it needs its own dial and not the one above it.
knob!(
"spellLightGain",
spell_light_gain,
Slider(0.0..=4.0),
"Brightness of spell, missile and impact lights."
),
knob!(
"lavaLightGain",
lava_light_gain,
Slider(0.0..=4.0),
"How strongly lava lights its surroundings; 0 turns the glow off."
),
knob!(
"fireFlicker",
fire_flicker,
Slider(0.0..=2.0),
"0 steady, 1 default, 2 pronounced."
),
],
),
(
"Torch shadows",
&[
knob!(flag "interiorShadows", interior_shadows, "Interior fixture shadows; requires interiorLight."),
knob!(flag "exteriorShadows", exterior_shadows, "Outdoor fire shadows at night."),
knob!(
"interiorShadowCasters",
interior_shadow_casters,
Integer(1.0..=16.0),
"Resident fixture shadow maps."
),
knob!(
"interiorShadowDynamic",
interior_shadow_dynamic,
Integer(1.0..=crate::torch_shadow::MAX_TORCH_DYNAMIC as f32),
"Nearest fixtures with moving entity shadows."
),
knob!(
"interiorShadowSoft",
interior_shadow_soft,
Slider(0.5..=3.0),
"Shadow edge softness at a contact; the penumbra grows from it."
),
knob!(
"torchShadowStrength",
torch_shadow_strength,
Slider(0.0..=1.0),
"Shadow darkness: how much direct light a shadow removes. 1 = black."
),
knob!(
"interiorShadowEntityRate",
interior_shadow_entity_rate,
Integer(0.0..=240.0),
"Moving caster regather Hz; 0 = every frame."
),
],
),
(
"Sun shadows",
&[
knob!(flag "characterShadows", character_shadows, "Realtime character silhouettes instead of blobs."),
knob!(flag "worldShadows", world_shadows, "Realtime static-world shadows instead of baked terrain shadows."),
knob!(
"shadowDistance",
shadow_distance,
Slider(SHADOW_DISTANCE_RANGE),
"Shadow distance in yards."
),
knob!(
"shadowMapSize",
shadow_map_size,
Choice(&SHADOW_MAP_SIZES),
"Map edge in texels; larger maps cost more GPU time and memory."
),
knob!(
"shadowFilter",
shadow_filter,
Widget::Toggle,
"0 hardware 2x2, 1 Gaussian PCF."
),
knob!(
"characterShadowRate",
character_shadow_rate,
Integer(0.0..=MAX_SHADOW_RATE as f32),
"Character shadow update Hz; 0 = every frame."
),
knob!(
"worldShadowRate",
world_shadow_rate,
Integer(0.0..=MAX_SHADOW_RATE as f32),
"World shadow update Hz; 0 = every frame."
),
knob!(
"shadowCasterReach",
shadow_caster_reach,
Slider(CASTER_REACH_RANGE),
"Caster collection reach multiplier."
),
],
),
(
"Night",
&[
knob!(
"nightGain",
night_gain,
Slider(0.2..=1.5),
"Exterior night brightness; inert by day."
),
// MONKEY (moon shadows): in "Night" rather than beside the sun dials, because that is
// where both its cost and its taste question live — it is inert by day, and what it buys
// is "the world is not flat after dark" for a second shadowed directional term.
knob!(
"moonShadowStrength",
moon_shadow_strength,
Slider(0.0..=1.0),
"How dark a moon-shadowed fragment gets at night; 0 = no moon shadow."
),
],
),
(
"Debug",
&[knob!(
"interiorDebug",
interior_debug,
Choice(&[0, 1, 2, 3, 4]),
"0 off, 1 classification, 2 shadow, 3 caster count, 4 WMO lane map."
)],
),
];
fn reset(script: &mut UiScript, knobs: &[Knob]) {
for knob in knobs {
let registered = REGISTERED.iter().find(|r| r.name == knob.name)
let registered = REGISTERED
.iter()
.find(|r| r.name == knob.name)
.expect("lighting panel knob must be registered");
script.set_cvar_engine(registered.name, registered.default);
}
@@ -117,35 +275,40 @@ fn row(ui: &mut egui::Ui, video: &VideoConfig, script: &mut UiScript, knob: &Kno
// MONKEY (lighting debug panel): stack the label over numeric controls so even the longest
// CVar fits the existing 280 px panel. Sliders retain their editable current-value readout;
// integer knobs cannot queue fractional counts/rates, and flags explicitly show their 0/1.
let changed = ui.push_id(knob.name, |ui| match &knob.widget {
Widget::Toggle => {
let mut enabled = value != 0.0;
let response = ui.checkbox(&mut enabled, format!("{} {}", knob.name, value as u32))
.on_hover_text(knob.help);
value = enabled as u32 as f32;
response.changed()
}
Slider(range) | Integer(range) => {
ui.label(knob.name).on_hover_text(knob.help);
let mut slider = egui::Slider::new(&mut value, range.clone());
if matches!(knob.widget, Integer(_)) {
slider = slider.integer();
let changed = ui
.push_id(knob.name, |ui| match &knob.widget {
Widget::Toggle => {
let mut enabled = value != 0.0;
let response = ui
.checkbox(&mut enabled, format!("{} {}", knob.name, value as u32))
.on_hover_text(knob.help);
value = enabled as u32 as f32;
response.changed()
}
ui.add(slider).on_hover_text(knob.help).changed()
}
Choice(options) => {
ui.label(knob.name).on_hover_text(knob.help);
let mut changed = false;
egui::ComboBox::from_id_salt("value")
.selected_text((value as u32).to_string())
.show_ui(ui, |ui| {
for &option in *options {
changed |= ui.selectable_value(&mut value, option as f32, option.to_string()).changed();
}
});
changed
}
}).inner;
Slider(range) | Integer(range) => {
ui.label(knob.name).on_hover_text(knob.help);
let mut slider = egui::Slider::new(&mut value, range.clone());
if matches!(knob.widget, Integer(_)) {
slider = slider.integer();
}
ui.add(slider).on_hover_text(knob.help).changed()
}
Choice(options) => {
ui.label(knob.name).on_hover_text(knob.help);
let mut changed = false;
egui::ComboBox::from_id_salt("value")
.selected_text((value as u32).to_string())
.show_ui(ui, |ui| {
for &option in *options {
changed |= ui
.selectable_value(&mut value, option as f32, option.to_string())
.changed();
}
});
changed
}
})
.inner;
if changed {
script.set_cvar_engine(knob.name, &value.to_string());
}
@@ -162,8 +325,9 @@ pub(super) fn section(ui: &mut egui::Ui, video: &VideoConfig, script: Option<&mu
// off the VM mirror rather than derived locally, because the registry's own per-frame
// derivation is the one authority on it and a second derivation would be a second opinion.
ui.horizontal(|ui| {
ui.label("Lighting quality")
.on_hover_text("Writes the member rows below; reads back Custom when they match no rung.");
ui.label("Lighting quality").on_hover_text(
"Writes the member rows below; reads back Custom when they match no rung.",
);
let current = script.cvar("lightingQuality").unwrap_or_default();
for (name, _) in crate::cvars::LIGHTING_PRESETS {
if ui
@@ -191,7 +355,13 @@ pub(super) fn section(ui: &mut egui::Ui, video: &VideoConfig, script: Option<&mu
("Default", None),
("Bright", Some(["1.0", "0.04", "0.16", "0.2"])),
] {
if ui.small_button(label).on_hover_text("Only gain, ambient, fill and bake floor; other settings stay as they are.").clicked() {
if ui
.small_button(label)
.on_hover_text(
"Only gain, ambient, fill and bake floor; other settings stay as they are.",
)
.clicked()
{
if let Some(values) = values {
for (knob, value) in INTERIOR[1..5].iter().zip(values) {
script.set_cvar_engine(knob.name, value);
@@ -202,7 +372,11 @@ pub(super) fn section(ui: &mut egui::Ui, video: &VideoConfig, script: Option<&mu
}
}
});
if ui.small_button("Reset all").on_hover_text("Restore registry defaults for every group in Lighting & shadows.").clicked() {
if ui
.small_button("Reset all")
.on_hover_text("Restore registry defaults for every group in Lighting & shadows.")
.clicked()
{
for (_, knobs) in GROUPS {
reset(script, knobs);
}
+10 -3
View File
@@ -26,7 +26,8 @@
//! Water quality and lava glow use the same guarded bridge into their renderer resources.
use benilla_assets::WaterQuality;
use benilla_world::lighting::{
DynamicInteriors, EmissiveTier, FireLightGain, LavaLightGain, MoonShadowStrength, SpellLightGain,
DynamicInteriors, EmissiveTier, FireLightGain, LavaLightGain, MoonShadowStrength,
SpellLightGain,
};
use bevy::prelude::*;
@@ -166,8 +167,14 @@ mod tests {
.init_resource::<LavaLightGain>()
.init_resource::<EmissiveTier>()
.add_plugins(DynamicInteriorPlugin);
assert_eq!(app.world().resource::<WaterQuality>().0, VideoConfig::default().water_quality);
assert_eq!(app.world().resource::<LavaLightGain>().0, VideoConfig::default().lava_light_gain);
assert_eq!(
app.world().resource::<WaterQuality>().0,
VideoConfig::default().water_quality
);
assert_eq!(
app.world().resource::<LavaLightGain>().0,
VideoConfig::default().lava_light_gain
);
app.update();
for (water, lava) in [(0, 0.0), (2, 3.25), (1, 1.0)] {
{
@@ -94,11 +94,14 @@ pub(super) fn resolve_char_look(
None
}
/// The `mpq://` URL of a baked NPC atlas under `Textures\BakedNpcTextures\`.
/// The `mpq://` URL of a baked NPC atlas: [`benilla_formats::baked_atlas_path`], as a lowercase
/// forward-slash URL (the reference appends `.blp` to a bake name that lacks it).
fn baked_npc_url(bake_name: &str) -> String {
format!(
"mpq://textures/bakednpctextures/{}",
bake_name.replace('\\', "/").to_ascii_lowercase()
"mpq://{}",
benilla_formats::baked_atlas_path(bake_name)
.replace('\\', "/")
.to_ascii_lowercase()
)
}
@@ -396,3 +399,27 @@ pub(super) fn build_char_skin_materials(
(body, hair, object, skin_extra)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn baked_npc_url_appends_the_extension_a_bare_bake_name_lacks() {
// A shipped bake name already carries `.blp`.
assert_eq!(
baked_npc_url("Northwindbailiff.blp"),
"mpq://textures/bakednpctextures/northwindbailiff.blp"
);
// A bare bake name (TurtleWoW stores these) resolves once `.blp` is appended, which also
// gives the loader the extension it keys on.
assert_eq!(
baked_npc_url("Northwindbutcher"),
"mpq://textures/bakednpctextures/northwindbutcher.blp"
);
assert_eq!(
baked_npc_url("kultirasrepresentive"),
"mpq://textures/bakednpctextures/kultirasrepresentive.blp"
);
}
}
@@ -744,9 +744,13 @@ pub(super) fn attach_entity_visuals(
// stands still, so it stays eligible. `net.kind` is the only place the two are told
// apart: downstream both are just a `PointLight` under a `ChildOf`.
let body_carried = !matches!(net.kind, EntityKind::GameObject);
super::spawn_carried_lights(&mut commands, model_lights, entity, body_carried, |bone| {
light_anchors.get(&bone).copied()
});
super::spawn_carried_lights(
&mut commands,
model_lights,
entity,
body_carried,
|bone| light_anchors.get(&bone).copied(),
);
// Ribbon trails, on the same host-bone ride; each despawns with its owner.
{
for rb in dm.map(|d| d.ribbons.as_slice()).unwrap_or_default() {
@@ -458,7 +458,11 @@ pub(crate) fn track_carried_light_motion(
time: Res<Time>,
mut lights: Query<
(Entity, &GlobalTransform, Option<&mut CarriedLightMotion>),
(With<WorldPointLight>, With<ChildOf>, Without<ShadowProxyLight>),
(
With<WorldPointLight>,
With<ChildOf>,
Without<ShadowProxyLight>,
),
>,
) {
let dt = time.delta_secs();
@@ -856,7 +860,10 @@ pub(crate) fn claim_carried_light_rooms(
if had_room != Some(want) {
match want {
Some(room) => {
e.insert(LightRooms::new(WmoGroupVis::single(room.instance, room.group)));
e.insert(LightRooms::new(WmoGroupVis::single(
room.instance,
room.group,
)));
}
// Back outdoors (or the anchor went away): drop the claim rather than leave a
// stale room, which would gate the light on a building it has walked out of.
@@ -882,7 +889,11 @@ pub(crate) fn claim_carried_light_rooms(
match claimed.as_mut() {
Some(c) => {
let (applied, lane) = (c.applied, c.lane);
c.set_if_neq(ClaimedRoom { applied, seen, lane });
c.set_if_neq(ClaimedRoom {
applied,
seen,
lane,
});
c.applied = want;
c.lane = want_lane;
}
@@ -962,12 +973,7 @@ mod tests {
let mut spawned: Vec<(Entity, Vec3, Entity)> = app
.world_mut()
.query::<(
Entity,
&WorldPointLight,
&Transform,
&ChildOf,
)>()
.query::<(Entity, &WorldPointLight, &Transform, &ChildOf)>()
.iter(app.world())
.map(|(e, _, t, c)| (e, t.translation, c.parent()))
.collect();
@@ -1112,8 +1118,10 @@ mod tests {
assert!(settled(&app, light), "held still past STILL_HOLD");
// One stride and it is disqualified again, with no credit for the second it banked.
*app.world_mut().entity_mut(light).get_mut::<GlobalTransform>().unwrap() =
GlobalTransform::from_translation(Vec3::new(1.0, 0.0, 0.0));
*app.world_mut()
.entity_mut(light)
.get_mut::<GlobalTransform>()
.unwrap() = GlobalTransform::from_translation(Vec3::new(1.0, 0.0, 0.0));
app.world_mut()
.resource_mut::<Time>()
.advance_by(Duration::from_secs_f32(1.0 / 60.0));
@@ -1158,10 +1166,7 @@ mod tests {
!held.fresh(held.at, 8, room(3)),
"a placement streamed in or out: the claim's instance key may be stale"
);
assert!(
!held.fresh(held.at, 7, room(4)),
"the bearer changed room"
);
assert!(!held.fresh(held.at, 7, room(4)), "the bearer changed room");
assert!(
!held.fresh(held.at, 7, None),
"the bearer left the building"
+1 -7
View File
@@ -352,13 +352,7 @@ pub(super) fn attach_ground_fx_models(
plan.school,
dm.lights.iter().find_map(|l| l.spell.map(|fx| fx.kind)),
);
super::spawn_area_spell_light(
&mut commands,
&dm.lights,
kind,
anchor,
plan.radius,
);
super::spawn_area_spell_light(&mut commands, &dm.lights, kind, anchor, plan.radius);
continue; // no one-shot clock and no flash — the area object owns both ends
}
// MONKEY (spell light): the IMPACT flash. A dest-anchored effect is the one lane whose
@@ -543,10 +543,17 @@ mod tests {
assert_eq!(at(kit(), 0.0), 0.0, "nothing on the spawn frame");
assert!((at(kit(), SPELL_LIGHT_RAMP * 0.5) - 0.5).abs() < 1e-3);
assert_eq!(at(kit(), SPELL_LIGHT_RAMP), 1.0);
assert_eq!(at(kit(), 30.0), 1.0, "an aura holds for as long as it stands");
assert_eq!(
at(kit(), 30.0),
1.0,
"an aura holds for as long as it stands"
);
// A missile is the same minus any ending of its own — the arrival despawn is the ending.
assert_eq!(at(SpellLight::new(1.0, 0.0, SpellLightMode::Missile), 5.0), 1.0);
assert_eq!(
at(SpellLight::new(1.0, 0.0, SpellLightMode::Missile), 5.0),
1.0
);
// A burst peaks at the ramp's end and is gone `span` later; the plateau is real, not a
// one-frame spike a low frame rate could step over.
@@ -586,8 +593,7 @@ mod tests {
// The swell is a factor on the WHOLE envelope, ramp included — it is what the light is
// doing, not a decoration on the hold — so the expected values carry it.
let swell = |t: f32| {
1.0 + SPELL_AREA_BREATH_DEPTH
* (std::f32::consts::TAU * SPELL_AREA_BREATH_HZ * t).sin()
1.0 + SPELL_AREA_BREATH_DEPTH * (std::f32::consts::TAU * SPELL_AREA_BREATH_HZ * t).sin()
};
// The ramp is the AREA one — at the flash ramp's 0.1 s an area light is well short of up.
@@ -600,14 +606,15 @@ mod tests {
// that it is at the top of the swell, and it never wanders outside ±depth. (Every mark
// below is past the ramp, so `up` is 1 and the swell is the whole of the value.)
let period = 1.0 / SPELL_AREA_BREATH_HZ;
assert!((at(area(), period) - 1.0).abs() < 1e-3, "one period, back to base");
assert!(
(at(area(), period) - 1.0).abs() < 1e-3,
"one period, back to base"
);
assert!(
(at(area(), period * 1.25) - (1.0 + SPELL_AREA_BREATH_DEPTH)).abs() < 1e-3,
"the swell is centred on the base, not clamped below it"
);
assert!(
(at(area(), period * 1.75) - (1.0 - SPELL_AREA_BREATH_DEPTH)).abs() < 1e-3
);
assert!((at(area(), period * 1.75) - (1.0 - SPELL_AREA_BREATH_DEPTH)).abs() < 1e-3);
for step in 0..200 {
let v = at(area(), 1.0 + step as f32 * 0.05);
assert!(
+3 -3
View File
@@ -66,7 +66,7 @@ mod sky_quality;
// MONKEY (ao): optional screen-space ambient occlusion plugin.
mod ssao;
// MONKEY (skybox): the zoneSkyboxes cvar bridge.
mod zone_skybox;
mod character_shadow;
mod entities;
mod fishing_line;
mod footprints;
@@ -89,7 +89,6 @@ mod npc_text;
mod pending_item_ops;
/// Ships in part (the FPS journal and the clocks it reads); the rest is `dev`.
mod perf;
mod character_shadow;
mod pipe_warm;
mod player;
mod poi_marker;
@@ -109,6 +108,7 @@ mod screen_fade;
mod screenshot;
mod shaders;
mod shadow_core;
mod zone_skybox;
mod game_tip;
mod name_persist;
@@ -129,6 +129,7 @@ pub(crate) mod test_support;
mod text_filter;
mod text_reshape;
mod textinput;
mod torch_shadow;
mod transport;
mod tutorial;
mod ui_action;
@@ -200,7 +201,6 @@ mod ui_unit;
mod ui_world_map;
mod video;
mod vplates;
mod torch_shadow;
mod weapon_trail;
mod world_backdrop;
mod world_shadow;
+5 -3
View File
@@ -12,9 +12,9 @@
use benilla_world::ffx_glow::SkyDither;
use benilla_world::lighting::FogModelSetting;
use benilla_world::weather::RainSurfaces;
use benilla_world::wind::{FoliageWind, FoliageWindStrength};
use bevy::prelude::*;
use benilla_world::weather::RainSurfaces; // MONKEY (wet)
use bevy::prelude::*; // MONKEY (wet)
/// The programme's cvar observer (registered by [`MonkeyGfxPlugin`]).
pub(crate) fn on_cvar(
@@ -37,7 +37,9 @@ pub(crate) fn on_cvar(
"foliagewind" => {
// Capture-only A/B pin (MONKEY reviewfix-a: gated like `capture_daylight` — dev
// build AND `WOW_CAPTURE`). Player runs obey the live CVar exactly.
let requested = foliage_wind_pin().map(f32::from).unwrap_or_else(|| ev.num());
let requested = foliage_wind_pin()
.map(f32::from)
.unwrap_or_else(|| ev.num());
let want = requested.clamp(0.0, 2.0) as u8;
if foliage_wind.0 != want {
foliage_wind.0 = want;
@@ -605,7 +605,10 @@ pub(super) fn spawn_menagerie(
solid_mesh.insert_indices(Indices::U32(vec![0, 1, 2]));
let mut cutout_mesh = crate::shadow_core::empty_cutout_mesh();
cutout_mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, tri.to_vec());
cutout_mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, vec![[0.0f32, 0.0], [1.0, 0.0], [1.0, 1.0]]);
cutout_mesh.insert_attribute(
Mesh::ATTRIBUTE_UV_0,
vec![[0.0f32, 0.0], [1.0, 0.0], [1.0, 1.0]],
);
cutout_mesh.insert_indices(Indices::U32(vec![0, 1, 2]));
if let Some(store) = lanes.shadow_solid.as_mut() {
let solid = store.add(crate::shadow_core::ShadowCasterMaterial {});
+5 -7
View File
@@ -336,12 +336,7 @@ fn update_views(
night,
prev_day,
prev_night,
control: Vec4::new(
strength,
night_weight(minute),
blend.fade,
prev_strength,
),
control: Vec4::new(strength, night_weight(minute), blend.fade, prev_strength),
})
});
for (entity, camera, old) in &cameras {
@@ -362,7 +357,10 @@ fn update_views(
/// MONKEY (reviewfix-a): the server's fractional minute while it is the minute being rendered,
/// else the rendered (manual or capture) minute.
pub(crate) fn rendered_minute(time: &WorldTime, rendered: Option<&benilla_world::lighting::GameClock>) -> f32 {
pub(crate) fn rendered_minute(
time: &WorldTime,
rendered: Option<&benilla_world::lighting::GameClock>,
) -> f32 {
match rendered {
Some(c) if time.minute_f.floor() as u32 != c.minute => c.minute as f32,
_ => time.minute_f,
+8 -2
View File
@@ -164,7 +164,10 @@ fn update_views(
fn horizon_row(transform: &GlobalTransform, projection: &Projection) -> (Vec4, Vec4) {
let Projection::Perspective(p) = projection else {
// No horizon test: `(0, 0, 1)` is always "up".
return (Vec4::new(0.0, 0.0, 1.0, -2.0), Vec4::new(0.0, 0.0, 0.0, 0.0));
return (
Vec4::new(0.0, 0.0, 1.0, -2.0),
Vec4::new(0.0, 0.0, 0.0, 0.0),
);
};
let ty = (p.fov * 0.5).tan();
let tx = ty * p.aspect_ratio;
@@ -510,6 +513,9 @@ mod tests {
let down = Transform::default().looking_to(Vec3::new(1.0, -0.3, 0.0), Vec3::Y);
let dir = Vec3::new(1.0, -0.3, 0.0).normalize();
assert!((up(&down, Vec2::ZERO) - dir.y).abs() < 1e-5);
assert!(up(&down, Vec2::ZERO) < -0.01, "sea below the horizon is not sky");
assert!(
up(&down, Vec2::ZERO) < -0.01,
"sea below the horizon is not sky"
);
}
}
+134 -49
View File
@@ -26,8 +26,8 @@
//! corruption seen during the first experiment. Only the private-layer proxy casts.
use bevy::asset::RenderAssetUsages;
use bevy::ecs::entity::EntityHashSet;
use bevy::camera::visibility::{NoFrustumCulling, RenderLayers};
use bevy::ecs::entity::EntityHashSet;
use bevy::light::{
CascadeShadowConfigBuilder, DirectionalLight, DirectionalLightShadowMap, NotShadowReceiver,
ShadowFilteringMethod,
@@ -50,8 +50,8 @@ use benilla_world::interact::PickMesh;
// this one directional light is aimed at. Imported rather than mirrored so the aim and the packed
// weight can never disagree about which map the receivers are reading.
use benilla_world::lighting::{
moon_shadow_weight, sun_shadow_strength, MoonShadowStrength, ShadowBody, ShadowHandover,
ShadowDistance, ShadowFilterGaussian, WowLighting,
moon_shadow_weight, sun_shadow_strength, MoonShadowStrength, ShadowBody, ShadowDistance,
ShadowFilterGaussian, ShadowHandover, WowLighting,
};
use benilla_world::model_render::{ModelKind, ModelPart, ShadowOccluder};
use benilla_world::rig_palette::{RigPalettes, RigPart, RigSkin};
@@ -129,7 +129,10 @@ pub(crate) const CASTER_REACH_RANGE: std::ops::RangeInclusive<f32> = 0.25..=2.0;
/// Snap a requested `shadowMapSize` onto [`SHADOW_MAP_SIZES`] — nearest in LOG space, so 1500 lands
/// on 1024 and 3000 on 4096 (halfway in ratio, not in texels, is what "one step" means here).
pub(crate) fn clamp_shadow_map_size(asked: u32) -> u32 {
let asked = asked.clamp(SHADOW_MAP_SIZES[0], SHADOW_MAP_SIZES[SHADOW_MAP_SIZES.len() - 1]);
let asked = asked.clamp(
SHADOW_MAP_SIZES[0],
SHADOW_MAP_SIZES[SHADOW_MAP_SIZES.len() - 1],
);
*SHADOW_MAP_SIZES
.iter()
.min_by(|a, b| {
@@ -164,7 +167,9 @@ impl RebuildRate {
}
let interval = 1.0 / rate as f32;
// `now < last` (a time reset) is treated as due rather than as a very long wait.
let due = self.last.is_none_or(|last| now - last >= interval || now < last);
let due = self
.last
.is_none_or(|last| now - last >= interval || now < last);
if due {
self.last = Some(now);
}
@@ -252,11 +257,10 @@ fn shadow_sun_travel(to_sun: Vec3) -> Vec3 {
} else {
Vec3::NEG_Z
};
let elevation = s
.y
.clamp(-1.0, 1.0)
.asin()
.clamp(MIN_SHADOW_SUN_ELEVATION, MAX_SHADOW_SUN_ELEVATION);
let elevation =
s.y.clamp(-1.0, 1.0)
.asin()
.clamp(MIN_SHADOW_SUN_ELEVATION, MAX_SHADOW_SUN_ELEVATION);
let to_sun_clamped = bearing * elevation.cos() + Vec3::Y * elevation.sin();
-to_sun_clamped
}
@@ -359,24 +363,27 @@ impl Plugin for ShadowCorePlugin {
app.insert_resource(DirectionalLightShadowMap {
size: DEFAULT_SHADOW_MAP_SIZE as usize,
})
.add_plugins(MaterialPlugin::<ShadowCasterMaterial>::default())
.init_resource::<ShadowRigState>()
.init_resource::<ShadowDemand>()
.init_resource::<ShadowFrame>()
// The rig runs before the lanes every frame; the lanes read the frame it publishes.
// MONKEY (moon shadows): after ALL Update work (lighting resolve + strength bridge),
// before this frame's transform propagation and light-buffer packing. Last was too
// late: a time jump had already packed the new body's weight over the old basis.
.configure_sets(PostUpdate, ShadowSet::Rig.before(bevy::transform::TransformSystems::Propagate))
// MONKEY (sun shadow perf): the quality dials land BEFORE the rig each frame, and
// unconditionally — a `shadowMapSize` write must take even while the lanes are off, so
// turning shadows back on doesn't render one frame at the previous size.
.add_systems(
PostUpdate,
(apply_shadow_quality, manage_rig)
.chain()
.in_set(ShadowSet::Rig),
);
.add_plugins(MaterialPlugin::<ShadowCasterMaterial>::default())
.init_resource::<ShadowRigState>()
.init_resource::<ShadowDemand>()
.init_resource::<ShadowFrame>()
// The rig runs before the lanes every frame; the lanes read the frame it publishes.
// MONKEY (moon shadows): after ALL Update work (lighting resolve + strength bridge),
// before this frame's transform propagation and light-buffer packing. Last was too
// late: a time jump had already packed the new body's weight over the old basis.
.configure_sets(
PostUpdate,
ShadowSet::Rig.before(bevy::transform::TransformSystems::Propagate),
)
// MONKEY (sun shadow perf): the quality dials land BEFORE the rig each frame, and
// unconditionally — a `shadowMapSize` write must take even while the lanes are off, so
// turning shadows back on doesn't render one frame at the previous size.
.add_systems(
PostUpdate,
(apply_shadow_quality, manage_rig)
.chain()
.in_set(ShadowSet::Rig),
);
}
}
@@ -467,7 +474,11 @@ fn manage_rig(
mut commands: Commands,
mut materials: ResMut<Assets<ShadowCasterMaterial>>,
mut cameras: Query<
(Entity, Option<&mut RenderLayers>, Option<&ShadowCameraLayer>),
(
Entity,
Option<&mut RenderLayers>,
Option<&ShadowCameraLayer>,
),
With<WorldCamera>,
>,
mut suns: Query<(&mut Transform, &mut DirectionalLight), With<ShadowSun>>,
@@ -487,9 +498,13 @@ fn manage_rig(
// MONKEY (moon shadows): publish zero immediately on a body change. Only the actual write
// below acknowledges the basis; fresh spawns may need a deferred-command frame to get here.
let sun_w = sun_shadow_strength(lighting.celestial_dir().y);
handover.request(sun_w,
handover.request(
sun_w,
moon_shadow_weight(lighting.celestial_dir().y, lighting.moon_dir().y),
moon_strength.0, wanted, time.delta_secs());
moon_strength.0,
wanted,
time.delta_secs(),
);
frame.suspended = sun_w <= 0.0 && moon_strength.0 <= 0.0;
if !wanted {
@@ -523,9 +538,13 @@ fn manage_rig(
rig.sun = None;
rig.sun_written = None;
*handover = ShadowHandover::default();
handover.request(sun_w,
handover.request(
sun_w,
moon_shadow_weight(lighting.celestial_dir().y, lighting.moon_dir().y),
moon_strength.0, wanted, 0.0);
moon_strength.0,
wanted,
0.0,
);
}
if rig.material.is_none() {
@@ -670,7 +689,12 @@ pub(crate) fn spawn_solid_caster(
/// Append a triangle list, dropping WHOLE triangles whose indices exceed the vertex range (filtering
/// single indices out of a `TriangleList` rewires the rest of the submesh into garbage).
pub(crate) fn append_triangles(source: &[u32], vertex_count: u32, base: u32, indices: &mut Vec<u32>) {
pub(crate) fn append_triangles(
source: &[u32],
vertex_count: u32,
base: u32,
indices: &mut Vec<u32>,
) {
for triangle in source.chunks_exact(3) {
if triangle.iter().all(|index| *index < vertex_count) {
indices.extend(triangle.iter().map(|index| base + *index));
@@ -878,16 +902,46 @@ mod tests {
#[test]
fn each_lane_admits_only_its_own_kinds() {
// The character lane wants creatures (opaque + alpha-test), not environment.
assert!(casts_realtime_shadow(ModelKind::Creature, ModelBlend::Opaque, true, false));
assert!(casts_realtime_shadow(ModelKind::Creature, ModelBlend::AlphaTest, true, false));
assert!(!casts_realtime_shadow(ModelKind::Creature, ModelBlend::Blend, true, false));
assert!(!casts_realtime_shadow(ModelKind::Doodad, ModelBlend::Opaque, true, false));
assert!(casts_realtime_shadow(
ModelKind::Creature,
ModelBlend::Opaque,
true,
false
));
assert!(casts_realtime_shadow(
ModelKind::Creature,
ModelBlend::AlphaTest,
true,
false
));
assert!(!casts_realtime_shadow(
ModelKind::Creature,
ModelBlend::Blend,
true,
false
));
assert!(!casts_realtime_shadow(
ModelKind::Doodad,
ModelBlend::Opaque,
true,
false
));
// The world lane wants environment (opaque only), not creatures.
for kind in [ModelKind::GameObject, ModelKind::Doodad, ModelKind::Wmo] {
assert!(casts_realtime_shadow(kind, ModelBlend::Opaque, false, true));
assert!(!casts_realtime_shadow(kind, ModelBlend::AlphaTest, false, true));
assert!(!casts_realtime_shadow(
kind,
ModelBlend::AlphaTest,
false,
true
));
}
assert!(!casts_realtime_shadow(ModelKind::Creature, ModelBlend::Opaque, false, true));
assert!(!casts_realtime_shadow(
ModelKind::Creature,
ModelBlend::Opaque,
false,
true
));
}
#[test]
@@ -938,7 +992,11 @@ mod tests {
// Dusk, the frame the aim flips: the sun has just reached the horizon.
let sun_down = -0.02_f32;
assert_eq!(sun_shadow_strength(sun_down), 0.0, "the sun casts nothing at the horizon");
assert_eq!(
sun_shadow_strength(sun_down),
0.0,
"the sun casts nothing at the horizon"
);
// …and the moon is still under it (the shipped tables put moonrise ~1h45m later).
let moon_under = -0.17_f32;
assert_eq!(
@@ -949,7 +1007,11 @@ mod tests {
);
// While the sun is still up the moon is refused outright, whatever its elevation.
assert!(sun_shadow_strength(0.5) > 0.0);
assert_eq!(moon_shadow_weight(0.5, 0.9), 0.0, "one body casts at a time");
assert_eq!(
moon_shadow_weight(0.5, 0.9),
0.0,
"one body casts at a time"
);
// A high midnight moon does cast, and at full weight (the strength dial scales it later).
assert_eq!(moon_shadow_weight(sun_down, 0.82), 1.0);
// The aim itself: a below-horizon moon still travels DOWN, pinned at the floor elevation —
@@ -968,10 +1030,16 @@ mod tests {
assert!(sun_snap_due(None, held), "first write always fires");
let axis = Vec3::Y;
let small = Quat::from_axis_angle(axis, SUN_SNAP_RADIANS * 0.5) * held;
assert!(!sun_snap_due(Some(held), small), "sub-threshold drift holds");
assert!(
!sun_snap_due(Some(held), small),
"sub-threshold drift holds"
);
let large = Quat::from_axis_angle(axis, SUN_SNAP_RADIANS * 2.0) * held;
assert!(sun_snap_due(Some(held), large), "over-threshold snaps");
assert!(!sun_snap_due(Some(large), large), "identical direction never rewrites");
assert!(
!sun_snap_due(Some(large), large),
"identical direction never rewrites"
);
}
#[test]
@@ -1003,8 +1071,14 @@ mod tests {
fn the_rebuild_gate_paces_from_the_last_grant_and_first_call_is_always_due() {
let mut gate = RebuildRate::default();
assert!(gate.due(10.0, 30), "first call has nothing to show yet");
assert!(!gate.due(10.01, 30), "10 ms later is inside a 33 ms interval");
assert!(!gate.due(10.03, 30), "still inside, measured from the grant");
assert!(
!gate.due(10.01, 30),
"10 ms later is inside a 33 ms interval"
);
assert!(
!gate.due(10.03, 30),
"still inside, measured from the grant"
);
assert!(gate.due(10.04, 30), "past 1/30 s — due again");
assert!(!gate.due(10.05, 30), "the grant reset the clock");
// A reset (the lane tore its caster down) re-arms immediately.
@@ -1024,7 +1098,10 @@ mod tests {
let mut gate = RebuildRate::default();
assert!(gate.due(100.0, 15));
assert!(!gate.due(100.01, 15));
assert!(gate.due(0.0, 15), "a clock that went backwards is due, not stuck");
assert!(
gate.due(0.0, 15),
"a clock that went backwards is due, not stuck"
);
}
/// The map-size ladder snaps in LOG space, so "halfway" means halfway in ratio (1448 = √2·1024)
@@ -1036,8 +1113,16 @@ mod tests {
assert_eq!(clamp_shadow_map_size(1024), 1024);
assert_eq!(clamp_shadow_map_size(4096), 4096);
assert_eq!(clamp_shadow_map_size(0), 1024, "clamped to the floor");
assert_eq!(clamp_shadow_map_size(99_999), 4096, "clamped to the ceiling");
assert_eq!(clamp_shadow_map_size(1500), 2048, "1500 is above the 1448 log midpoint");
assert_eq!(
clamp_shadow_map_size(99_999),
4096,
"clamped to the ceiling"
);
assert_eq!(
clamp_shadow_map_size(1500),
2048,
"1500 is above the 1448 log midpoint"
);
assert_eq!(clamp_shadow_map_size(1400), 1024, "1400 < 1448");
assert!(SHADOW_MAP_SIZES.iter().all(|s| s.is_power_of_two()));
assert!(SHADOW_MAP_SIZES.contains(&DEFAULT_SHADOW_MAP_SIZE));
+12 -4
View File
@@ -4,8 +4,8 @@
//! 2 High (+ detailed, sun-lit clouds). `$WOW_SKY_QUALITY` pins the tier for the session, so
//! captures stay independent of `config.toml`.
use bevy::prelude::*;
use benilla_world::sky_fx::SkyQuality;
use bevy::prelude::*;
use crate::video::VideoConfig;
@@ -21,7 +21,11 @@ impl Plugin for SkyQualityPlugin {
}
}
fn bridge(video: Res<VideoConfig>, mut out: ResMut<SkyQuality>, mut pinned: Local<Option<Option<u8>>>) {
fn bridge(
video: Res<VideoConfig>,
mut out: ResMut<SkyQuality>,
mut pinned: Local<Option<Option<u8>>>,
) {
let pin = *pinned.get_or_insert_with(SkyQuality::env_override);
let want = SkyQuality(pin.unwrap_or(video.sky_quality).min(SkyQuality::MAX));
if *out != want {
@@ -39,9 +43,13 @@ mod tests {
return; // pinned by the environment; nothing to observe
}
let mut app = App::new();
app.init_resource::<VideoConfig>().add_plugins(SkyQualityPlugin);
app.init_resource::<VideoConfig>()
.add_plugins(SkyQualityPlugin);
app.update();
assert_eq!(app.world().resource::<SkyQuality>().0, VideoConfig::default().sky_quality);
assert_eq!(
app.world().resource::<SkyQuality>().0,
VideoConfig::default().sky_quality
);
for tier in [2u8, 1, 0] {
app.world_mut().resource_mut::<VideoConfig>().sky_quality = tier;
app.update();
+70 -25
View File
@@ -57,8 +57,11 @@ struct AoView {
/// Dev tuning knob `WOW_AO_<name>=<f32>`, read once per name; `None` keeps the tier value.
fn tuning(name: &'static str) -> Option<f32> {
static KNOBS: std::sync::Mutex<Vec<(&'static str, Option<f32>)>> = std::sync::Mutex::new(Vec::new());
let mut knobs = KNOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
static KNOBS: std::sync::Mutex<Vec<(&'static str, Option<f32>)>> =
std::sync::Mutex::new(Vec::new());
let mut knobs = KNOBS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((_, v)) = knobs.iter().find(|(n, _)| *n == name) {
return *v;
}
@@ -72,9 +75,17 @@ fn tuning(name: &'static str) -> Option<f32> {
impl AoView {
fn tier(tier: u8) -> Self {
let (radius, strength, samples) = if tier >= 2 { (1.5, 0.7, 12.0) } else { (1.2, 0.65, 6.0) };
let (radius, strength, samples) = if tier >= 2 {
(1.5, 0.7, 12.0)
} else {
(1.2, 0.65, 6.0)
};
// Outdoor contacts (house bases, trunks) sit 30-60 yd out; Low stops sooner.
let fade = if tier >= 2 { (60.0, 120.0) } else { (45.0, 90.0) };
let fade = if tier >= 2 {
(60.0, 120.0)
} else {
(45.0, 90.0)
};
let radius = tuning("RADIUS").unwrap_or(radius);
let strength = tuning("STRENGTH").unwrap_or(strength).min(1.0);
let bias = tuning("BIAS").unwrap_or(0.05);
@@ -91,7 +102,11 @@ impl AoView {
fn debug_mode() -> u8 {
static MODE: std::sync::OnceLock<u8> = std::sync::OnceLock::new();
*MODE.get_or_init(|| {
std::env::var("WOW_AO_DEBUG").ok().and_then(|v| v.parse().ok()).unwrap_or(0).min(5)
std::env::var("WOW_AO_DEBUG")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0)
.min(5)
})
}
@@ -185,11 +200,7 @@ enum Stage {
const AO_FORMAT: TextureFormat = TextureFormat::Rgba16Float;
fn init_pipeline(
mut commands: Commands,
shader: Res<AoShader>,
fullscreen: Res<FullscreenShader>,
) {
fn init_pipeline(mut commands: Commands, shader: Res<AoShader>, fullscreen: Res<FullscreenShader>) {
let with_depth = |label: &'static str, multisampled: bool| {
BindGroupLayoutDescriptor::new(
label,
@@ -213,7 +224,10 @@ fn init_pipeline(
&BindGroupLayoutEntries::with_indices(
ShaderStages::FRAGMENT,
(
(0, texture_2d(TextureSampleType::Float { filterable: false })),
(
0,
texture_2d(TextureSampleType::Float { filterable: false }),
),
(3, uniform_buffer::<AoView>(false)),
),
),
@@ -242,7 +256,11 @@ impl SpecializedRenderPipeline for AoPipeline {
defs.push("MULTISAMPLED".into());
}
let (label, entry, layout) = match stage {
Stage::Ao => ("ssao", "ao_main", self.ao_layouts[multisampled as usize].clone()),
Stage::Ao => (
"ssao",
"ao_main",
self.ao_layouts[multisampled as usize].clone(),
),
Stage::Blur => ("ssao_blur", "blur_main", self.blur_layout.clone()),
Stage::Apply => (
"ssao_apply",
@@ -272,7 +290,11 @@ impl SpecializedRenderPipeline for AoPipeline {
shader_defs: defs,
entry_point: Some(entry.into()),
targets: vec![Some(ColorTargetState {
format: if stage == Stage::Apply { format } else { AO_FORMAT },
format: if stage == Stage::Apply {
format
} else {
AO_FORMAT
},
blend,
write_mask: ColorWrites::ALL,
})],
@@ -313,8 +335,9 @@ fn prepare_pipelines(
for (entity, target, msaa) in &views {
let format = target.main_texture_format();
let samples = msaa.samples();
let ids = [Stage::Ao, Stage::Blur, Stage::Apply]
.map(|stage| specialized.specialize(&cache, &pipeline, (stage, format, samples, debug)));
let ids = [Stage::Ao, Stage::Blur, Stage::Apply].map(|stage| {
specialized.specialize(&cache, &pipeline, (stage, format, samples, debug))
});
commands.entity(entity).insert(ViewAoPipelines(ids));
}
}
@@ -333,7 +356,9 @@ fn prepare_textures(
) {
for (entity, camera) in &views {
// The depth texture is sized to the physical target.
let Some(size) = camera.physical_target_size else { continue };
let Some(size) = camera.physical_target_size else {
continue;
};
let half = UVec2::new(size.x.div_ceil(2), size.y.div_ceil(2)).max(UVec2::ONE);
let mut texture = |label: &'static str| {
cache.get(
@@ -440,10 +465,7 @@ impl ViewNode for AoNode {
let blur_bind = device.create_bind_group(
"ssao_blur",
&cache.get_bind_group_layout(&layouts.blur_layout),
&BindGroupEntries::with_indices((
(0, &textures.raw.default_view),
(3, params.clone()),
)),
&BindGroupEntries::with_indices(((0, &textures.raw.default_view), (3, params.clone()))),
);
let apply_bind = device.create_bind_group(
"ssao_apply",
@@ -484,8 +506,22 @@ impl ViewNode for AoNode {
span.end(&mut pass);
};
let encoder = context.command_encoder();
half_pass(encoder, "ssao", &textures.raw.default_view, ao, &ao_bind, &[view_offset.offset]);
half_pass(encoder, "ssao_blur", &textures.blurred.default_view, blur, &blur_bind, &[]);
half_pass(
encoder,
"ssao",
&textures.raw.default_view,
ao,
&ao_bind,
&[view_offset.offset],
);
half_pass(
encoder,
"ssao_blur",
&textures.blurred.default_view,
blur,
&blur_bind,
&[],
);
// The main attachment (MSAA + resolve when multisampled), so later passes load it darkened.
let mut pass = encoder.begin_render_pass(&RenderPassDescriptor {
label: Some("ssao_apply"),
@@ -524,14 +560,23 @@ mod tests {
// The registered default is Off: no pass, image unchanged.
app.update();
assert!(app.world().get::<AoView>(camera).is_none());
app.world_mut().resource_mut::<VideoConfig>().ambient_occlusion = 1;
app.world_mut()
.resource_mut::<VideoConfig>()
.ambient_occlusion = 1;
app.update();
let low = *app.world().get::<AoView>(camera).unwrap();
assert_eq!(low.params.z, 6.0);
assert!(app.world().get::<AoView>(preview).is_none());
let usage = app.world().get::<Camera3d>(camera).unwrap().depth_texture_usages.0;
let usage = app
.world()
.get::<Camera3d>(camera)
.unwrap()
.depth_texture_usages
.0;
assert!(usage & TextureUsages::TEXTURE_BINDING.bits() != 0);
app.world_mut().resource_mut::<VideoConfig>().ambient_occlusion = 2;
app.world_mut()
.resource_mut::<VideoConfig>()
.ambient_occlusion = 2;
app.update();
let high = *app.world().get::<AoView>(camera).unwrap();
assert_eq!(high.params.z, 12.0);
+457 -184
View File
@@ -52,34 +52,32 @@ use bevy::render::extract_resource::ExtractResourcePlugin;
use bevy::pbr::MeshMaterial3d;
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::coords::wow_to_bevy;
use benilla_assets::materials::WowModelMaterial;
use benilla_formats::ModelBlend;
use benilla_world::billboard::BillboardCard;
// MONKEY (torch owner exclusion): the placement identity an entity-lane caster part carries, and
// the model-local bound the containment fallback is decided on.
use benilla_world::interact::{PickMesh, WorldObject};
use bevy::camera::primitives::Aabb;
use benilla_world::lighting::{
interior_reach, m2_light_reach, DaylightFixture, DynamicInteriors, FireLightGain, LightLane,
LightLitRooms, LightReach, LightRooms, ShadowDistance, ShadowProxyLight, SyntheticFireLight,
WorldPointLight, WowLighting,
};
use bevy::camera::primitives::Aabb;
// MONKEY (carried light stability): the settle verdict a carried light earns by standing still.
// MONKEY (outdoor torch shadows): …and the marker that says a light is CARRIED BY A BODY.
use crate::entities::{CarriedLightMotion, HeldLight};
use benilla_world::model_render::{ModelKind, ModelPart, ShadowOccluder};
use benilla_world::rig_palette::{RigPalettes, RigPart, RigSkin};
use benilla_world::static_gx::{
torch_flame_inside_bounds, LightOwner, StaticGx, TorchShadowViews,
};
use benilla_world::static_gx::{torch_flame_inside_bounds, LightOwner, StaticGx, TorchShadowViews};
use benilla_world::view::WorldCamera;
use crate::char_select::ClientState;
use crate::net::SelfPlayer;
use crate::shadow_core::{
empty_shadow_mesh, restore_mesh_buffers, take_mesh_buffers,
ShadowDemand, ShadowFrame, ShadowSet,
empty_shadow_mesh, restore_mesh_buffers, take_mesh_buffers, ShadowDemand, ShadowFrame,
ShadowSet,
};
use crate::video::VideoConfig;
@@ -382,7 +380,11 @@ fn fixture_score(intensity: f32, d: f32, r: f32) -> f32 {
let r0 = (INTERIOR_CORE_FRAC * r).max(1e-3);
let atten = INTERIOR_CORE_GAIN / (1.0 + (d / r0) * (d / r0));
let extent = TORCH_ELIGIBLE_REACH_MULT * r + TORCH_ELIGIBLE_SLACK;
let w = (1.0 - (d / extent.max(1e-4)).clamp(0.0, 1.0).powf(INTERIOR_DIRECT_POW)).clamp(0.0, 1.0);
let w = (1.0
- (d / extent.max(1e-4))
.clamp(0.0, 1.0)
.powf(INTERIOR_DIRECT_POW))
.clamp(0.0, 1.0);
intensity * atten * w * w
}
@@ -391,8 +393,13 @@ fn fixture_score(intensity: f32, d: f32, r: f32) -> f32 {
/// exterior fixture must score zero before it can evict a useful caster. Reach still uses the
/// authored intensity, so dimming a flame changes its contribution without moving its pool.
fn candidate_score(
intensity: f32, d: f32, r: f32, lane: Option<&LightLane>, has_rooms: bool,
synthetic: bool, fire_gain: f32,
intensity: f32,
d: f32,
r: f32,
lane: Option<&LightLane>,
has_rooms: bool,
synthetic: bool,
fire_gain: f32,
) -> f32 {
if !lane.map_or(has_rooms, |l| l.interior) {
return 0.0;
@@ -419,8 +426,11 @@ fn exterior_fixture_score(intensity: f32, d: f32) -> f32 {
// score to exactly zero at the edge instead of stepping it there. It is the interior lane's
// constant because it is the same shoulder, not because this is an interior term; the
// attenuation above is the exterior receivers' own and shares nothing with that lane.
let w = (1.0 - (d / TORCH_EXT_ELIGIBLE_YD).clamp(0.0, 1.0).powf(INTERIOR_DIRECT_POW))
.clamp(0.0, 1.0);
let w = (1.0
- (d / TORCH_EXT_ELIGIBLE_YD)
.clamp(0.0, 1.0)
.powf(INTERIOR_DIRECT_POW))
.clamp(0.0, 1.0);
intensity * atten * w * w
}
@@ -504,8 +514,16 @@ fn map_publishable(built_at: Option<Vec3>, pos: Vec3) -> bool {
fn moving_budget(mut movers: Vec<(usize, f32)>) -> (Vec<usize>, Vec<usize>) {
movers.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
(
movers.iter().take(TORCH_MOVING_MAX).map(|(i, _)| *i).collect(),
movers.iter().skip(TORCH_MOVING_MAX).map(|(i, _)| *i).collect(),
movers
.iter()
.take(TORCH_MOVING_MAX)
.map(|(i, _)| *i)
.collect(),
movers
.iter()
.skip(TORCH_MOVING_MAX)
.map(|(i, _)| *i)
.collect(),
)
}
@@ -514,10 +532,16 @@ fn moving_budget(mut movers: Vec<(usize, f32)>) -> (Vec<usize>, Vec<usize>) {
/// subset is independent of contribution ranking and never moves the resident static maps.
fn dynamic_set(fixtures: &[(usize, Vec3)], anchor: Vec3, budget: usize) -> u32 {
let mut nearest = fixtures.to_vec();
nearest.sort_by(|a, b| a.1.distance_squared(anchor).total_cmp(&b.1.distance_squared(anchor))
.then(a.0.cmp(&b.0)));
nearest.sort_by(|a, b| {
a.1.distance_squared(anchor)
.total_cmp(&b.1.distance_squared(anchor))
.then(a.0.cmp(&b.0))
});
// MONKEY (live bank rank): also clamp resource values that bypass the cvar setter.
nearest.iter().take(budget.clamp(1, MAX_TORCH_DYNAMIC)).fold(0, |mask, (i, _)| mask | (1 << i))
nearest
.iter()
.take(budget.clamp(1, MAX_TORCH_DYNAMIC))
.fold(0, |mask, (i, _)| mask | (1 << i))
}
/// MONKEY (torch lane perf): the radius the MOVING-caster gather admits parts inside, for a
@@ -535,7 +559,9 @@ fn entity_gather_radius(reach: f32) -> f32 {
/// UNION that replaced "48 yd of every dynamic fixture". Split out as a pure function because it is
/// the whole admission rule of the gather and the one part of it worth a test.
fn within_any_reach(fixtures: &[GatherFixture], origin: Vec3) -> bool {
fixtures.iter().any(|(_, p, r, _)| p.distance_squared(origin) <= r * r)
fixtures
.iter()
.any(|(_, p, r, _)| p.distance_squared(origin) <= r * r)
}
/// MONKEY (torch owner exclusion): is this entity-lane caster part the fixture's OWN body?
@@ -566,10 +592,17 @@ fn within_any_reach(fixtures: &[GatherFixture], origin: Vec3) -> bool {
/// is covered by rule 1 instead, which is exact — `entities::carried_light` tags every one of
/// those lights with the frame it hangs under.
fn part_is_own_body(
ents: &EntityCasters, fixtures: &[GatherFixture], entity: Entity, kind: ModelKind,
object: Option<&WorldObject>, global: Option<&GlobalTransform>, aabb: Option<&Aabb>,
ents: &EntityCasters,
fixtures: &[GatherFixture],
entity: Entity,
kind: ModelKind,
object: Option<&WorldObject>,
global: Option<&GlobalTransform>,
aabb: Option<&Aabb>,
) -> bool {
let bound = (kind != ModelKind::Creature).then_some(()).and(global.zip(aabb));
let bound = (kind != ModelKind::Creature)
.then_some(())
.and(global.zip(aabb));
fixtures.iter().any(|(_, pos, _, owner)| {
let owned = match owner.map(|o| (o, o.instance())) {
Some((_, Some(frame))) => hangs_under(ents, entity, frame),
@@ -591,7 +624,9 @@ fn hangs_under(ents: &EntityCasters, entity: Entity, frame: Entity) -> bool {
if at == frame {
return true;
}
let Ok(parent) = ents.parents.get(at) else { return false };
let Ok(parent) = ents.parents.get(at) else {
return false;
};
at = parent.parent();
}
false
@@ -656,11 +691,13 @@ struct EntityCasters<'w, 's> {
/// The cvar `torchTerrainShadows`, or `WOW_TORCH_TERRAIN=0|1` (read once) for hermetic captures.
fn terrain_casters_on(video: &VideoConfig) -> bool {
static ENV: std::sync::OnceLock<Option<bool>> = std::sync::OnceLock::new();
ENV.get_or_init(|| match std::env::var("WOW_TORCH_TERRAIN").ok().as_deref() {
Some("1") => Some(true),
Some("0") => Some(false),
_ => None,
})
ENV.get_or_init(
|| match std::env::var("WOW_TORCH_TERRAIN").ok().as_deref() {
Some("1") => Some(true),
Some("0") => Some(false),
_ => None,
},
)
.unwrap_or(video.torch_terrain_shadows)
}
@@ -758,9 +795,20 @@ fn update_torch_shadows(
// `entities::carried_light`). It rides the candidate and then the slot, because the caster
// gathers need it and they run far downstream of this query.
torches: Query<
(Entity, &GlobalTransform, &WorldPointLight, Option<&LightReach>, Option<&LightLane>,
Has<LightRooms>, Has<LightLitRooms>, Has<SyntheticFireLight>, Has<ChildOf>,
Has<HeldLight>, Option<&crate::entities::CarriedLightMotion>, Option<&LightOwner>),
(
Entity,
&GlobalTransform,
&WorldPointLight,
Option<&LightReach>,
Option<&LightLane>,
Has<LightRooms>,
Has<LightLitRooms>,
Has<SyntheticFireLight>,
Has<ChildOf>,
Has<HeldLight>,
Option<&crate::entities::CarriedLightMotion>,
Option<&LightOwner>,
),
// MONKEY (daylight fixtures): a doorway's daylight source is NOT a caster candidate. It is
// an AREA source the width of the opening, standing in a hole in a wall — a point-cube
// shadow of it would be wrong in kind (hard radial wedges from a soft sky) — and being
@@ -837,93 +885,107 @@ fn update_torch_shadows(
// MONKEY (outdoor torch shadows): the candidate tuple carries its LANE (`true` = exterior).
let mut cands: Vec<Candidate> = torches
.iter()
.filter_map(|(e, gt, pl, reach, light_lane, has_rooms, has_lit, synthetic, carried,
held, motion, owner)| {
// MONKEY (carried light stability): a CARRIED light (`ChildOf` — a pet's hand flame,
// an NPC's torch, a transport's deck brazier) used to cast ONLY while standing still,
// because a cached cube is valid only while its fixture stays within
// `TORCH_STALE_DRIFT_SQ` of where it was baked ([`map_publishable`]) and a moving one
// toggled a full-strength shadow at frame rate.
//
// MONKEY (moving fixture): that ban is lifted, because the lane can now RE-RENDER a
// moving fixture's whole cube every frame instead of republishing a frozen one (see
// [`TorchSlot::moving`]). What survives of the old rule is its arithmetic: only
// [`TORCH_MOVING_MAX`] fixtures can be afforded that way, so a moving carried light is
// admitted as a candidate only if it already HOLDS one of those budgets or one is free.
// Doing it here, at candidacy, rather than by promoting it and evicting it a moment
// later, is what stops a third walking pet from churning the slot machinery: it never
// enters `cands`, so nothing downstream has to fade it out and refill after it.
//
// The stationary members of the same `ChildOf` family are untouched — a placed brazier
// GameObject or a campfire is `settled` and takes the ordinary cached path.
if carried
&& !motion.is_some_and(CarriedLightMotion::settled)
&& !live_held.contains(&e)
&& live_held.len() >= TORCH_MOVING_MAX
{
return None;
}
let p = gt.translation();
let d = p.distance(anchor);
if p.distance(cam) > TORCH_SEARCH_RADIUS.max(ents.distance.0) {
return None;
}
// The packer's own recovery of authored colour x intensity (`spawn_point_light`
// premultiplied 4*pi), and the packer's own reach: the authored MOLT end where there is
// one, the intensity bucket otherwise, times the live `interiorAttenScale`.
let base = pl.intensity / (4.0 * std::f32::consts::PI);
let authored = reach
.map(|r| r.0)
.filter(|r| *r > 0.5)
.unwrap_or_else(|| m2_light_reach(base));
let r = interior_reach(authored, interiors.atten_scale);
// MONKEY (outdoor torch shadows): the `4R + slack` eligibility test moved DOWN into the
// interior arm (unchanged there). It cannot be shared: `r` is an INTERIOR reach — the
// authored MOLT end or the M2 intensity bucket times `interiorAttenScale` — and an
// exterior entry packs no reach at all, so applying it out here would size an outdoor
// campfire's promotion window off a number no exterior receiver ever reads.
let c = pl.color;
// Rec.709 luminance of the committed colour: ONE scalar for "how much light is this",
// so a dim blue magic brazier cannot outrank a bright hearth on channel count.
let lum = (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]).max(0.0) * base.max(0.0);
// MONKEY (outdoor torch shadows): the lane split. It is the SAME verdict the light
// packer writes into the colour row's `.w` (`LightLane` — a light is interior iff it
// physically stands in an interior-class WMO group, with the old `LightRooms` rule as
// the pre-classification fallback), because that is what decides which receiver family
// reads it and therefore which shadow lane can ever be sampled for it.
let interior = light_lane.map_or(has_rooms, |l| l.interior);
if interior {
// The interior arm, unchanged — including the retired query filter's own
// membership test, so this candidate set is identical member-for-member to the one
// `Or<(With<LightRooms>, With<LightLitRooms>)>` produced.
if !interior_on || !(has_rooms || has_lit) {
.filter_map(
|(
e,
gt,
pl,
reach,
light_lane,
has_rooms,
has_lit,
synthetic,
carried,
held,
motion,
owner,
)| {
// MONKEY (carried light stability): a CARRIED light (`ChildOf` — a pet's hand flame,
// an NPC's torch, a transport's deck brazier) used to cast ONLY while standing still,
// because a cached cube is valid only while its fixture stays within
// `TORCH_STALE_DRIFT_SQ` of where it was baked ([`map_publishable`]) and a moving one
// toggled a full-strength shadow at frame rate.
//
// MONKEY (moving fixture): that ban is lifted, because the lane can now RE-RENDER a
// moving fixture's whole cube every frame instead of republishing a frozen one (see
// [`TorchSlot::moving`]). What survives of the old rule is its arithmetic: only
// [`TORCH_MOVING_MAX`] fixtures can be afforded that way, so a moving carried light is
// admitted as a candidate only if it already HOLDS one of those budgets or one is free.
// Doing it here, at candidacy, rather than by promoting it and evicting it a moment
// later, is what stops a third walking pet from churning the slot machinery: it never
// enters `cands`, so nothing downstream has to fade it out and refill after it.
//
// The stationary members of the same `ChildOf` family are untouched — a placed brazier
// GameObject or a campfire is `settled` and takes the ordinary cached path.
if carried
&& !motion.is_some_and(CarriedLightMotion::settled)
&& !live_held.contains(&e)
&& live_held.len() >= TORCH_MOVING_MAX
{
return None;
}
if !fixture_eligible(d, p.distance(cam), r, ents.distance.0) {
let p = gt.translation();
let d = p.distance(anchor);
if p.distance(cam) > TORCH_SEARCH_RADIUS.max(ents.distance.0) {
return None;
}
let score =
candidate_score(lum, d, r, light_lane, has_rooms, synthetic, fire_gain.0);
// MONKEY (torch lane perf): `r` rides along — the moving-caster gather sizes its
// radius off the light that will be sampled, not off the cube's 48 yd far plane.
return (score > 0.0).then_some((e, p, score, false, r, owner.copied()));
}
// The EXTERIOR arm — a campfire, a brazier, a lamppost, a bonfire.
if !exterior_on || held {
return None;
}
if !exterior_eligible(d, p.distance(cam), ents.distance.0) {
return None;
}
// Same synthetic-gain fold as the interior arm (and as the packer): dimming the
// invented lights with `fireLightGain 0` must take their shadows with them, or a fire
// that lights nothing would still carve a black wedge across the ground.
let gain = if synthetic { fire_gain.0.max(0.0) } else { 1.0 };
let score = exterior_fixture_score(lum * gain, d);
// MONKEY (torch lane perf): an exterior entry packs no reach and its receivers apply an
// un-windowed falloff, so the cube's own range is the only honest gather radius here.
(score > 0.0).then_some((e, p, score, true, TORCH_RANGE, owner.copied()))
})
// The packer's own recovery of authored colour x intensity (`spawn_point_light`
// premultiplied 4*pi), and the packer's own reach: the authored MOLT end where there is
// one, the intensity bucket otherwise, times the live `interiorAttenScale`.
let base = pl.intensity / (4.0 * std::f32::consts::PI);
let authored = reach
.map(|r| r.0)
.filter(|r| *r > 0.5)
.unwrap_or_else(|| m2_light_reach(base));
let r = interior_reach(authored, interiors.atten_scale);
// MONKEY (outdoor torch shadows): the `4R + slack` eligibility test moved DOWN into the
// interior arm (unchanged there). It cannot be shared: `r` is an INTERIOR reach — the
// authored MOLT end or the M2 intensity bucket times `interiorAttenScale` — and an
// exterior entry packs no reach at all, so applying it out here would size an outdoor
// campfire's promotion window off a number no exterior receiver ever reads.
let c = pl.color;
// Rec.709 luminance of the committed colour: ONE scalar for "how much light is this",
// so a dim blue magic brazier cannot outrank a bright hearth on channel count.
let lum = (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]).max(0.0) * base.max(0.0);
// MONKEY (outdoor torch shadows): the lane split. It is the SAME verdict the light
// packer writes into the colour row's `.w` (`LightLane` — a light is interior iff it
// physically stands in an interior-class WMO group, with the old `LightRooms` rule as
// the pre-classification fallback), because that is what decides which receiver family
// reads it and therefore which shadow lane can ever be sampled for it.
let interior = light_lane.map_or(has_rooms, |l| l.interior);
if interior {
// The interior arm, unchanged — including the retired query filter's own
// membership test, so this candidate set is identical member-for-member to the one
// `Or<(With<LightRooms>, With<LightLitRooms>)>` produced.
if !interior_on || !(has_rooms || has_lit) {
return None;
}
if !fixture_eligible(d, p.distance(cam), r, ents.distance.0) {
return None;
}
let score =
candidate_score(lum, d, r, light_lane, has_rooms, synthetic, fire_gain.0);
// MONKEY (torch lane perf): `r` rides along — the moving-caster gather sizes its
// radius off the light that will be sampled, not off the cube's 48 yd far plane.
return (score > 0.0).then_some((e, p, score, false, r, owner.copied()));
}
// The EXTERIOR arm — a campfire, a brazier, a lamppost, a bonfire.
if !exterior_on || held {
return None;
}
if !exterior_eligible(d, p.distance(cam), ents.distance.0) {
return None;
}
// Same synthetic-gain fold as the interior arm (and as the packer): dimming the
// invented lights with `fireLightGain 0` must take their shadows with them, or a fire
// that lights nothing would still carve a black wedge across the ground.
let gain = if synthetic { fire_gain.0.max(0.0) } else { 1.0 };
let score = exterior_fixture_score(lum * gain, d);
// MONKEY (torch lane perf): an exterior entry packs no reach and its receivers apply an
// un-windowed falloff, so the cube's own range is the only honest gather radius here.
(score > 0.0).then_some((e, p, score, true, TORCH_RANGE, owner.copied()))
},
)
.collect();
cands.sort_by(|a, b| b.2.total_cmp(&a.2));
let ext_cap = exterior_budget(want);
@@ -996,7 +1058,11 @@ fn update_torch_shadows(
let step = TORCH_FADE_RATE * dt;
for slot in lane.slots.iter_mut() {
// MONKEY (moving fixture): a dropped mover leaves five times faster — see the const.
let step = if slot.fast_fade { TORCH_MOVING_FADE_RATE * dt } else { step };
let step = if slot.fast_fade {
TORCH_MOVING_FADE_RATE * dt
} else {
step
};
slot.w = if slot.evicting {
(slot.w - step).max(0.0)
} else {
@@ -1006,7 +1072,9 @@ fn update_torch_shadows(
lane.slots.retain(|s| {
let release = s.evicting && s.w <= 0.0;
if release {
if let Some(mesh) = &s.mesh { meshes.remove(mesh.id()); }
if let Some(mesh) = &s.mesh {
meshes.remove(mesh.id());
}
}
!release
});
@@ -1024,7 +1092,8 @@ fn update_torch_shadows(
break;
};
let cache_slot = (0..MAX_TORCH_CASTERS)
.find(|i| !lane.slots.iter().any(|s| s.cache_slot == *i)).unwrap();
.find(|i| !lane.slots.iter().any(|s| s.cache_slot == *i))
.unwrap();
promotions += 1;
lane.slots.push(TorchSlot {
cache_slot,
@@ -1101,8 +1170,11 @@ fn update_torch_shadows(
.iter()
.filter(|s| s.moving_live)
.fold(0u32, |mask, s| mask | (1 << s.cache_slot));
let dynamic = dynamic_set(&fixture_positions, anchor, video.interior_shadow_dynamic as usize)
| moving_mask;
let dynamic = dynamic_set(
&fixture_positions,
anchor,
video.interior_shadow_dynamic as usize,
) | moving_mask;
let mut rebuilt = 0;
// MONKEY (moving fixture): the live slots' own rebuild allowance, kept apart from `rebuilt`.
let mut moving_rebuilt = 0;
@@ -1112,7 +1184,11 @@ fn update_torch_shadows(
// loop, so the counter cannot live on it until the loop has finished).
let mut lane_scans = 0u32;
let mut published = TorchShadowViews {
count: promoted.iter().map(|(i, ..)| *i as u32 + 1).max().unwrap_or(0),
count: promoted
.iter()
.map(|(i, ..)| *i as u32 + 1)
.max()
.unwrap_or(0),
soft: video.interior_shadow_soft,
// MONKEY (shadow floor): the direct-term floor, packed beside `soft` in `count.y`.
strength: video.torch_shadow_strength,
@@ -1167,7 +1243,13 @@ fn update_torch_shadows(
.slots
.iter()
.position(|s| s.checked.is_none())
.unwrap_or_else(|| if slot_count == 0 { 0 } else { lane.rebuild_cursor % slot_count });
.unwrap_or_else(|| {
if slot_count == 0 {
0
} else {
lane.rebuild_cursor % slot_count
}
});
let scan = slot_count.min(TORCH_SCAN_PER_FRAME);
// MONKEY (moving fixture): a live slot is scanned EVERY frame, in addition to (and ahead of)
// the cursor's window. It cannot wait its turn in a rotation that takes a dozen frames to come
@@ -1201,7 +1283,11 @@ fn update_torch_shadows(
// does not matter, but this slot pays it EVERY frame, and an imp's flame reaches ~10 yd
// where the cube reaches 48 (a hundredth of the volume). Settled slots keep the full range
// verbatim, so no cached key or mesh changes meaning.
let range = if moving { entity_gather_radius(slot.reach) } else { TORCH_RANGE };
let range = if moving {
entity_gather_radius(slot.reach)
} else {
TORCH_RANGE
};
// MONKEY (torch lane perf): the skip. The fixture's own position joins the census because
// it is hashed INTO the fingerprint (a carried torch moves without the world changing),
// and a slot is only ever stamped once its key is actually committed below — a rebuild
@@ -1224,19 +1310,35 @@ fn update_torch_shadows(
.hash(&mut hash);
let mut entity_key = 0u64;
for (entity, pick, part, global, rig, occluder, _, object, aabb) in &ents.parts {
if !static_part(part, rig.is_some()) || !occluder.0 { continue; }
if !static_part(part, rig.is_some()) || !occluder.0 {
continue;
}
let Some(global) = global else { continue };
if global.translation().distance_squared(slot.pos) > range * range { continue; }
if global.translation().distance_squared(slot.pos) > range * range {
continue;
}
// The same exclusion the gather below applies, or the key would certify a mesh that
// was never built from this set.
if part_is_own_body(&ents, &gather, entity, part.kind, object, Some(global), aabb) {
if part_is_own_body(
&ents,
&gather,
entity,
part.kind,
object,
Some(global),
aabb,
) {
continue;
}
// Arc identity catches geometry replacement, transform bits catch moved furniture.
let mut part_hash = std::collections::hash_map::DefaultHasher::new();
entity.hash(&mut part_hash);
(std::sync::Arc::as_ptr(&pick.0) as usize).hash(&mut part_hash);
global.to_matrix().to_cols_array().map(f32::to_bits).hash(&mut part_hash);
global
.to_matrix()
.to_cols_array()
.map(f32::to_bits)
.hash(&mut part_hash);
entity_key = entity_key.wrapping_add(part_hash.finish());
}
entity_key.hash(&mut hash);
@@ -1248,7 +1350,10 @@ fn update_torch_shadows(
// re-rendered every exterior slot on any stream event anywhere.
if let (Some(t), Some(a)) = (&ents.terrain, &ents.adt_tiles) {
(benilla_world::terrain_stream::terrain_torch_generation_near(
t, a, slot.pos, TORCH_RANGE,
t,
a,
slot.pos,
TORCH_RANGE,
) ^ 0x7e44_a1d0)
.hash(&mut hash);
}
@@ -1261,7 +1366,11 @@ fn update_torch_shadows(
// MONKEY (moving fixture): two budgets, not one. A moving slot asks to rebuild every
// frame, so sharing the resident slots' two would let one walking pet freeze every static
// map in the room for as long as it walked.
let afford = if moving { moving_rebuilt < TORCH_MOVING_MAX } else { rebuilt < 2 };
let afford = if moving {
moving_rebuilt < TORCH_MOVING_MAX
} else {
rebuilt < 2
};
if dirty && afford {
// MONKEY (moving fixture): recycle THIS slot's own buffers when it is live, so a
// fixture that rebuilds sixty times a second does not allocate two Vecs a frame.
@@ -1273,8 +1382,7 @@ fn update_torch_shadows(
.map(take_mesh_buffers)
.unwrap_or_default();
let gx_excluded = gx.as_ref().map_or(0, |gx| {
gx.append_torch_triangles(
slot.pos, range, owner, &mut positions, &mut indices)
gx.append_torch_triangles(slot.pos, range, owner, &mut positions, &mut indices)
});
let (_, part_excluded) =
collect_torch_entities(&ents, true, &gather, &mut positions, &mut indices);
@@ -1282,7 +1390,13 @@ fn update_torch_shadows(
if let (Some(t), Some(a)) = (&ents.terrain, &ents.adt_tiles) {
let t0 = std::time::Instant::now();
let tris = benilla_world::terrain_stream::append_terrain_torch_triangles(
t, a, slot.pos, range, &mut positions, &mut indices);
t,
a,
slot.pos,
range,
&mut positions,
&mut indices,
);
if std::env::var_os("WOW_TORCH_TRACE").is_some() {
info!("torch-terrain: slot {i} +{tris} ground tris within {range:.0} yd in {:.3} ms",
t0.elapsed().as_secs_f64() * 1e3);
@@ -1298,18 +1412,29 @@ fn update_torch_shadows(
// turns into a zero cross-fade weight: the shadow would blink off on exactly the frames
// the fixture is moving. With the id stable the mesh is always ready, and the render
// plan rebuilds a `moving_mask` slot unconditionally instead of on an id change.
match slot.mesh.as_ref().filter(|_| moving).and_then(|h| meshes.get_mut(h)) {
match slot
.mesh
.as_ref()
.filter(|_| moving)
.and_then(|h| meshes.get_mut(h))
{
Some(mesh) => restore_mesh_buffers(mesh, positions, indices),
None => {
let mut mesh = empty_shadow_mesh();
restore_mesh_buffers(&mut mesh, positions, indices);
if let Some(old) = slot.mesh.replace(meshes.add(mesh)) { meshes.remove(old.id()); }
if let Some(old) = slot.mesh.replace(meshes.add(mesh)) {
meshes.remove(old.id());
}
}
}
slot.geometry_key = Some(key);
slot.built_at = Some(slot.pos);
slot.checked = Some((census.gx, census.parts, slot.pos));
if moving { moving_rebuilt += 1 } else { rebuilt += 1 }
if moving {
moving_rebuilt += 1
} else {
rebuilt += 1
}
static_rebuilt |= 1 << i;
}
}
@@ -1372,7 +1497,9 @@ fn update_torch_shadows(
// casters were skinned at; it never removes a shadow.
if dynamic != 0 {
let fresh = lane.entity_mesh.is_none();
if fresh { lane.entity_mesh = Some(meshes.add(empty_shadow_mesh())); }
if fresh {
lane.entity_mesh = Some(meshes.add(empty_shadow_mesh()));
}
// MONKEY (moving fixture): a live slot overrides the cadence. `interiorShadowEntityRate`
// ages the POSE of the moving casters, which is invisible on a fixture that is standing
// still (the shadow is in the right place either way, a frame's stride out of date at
@@ -1392,7 +1519,12 @@ fn update_torch_shadows(
if let Some(mesh) = lane.entity_mesh.as_ref().and_then(|h| meshes.get_mut(h)) {
let (mut positions, mut indices) = take_mesh_buffers(mesh);
(lane.trace_parts, _) = collect_torch_entities(
&ents, false, &dynamic_positions, &mut positions, &mut indices);
&ents,
false,
&dynamic_positions,
&mut positions,
&mut indices,
);
restore_mesh_buffers(mesh, positions, indices);
}
lane.trace_gathers += 1;
@@ -1402,7 +1534,10 @@ fn update_torch_shadows(
published.entity_mesh = lane.entity_mesh.as_ref().map(Handle::id);
}
lane.trace_scans += lane_scans;
lane.trace_reach = dynamic_positions.iter().map(|(_, _, r, _)| *r).fold(0.0, f32::max);
lane.trace_reach = dynamic_positions
.iter()
.map(|(_, _, r, _)| *r)
.fold(0.0, f32::max);
*views = published;
// `WOW_TORCH_TRACE=1` — once a second, what the lane published: fixture distances + whether a
@@ -1506,21 +1641,31 @@ fn static_part(part: &ModelPart, rigged: bool) -> bool {
/// MONKEY (torch owner exclusion): …and the owner, which drops the fixture's own body
/// ([`part_is_own_body`]). Returns `(admitted, excluded-as-own-body)` — the second is trace only.
fn collect_torch_entities(
ents: &EntityCasters, want_static: bool, fixtures: &[GatherFixture],
positions: &mut Vec<[f32; 3]>, indices: &mut Vec<u32>,
ents: &EntityCasters,
want_static: bool,
fixtures: &[GatherFixture],
positions: &mut Vec<[f32; 3]>,
indices: &mut Vec<u32>,
) -> (u32, u32) {
let mut admitted = 0;
let mut excluded = 0;
for (entity, pick, part, global, rig_part, occluder, _, object, aabb) in &ents.parts {
if !occluder.0 || static_part(part, rig_part.is_some()) != want_static { continue; }
if !occluder.0 || static_part(part, rig_part.is_some()) != want_static {
continue;
}
let solid = part.blend == ModelBlend::Opaque
|| (part.kind == ModelKind::Creature && part.blend == ModelBlend::AlphaTest);
if !solid { continue; }
if !solid {
continue;
}
let rig = rig_part.and_then(|r| ents.rigs.get(r.0).ok());
let origin = global.map(GlobalTransform::translation)
let origin = global
.map(GlobalTransform::translation)
.or_else(|| rig.and_then(|r| ents.palettes.slot_origin(r.slot)));
let Some(origin) = origin else { continue };
if !within_any_reach(fixtures, origin) { continue; }
if !within_any_reach(fixtures, origin) {
continue;
}
// After the cheap window, before the expensive CPU skin: this is the lamp's own housing,
// the brazier's own bowl, the lantern post the light hangs off.
if part_is_own_body(ents, fixtures, entity, part.kind, object, global, aabb) {
@@ -1529,11 +1674,20 @@ fn collect_torch_entities(
}
let base = positions.len() as u32;
if let Some(rig) = rig {
let Some(palette) = ents.palettes.world_palette(rig.slot, rig.bones() as usize) else { continue };
let Some(palette) = ents.palettes.world_palette(rig.slot, rig.bones() as usize) else {
continue;
};
for (v, p) in pick.0.positions.iter().enumerate() {
let p = wow_to_bevy(*p);
let (Some(joints), Some(weights)) = (pick.0.joints.get(v), pick.0.weights.get(v)) else {
positions.push(palette.first().map(|m| m.transform_point3(p)).unwrap_or(origin).to_array());
let (Some(joints), Some(weights)) = (pick.0.joints.get(v), pick.0.weights.get(v))
else {
positions.push(
palette
.first()
.map(|m| m.transform_point3(p))
.unwrap_or(origin)
.to_array(),
);
continue;
};
let mut skinned = Vec3::ZERO;
@@ -1548,13 +1702,22 @@ fn collect_torch_entities(
}
} else if rig_part.is_none() {
if let Some(global) = global {
positions.extend(pick.0.positions.iter().map(|p| global.transform_point(wow_to_bevy(*p)).to_array()));
positions.extend(
pick.0
.positions
.iter()
.map(|p| global.transform_point(wow_to_bevy(*p)).to_array()),
);
}
}
let added = positions.len() as u32 - base;
if added == 0 { continue; }
if added == 0 {
continue;
}
for tri in pick.0.indices.chunks_exact(3) {
if tri.iter().all(|i| *i < added) { indices.extend(tri.iter().map(|i| base + *i)); }
if tri.iter().all(|i| *i < added) {
indices.extend(tri.iter().map(|i| base + *i));
}
}
admitted += 1;
}
@@ -1574,13 +1737,19 @@ fn collect_torch_entities(
fn static_part_census(ents: &EntityCasters) -> u64 {
let mut census = 0u64;
for (entity, pick, part, global, rig, occluder, ..) in &ents.parts {
if !occluder.0 || !static_part(part, rig.is_some()) { continue; }
if !occluder.0 || !static_part(part, rig.is_some()) {
continue;
}
let Some(global) = global else { continue };
let a = global.affine();
let mut mix = entity.to_bits() ^ (std::sync::Arc::as_ptr(&pick.0) as usize as u64);
for v in [
a.translation.x, a.translation.y, a.translation.z,
a.matrix3.x_axis.x, a.matrix3.x_axis.y, a.matrix3.x_axis.z,
a.translation.x,
a.translation.y,
a.translation.z,
a.matrix3.x_axis.x,
a.matrix3.x_axis.y,
a.matrix3.x_axis.z,
] {
mix = mix.rotate_left(11) ^ u64::from(v.to_bits());
}
@@ -1592,9 +1761,13 @@ fn static_part_census(ents: &EntityCasters) -> u64 {
/// Tear down the caster mesh and clear the published views (the lane went off, or we left the world).
fn teardown(lane: &mut TorchLane, meshes: &mut Assets<Mesh>, views: &mut TorchShadowViews) {
for slot in &mut lane.slots {
if let Some(handle) = slot.mesh.take() { meshes.remove(handle.id()); }
if let Some(handle) = slot.mesh.take() {
meshes.remove(handle.id());
}
}
if let Some(handle) = lane.entity_mesh.take() {
meshes.remove(handle.id());
}
if let Some(handle) = lane.entity_mesh.take() { meshes.remove(handle.id()); }
// MONKEY (torch caster selection): the slot table goes with them — an incumbent kept across a
// teardown would come back at full weight in a room it is no longer in.
lane.slots.clear();
@@ -1619,16 +1792,34 @@ mod tests {
let exterior = LightLane::carried(false);
let authored = candidate_score(1.0, 10.0, 10.0, Some(&interior), true, false, 0.0);
assert!(authored > 0.0);
assert_eq!(candidate_score(100.0, 1.0, 10.0, Some(&exterior), true, false, 1.0), 0.0);
assert_eq!(
candidate_score(100.0, 1.0, 10.0, Some(&exterior), true, false, 1.0),
0.0
);
for gain in [0.0, -1.0] {
assert_eq!(candidate_score(100.0, 1.0, 10.0, Some(&interior), true, true, gain), 0.0);
assert_eq!(
candidate_score(100.0, 1.0, 10.0, Some(&interior), true, true, gain),
0.0
);
}
assert_eq!(candidate_score(1.0, 10.0, 10.0, Some(&interior), true, true, 0.5), authored * 0.5);
assert_eq!(
candidate_score(1.0, 10.0, 10.0, Some(&interior), true, true, 0.5),
authored * 0.5
);
// No MOLR is needed once a containment-only fixture resolves interior; before resolution
// the old LightRooms fallback is shared with build_light_data, in both directions.
assert_eq!(candidate_score(1.0, 10.0, 10.0, Some(&interior), false, false, 0.0), authored);
assert_eq!(candidate_score(1.0, 10.0, 10.0, None, true, false, 0.0), authored);
assert_eq!(candidate_score(1.0, 10.0, 10.0, None, false, false, 0.0), 0.0);
assert_eq!(
candidate_score(1.0, 10.0, 10.0, Some(&interior), false, false, 0.0),
authored
);
assert_eq!(
candidate_score(1.0, 10.0, 10.0, None, true, false, 0.0),
authored
);
assert_eq!(
candidate_score(1.0, 10.0, 10.0, None, false, false, 0.0),
0.0
);
}
#[test]
@@ -1647,12 +1838,19 @@ mod tests {
// MONKEY (live bank rank): sparse slots, tie stability and both ends of the live budget.
#[test]
fn dynamic_subset_is_nearest_and_deterministic() {
let fixtures = [(15, Vec3::X), (8, -Vec3::X), (2, Vec3::X * 20.0), (4, Vec3::ZERO)];
let fixtures = [
(15, Vec3::X),
(8, -Vec3::X),
(2, Vec3::X * 20.0),
(4, Vec3::ZERO),
];
assert_eq!(dynamic_set(&fixtures, Vec3::ZERO, 2), (1 << 4) | (1 << 8));
assert_eq!(dynamic_set(&fixtures, Vec3::X * 20.0, 1), 1 << 2);
assert_eq!(dynamic_set(&fixtures, Vec3::ZERO, 0), 1 << 4);
assert_eq!(dynamic_set(&fixtures, Vec3::ZERO, 16).count_ones(), 4);
let all: Vec<_> = (0..MAX_TORCH_CASTERS).map(|i| (i, Vec3::X * i as f32)).collect();
let all: Vec<_> = (0..MAX_TORCH_CASTERS)
.map(|i| (i, Vec3::X * i as f32))
.collect();
assert_eq!(dynamic_set(&all, Vec3::ZERO, 16), 0xff);
assert_eq!(dynamic_set(&[], Vec3::ZERO, 1), 0);
}
@@ -1685,7 +1883,10 @@ mod tests {
// The falloff's pole at d = 0 is floored, so a fixture the player is standing on cannot
// score infinity, take a slot for one frame and hand it straight back.
let at_zero = exterior_fixture_score(1.0, 0.0);
assert!(at_zero.is_finite() && at_zero <= 1.0 / 0.73 + 1e-3, "{at_zero}");
assert!(
at_zero.is_finite() && at_zero <= 1.0 / 0.73 + 1e-3,
"{at_zero}"
);
// …and inside the window it IS the receivers' falloff: 1/(0.7d + 0.03d^2) at 1 yd.
assert!((exterior_fixture_score(1.0, 1.0) - 1.0 / 0.73).abs() < 1e-2);
}
@@ -1728,26 +1929,51 @@ mod tests {
fn the_exterior_budget_is_half_and_caps_only_its_own_lane() {
assert_eq!(exterior_budget(12), 6);
assert_eq!(exterior_budget(16), 8);
assert_eq!(exterior_budget(1), 1, "the smallest budget still gets one outdoor shadow");
assert_eq!(
exterior_budget(1),
1,
"the smallest budget still gets one outdoor shadow"
);
let mut cands: Vec<Candidate> = (0..8)
.map(|i| {
(Entity::from_raw_u32(i).unwrap(), Vec3::ZERO, 8.0 - i as f32, i % 2 == 0, 10.0,
None)
(
Entity::from_raw_u32(i).unwrap(),
Vec3::ZERO,
8.0 - i as f32,
i % 2 == 0,
10.0,
None,
)
})
.collect();
cap_exterior_candidates(&mut cands, 2);
// Identified by score, which is also their rank: 8,7,6,5,4,3,2,1 with the even ranks
// exterior. Capped at two, ranks 8 and 6 (the best two fires) survive; 4 and 2 do not.
let kept: Vec<(u32, bool)> =
cands.iter().map(|(_, _, sc, x, ..)| (*sc as u32, *x)).collect();
let kept: Vec<(u32, bool)> = cands
.iter()
.map(|(_, _, sc, x, ..)| (*sc as u32, *x))
.collect();
assert_eq!(
kept,
vec![(8, true), (7, false), (6, true), (5, false), (3, false), (1, false)],
vec![
(8, true),
(7, false),
(6, true),
(5, false),
(3, false),
(1, false)
],
"the two best exterior fires survive; every interior candidate does"
);
// A cap of zero is a lane switched off, and it takes nothing else with it.
let mut all_ext: Vec<Candidate> =
vec![(Entity::from_raw_u32(0).unwrap(), Vec3::ZERO, 1.0, true, 10.0, None)];
let mut all_ext: Vec<Candidate> = vec![(
Entity::from_raw_u32(0).unwrap(),
Vec3::ZERO,
1.0,
true,
10.0,
None,
)];
cap_exterior_candidates(&mut all_ext, 0);
assert!(all_ext.is_empty());
}
@@ -1759,12 +1985,23 @@ mod tests {
#[test]
fn the_entity_gather_radius_is_the_fixtures_own_reach() {
assert_eq!(entity_gather_radius(12.0), 12.0 + TORCH_ENTITY_REACH_MARGIN);
assert!(entity_gather_radius(12.0) < TORCH_RANGE, "a candle sweeps far less than 48 yd");
assert_eq!(entity_gather_radius(TORCH_RANGE), TORCH_RANGE, "clamped to the cube range");
assert!(
entity_gather_radius(12.0) < TORCH_RANGE,
"a candle sweeps far less than 48 yd"
);
assert_eq!(
entity_gather_radius(TORCH_RANGE),
TORCH_RANGE,
"clamped to the cube range"
);
assert_eq!(entity_gather_radius(1e9), TORCH_RANGE);
assert_eq!(entity_gather_radius(0.0), TORCH_ENTITY_REACH_MARGIN);
assert_eq!(entity_gather_radius(-5.0), TORCH_ENTITY_REACH_MARGIN);
assert_eq!(entity_gather_radius(f32::NAN), TORCH_RANGE, "never a silent zero radius");
assert_eq!(
entity_gather_radius(f32::NAN),
TORCH_RANGE,
"never a silent zero radius"
);
}
// …and the gather admits the UNION of those radii, not one shared radius: a tight candle and a
@@ -1775,11 +2012,23 @@ mod tests {
fn the_gather_admits_the_union_of_the_fixture_reaches() {
let candle = entity_gather_radius(8.0);
let hearth = entity_gather_radius(30.0);
let set = [(0usize, Vec3::ZERO, candle, None), (1usize, Vec3::X * 60.0, hearth, None)];
let set = [
(0usize, Vec3::ZERO, candle, None),
(1usize, Vec3::X * 60.0, hearth, None),
];
assert!(within_any_reach(&set, Vec3::X * 5.0), "inside the candle");
assert!(within_any_reach(&set, Vec3::X * 40.0), "inside the hearth, not the candle");
assert!(!within_any_reach(&set, Vec3::X * 20.0), "between the two, lit by neither");
assert!(!within_any_reach(&[], Vec3::ZERO), "an empty dynamic set admits nothing");
assert!(
within_any_reach(&set, Vec3::X * 40.0),
"inside the hearth, not the candle"
);
assert!(
!within_any_reach(&set, Vec3::X * 20.0),
"between the two, lit by neither"
);
assert!(
!within_any_reach(&[], Vec3::ZERO),
"an empty dynamic set admits nothing"
);
// The margin is what keeps a body whose ORIGIN sits just outside the pool casting.
assert!(within_any_reach(&set, Vec3::X * (8.0 + 1.0)));
}
@@ -1790,7 +2039,12 @@ mod tests {
// frame into an alternating 1/3-frame stutter.
// MONKEY (moving fixture): a slot for the motion tests — everything but the four fields the
// verdict reads is inert.
fn moving_slot(owner: Option<LightOwner>, built_at: Vec3, pos: Vec3, still_since: f64) -> TorchSlot {
fn moving_slot(
owner: Option<LightOwner>,
built_at: Vec3,
pos: Vec3,
still_since: f64,
) -> TorchSlot {
TorchSlot {
cache_slot: 0,
mesh: None,
@@ -1817,7 +2071,11 @@ mod tests {
#[test]
fn a_fixture_is_moving_only_while_it_is_actually_moving() {
let pet = Some(LightOwner::Instance(Entity::PLACEHOLDER));
let wall = Some(LightOwner::Placement { kind: ModelKind::Wmo, id: 7, label: 0 });
let wall = Some(LightOwner::Placement {
kind: ModelKind::Wmo,
id: 7,
label: 0,
});
let now = 100.0;
// A pet's flame that moved this frame: live.
assert!(moving_slot(pet, Vec3::ZERO, Vec3::ZERO, now).moving(now));
@@ -1849,18 +2107,33 @@ mod tests {
// Equal scores resolve by slot order, both ways round, so the set cannot alternate.
let tied = vec![(5, 1.0), (2, 1.0), (9, 1.0)];
assert_eq!(moving_budget(tied.clone()).0, vec![2, 5]);
assert_eq!(moving_budget(tied.into_iter().rev().collect()).0, vec![2, 5]);
assert_eq!(
moving_budget(tied.into_iter().rev().collect()).0,
vec![2, 5]
);
assert_eq!(TORCH_MOVING_MAX, 2, "the cap the render plan budgets for");
}
#[test]
fn the_entity_gather_cadence_is_a_rate_not_a_gate() {
assert!(entity_gather_due(0, 0.0, 1, 1), "rate 0 = every frame");
assert!(!entity_gather_due(30, 0.0, 1, 1), "same set, no time passed");
assert!(
!entity_gather_due(30, 0.0, 1, 1),
"same set, no time passed"
);
assert!(!entity_gather_due(30, 0.02, 1, 1), "still inside 1/30 s");
assert!(entity_gather_due(30, 1.0 / 30.0, 1, 1), "exactly on the tick");
assert!(entity_gather_due(30, 1.0 / 60.0 * 2.0, 1, 1), "two 60 fps frames make the tick");
assert!(entity_gather_due(30, 0.0, 0b11, 0b01), "a changed dynamic set overrides the clock");
assert!(
entity_gather_due(30, 1.0 / 30.0, 1, 1),
"exactly on the tick"
);
assert!(
entity_gather_due(30, 1.0 / 60.0 * 2.0, 1, 1),
"two 60 fps frames make the tick"
);
assert!(
entity_gather_due(30, 0.0, 0b11, 0b01),
"a changed dynamic set overrides the clock"
);
assert!(entity_gather_due(15, 0.07, 1, 1));
assert!(!entity_gather_due(15, 0.05, 1, 1));
// A rate at or above the frame rate is "every frame" without being a special case.
+311 -93
View File
@@ -1759,47 +1759,95 @@ fn volumetric_fog_dropdown_is_localised_and_live() {
let xml = include_str!("../../assets/ui/OptionsFrame.xml");
let strings = &xml[xml.find("BENILLA_ADVGFX_STRINGS = {").unwrap()
..xml.find("OPTIONS_PAGE_ROWS = {").unwrap()];
let row = xml.split("<Frame name=\"$parentRowVolumetricFog\"").nth(1).unwrap();
let on_load = row.split("<OnLoad>").nth(1).unwrap().split("</OnLoad>").next().unwrap();
let row = xml
.split("<Frame name=\"$parentRowVolumetricFog\"")
.nth(1)
.unwrap();
let on_load = row
.split("<OnLoad>")
.nth(1)
.unwrap()
.split("</OnLoad>")
.next()
.unwrap();
for (locale, title, labels) in [
("enUS", "Volumetric Fog", ["Off", "Low", "High"]),
("ruRU", "Объёмный туман", ["Выкл", "Низкое", "Высокое"]),
] {
let mut s = audio_harness();
s.run(&format!("function GetLocale() return '{locale}' end")).unwrap();
s.run(&format!("function GetLocale() return '{locale}' end"))
.unwrap();
s.run(strings).unwrap();
s.run(r#"
s.run(
r#"
self = {}
function OptionsRow_OnLoad(row, cvar, title, tip)
row.cvar, row.title, row.tip = cvar, title, tip
end
function OptionsDropdownRow_Setup(row, choices) row.choices = choices end
"#).unwrap();
"#,
)
.unwrap();
s.run(on_load).unwrap();
assert_eq!(s.eval::<String>("return self.title").unwrap(), title);
assert!(s.eval::<bool>("return getglobal(self.tip) == BENILLA_ADVGFX.tips.VOLUMETRIC_FOG and string.len(getglobal(self.tip)) > 80").unwrap());
for (tier, label) in labels.iter().enumerate() {
assert_eq!(s.eval::<String>(&format!("return self.choices[{}].text", tier + 1)).unwrap(), *label);
assert_eq!(
s.eval::<String>(&format!("return self.choices[{}].text", tier + 1))
.unwrap(),
*label
);
let _ = s.take_cvar_changes();
s.run(&format!("SetCVar(self.cvar, self.choices[{}].value)", tier + 1)).unwrap();
assert_eq!(s.take_cvar_changes(), vec![("volumetricFog".to_string(), tier.to_string())]);
s.run(&format!(
"SetCVar(self.cvar, self.choices[{}].value)",
tier + 1
))
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("volumetricFog".to_string(), tier.to_string())]
);
}
if benilla_formats::wow_data().is_none() {
continue;
}
// MONKEY (volumetric fog): with an install, also exercise the real widget kit.
let s = audio_harness();
s.run(&format!("function GetLocale() return '{locale}' end")).unwrap();
s.run(&format!("function GetLocale() return '{locale}' end"))
.unwrap();
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame) BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()").unwrap();
assert_eq!(s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[5]").unwrap(), "RowVolumetricFog");
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowVolumetricFogLabel:GetText()")).unwrap(), title);
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowVolumetricFogDropdownText:GetText()")).unwrap(), labels[1]);
assert_eq!(
s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[5]")
.unwrap(),
"RowVolumetricFog"
);
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowVolumetricFogLabel:GetText()"))
.unwrap(),
title
);
assert_eq!(
s.eval::<String>(&format!(
"return {ADVGFX}RowVolumetricFogDropdownText:GetText()"
))
.unwrap(),
labels[1]
);
let _ = s.take_cvar_changes();
for (tier, label) in labels.iter().enumerate() {
s.run(&format!("OptionsRow_Set({ADVGFX}RowVolumetricFog, '{tier}') OptionsDropdown_ShowValue({ADVGFX}RowVolumetricFog)")).unwrap();
assert_eq!(s.take_cvar_changes(), vec![("volumetricFog".to_string(), tier.to_string())]);
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowVolumetricFogDropdownText:GetText()")).unwrap(), *label);
assert_eq!(
s.take_cvar_changes(),
vec![("volumetricFog".to_string(), tier.to_string())]
);
assert_eq!(
s.eval::<String>(&format!(
"return {ADVGFX}RowVolumetricFogDropdownText:GetText()"
))
.unwrap(),
*label
);
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -1812,31 +1860,55 @@ fn ambient_occlusion_dropdown_is_localised_and_live() {
let xml = include_str!("../../assets/ui/OptionsFrame.xml");
let strings = &xml[xml.find("BENILLA_ADVGFX_STRINGS = {").unwrap()
..xml.find("OPTIONS_PAGE_ROWS = {").unwrap()];
let row = xml.split("<Frame name=\"$parentRowAmbientOcclusion\"").nth(1).unwrap();
let on_load = row.split("<OnLoad>").nth(1).unwrap().split("</OnLoad>").next().unwrap();
let row = xml
.split("<Frame name=\"$parentRowAmbientOcclusion\"")
.nth(1)
.unwrap();
let on_load = row
.split("<OnLoad>")
.nth(1)
.unwrap()
.split("</OnLoad>")
.next()
.unwrap();
for (locale, title, labels) in [
("enUS", "Ambient Occlusion", ["Off", "Low", "High"]),
("ruRU", "Затенение окружения", ["Выкл", "Низкое", "Высокое"]),
] {
let mut s = audio_harness();
s.run(&format!("function GetLocale() return '{locale}' end")).unwrap();
s.run(&format!("function GetLocale() return '{locale}' end"))
.unwrap();
s.run(strings).unwrap();
s.run(r#"
s.run(
r#"
self = {}
function OptionsRow_OnLoad(row, cvar, title, tip)
row.cvar, row.title, row.tip = cvar, title, tip
end
function OptionsDropdownRow_Setup(row, choices) row.choices = choices end
"#).unwrap();
"#,
)
.unwrap();
s.run(on_load).unwrap();
assert_eq!(s.eval::<String>("return self.title").unwrap(), title);
assert!(s.eval::<bool>("return getglobal(self.tip) == BENILLA_ADVGFX.tips.AMBIENT_OCCLUSION and string.len(getglobal(self.tip)) > 80").unwrap());
// High first: the registered default is Off, and rewriting the same value is no change.
for (tier, label) in labels.iter().enumerate().rev() {
assert_eq!(s.eval::<String>(&format!("return self.choices[{}].text", tier + 1)).unwrap(), *label);
assert_eq!(
s.eval::<String>(&format!("return self.choices[{}].text", tier + 1))
.unwrap(),
*label
);
let _ = s.take_cvar_changes();
s.run(&format!("SetCVar(self.cvar, self.choices[{}].value)", tier + 1)).unwrap();
assert_eq!(s.take_cvar_changes(), vec![("ambientOcclusion".to_string(), tier.to_string())]);
s.run(&format!(
"SetCVar(self.cvar, self.choices[{}].value)",
tier + 1
))
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("ambientOcclusion".to_string(), tier.to_string())]
);
}
}
}
@@ -1847,8 +1919,17 @@ fn lamp_fog_dropdown_is_localised_and_live() {
let xml = include_str!("../../assets/ui/OptionsFrame.xml");
let strings = &xml[xml.find("BENILLA_ADVGFX_STRINGS = {").unwrap()
..xml.find("OPTIONS_PAGE_ROWS = {").unwrap()];
let row = xml.split("<Frame name=\"$parentRowLampFog\"").nth(1).unwrap();
let on_load = row.split("<OnLoad>").nth(1).unwrap().split("</OnLoad>").next().unwrap();
let row = xml
.split("<Frame name=\"$parentRowLampFog\"")
.nth(1)
.unwrap();
let on_load = row
.split("<OnLoad>")
.nth(1)
.unwrap()
.split("</OnLoad>")
.next()
.unwrap();
for (locale, title, labels) in [
("enUS", "Lamp Fog", ["Off", "Low", "High"]),
(
@@ -1907,25 +1988,61 @@ fn water_quality_writes_numeric_tiers_with_localised_labels() {
benilla_formats::wow_data_or_skip!();
for (locale, labels, lava_label) in [
("enUS", ["Classic", "Enhanced", "High"], "Lava Glow"),
("ruRU", ["Классическое", "Улучшенное", "Высокое"], "Свечение лавы"),
(
"ruRU",
["Классическое", "Улучшенное", "Высокое"],
"Свечение лавы",
),
] {
let s = audio_harness();
s.run(&format!("function GetLocale() return '{locale}' end")).unwrap();
s.run(&format!("function GetLocale() return '{locale}' end"))
.unwrap();
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame) BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()").unwrap();
// MONKEY (volumetric fog): account for the atmosphere row after water.
// MONKEY (integration): Graphics Preset + Render Distance lead the page, then every programme row (sky, post, dither, fog, wet, wind, ao, lamp fog, window split, zone skyboxes): 33.
assert_eq!(s.eval::<usize>("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)").unwrap(), 33);
assert_eq!(s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[4]").unwrap(), "RowWaterQuality");
assert_eq!(s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[23]").unwrap(), "RowLavaGlow");
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowWaterQualityDropdownText:GetText()")).unwrap(), labels[1]);
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowLavaGlowLabel:GetText()")).unwrap(), lava_label);
assert_eq!(
s.eval::<usize>("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)")
.unwrap(),
33
);
assert_eq!(
s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[4]")
.unwrap(),
"RowWaterQuality"
);
assert_eq!(
s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[23]")
.unwrap(),
"RowLavaGlow"
);
assert_eq!(
s.eval::<String>(&format!(
"return {ADVGFX}RowWaterQualityDropdownText:GetText()"
))
.unwrap(),
labels[1]
);
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowLavaGlowLabel:GetText()"))
.unwrap(),
lava_label
);
assert!(s.eval::<bool>("return BENILLA_TOOLTIP_WATER_QUALITY == BENILLA_ADVGFX.tips.WATER_QUALITY and BENILLA_TOOLTIP_LAVA_GLOW == BENILLA_ADVGFX.tips.LAVA_GLOW").unwrap());
let _ = s.take_cvar_changes();
for (tier, label) in labels.iter().enumerate() {
s.run(&format!("OptionsRow_Set({ADVGFX}RowWaterQuality, '{tier}') OptionsDropdown_ShowValue({ADVGFX}RowWaterQuality)")).unwrap();
assert_eq!(s.take_cvar_changes(), vec![("waterQuality".to_string(), tier.to_string())]);
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowWaterQualityDropdownText:GetText()")).unwrap(), *label);
assert_eq!(
s.take_cvar_changes(),
vec![("waterQuality".to_string(), tier.to_string())]
);
assert_eq!(
s.eval::<String>(&format!(
"return {ADVGFX}RowWaterQualityDropdownText:GetText()"
))
.unwrap(),
*label
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -1939,20 +2056,33 @@ fn advanced_graphics_fire_spell_and_lava_sliders_read_and_write_the_full_range()
s.set_cvar_host("spellLightGain", "3.25");
s.set_cvar_host("lavaLightGain", "3.25");
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame) \
BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
.unwrap();
s.run(
"ShowUIPanel(BenillaOptionsFrame) \
BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()",
)
.unwrap();
let _ = s.take_cvar_changes();
for (row, cvar) in [("RowFireLight", "fireLightGain"), ("RowSpellLights", "spellLightGain"), ("RowLavaGlow", "lavaLightGain")] {
assert_eq!(s.eval::<f32>(&format!(
"return {ADVGFX}{row}ControlSlider:GetValue()"
)).unwrap(), 3.25);
assert_eq!(s.eval::<String>(&format!(
"return {ADVGFX}{row}ControlValue:GetText()"
)).unwrap(), "325%");
for (row, cvar) in [
("RowFireLight", "fireLightGain"),
("RowSpellLights", "spellLightGain"),
("RowLavaGlow", "lavaLightGain"),
] {
assert_eq!(
s.eval::<f32>(&format!("return {ADVGFX}{row}ControlSlider:GetValue()"))
.unwrap(),
3.25
);
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}{row}ControlValue:GetText()"))
.unwrap(),
"325%"
);
for value in ["4", "2.75", "0", "0.05"] {
s.run(&format!("{ADVGFX}{row}ControlSlider:SetValue({value})")).unwrap();
assert!(s.take_cvar_changes().contains(&(cvar.to_string(), value.to_string())));
s.run(&format!("{ADVGFX}{row}ControlSlider:SetValue({value})"))
.unwrap();
assert!(s
.take_cvar_changes()
.contains(&(cvar.to_string(), value.to_string())));
assert_eq!(s.cvar(cvar).as_deref(), Some(value));
}
}
@@ -1968,20 +2098,31 @@ fn advanced_graphics_selects_its_locale_at_load_including_yards() {
("deDE", "Advanced Graphics", "80 yd"),
] {
let s = audio_harness();
s.run(&format!("function GetLocale() return '{locale}' end")).unwrap();
let s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame) \
BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
s.run(&format!("function GetLocale() return '{locale}' end"))
.unwrap();
assert_eq!(s.eval::<String>(
"return BenillaOptionsFrameContainerTitle:GetText()"
).unwrap(), title);
assert_eq!(s.eval::<String>(&format!(
"return {ADVGFX}RowShadowDistanceControlValue:GetText()"
)).unwrap(), distance);
assert!(s.eval::<bool>(
"return BENILLA_TOOLTIP_NIGHT_DARKNESS == BENILLA_ADVGFX.tips.NIGHT_DARKNESS"
).unwrap());
let s = harness_on(s);
s.run(
"ShowUIPanel(BenillaOptionsFrame) \
BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()",
)
.unwrap();
assert_eq!(
s.eval::<String>("return BenillaOptionsFrameContainerTitle:GetText()")
.unwrap(),
title
);
assert_eq!(
s.eval::<String>(&format!(
"return {ADVGFX}RowShadowDistanceControlValue:GetText()"
))
.unwrap(),
distance
);
assert!(s
.eval::<bool>(
"return BENILLA_TOOLTIP_NIGHT_DARKNESS == BENILLA_ADVGFX.tips.NIGHT_DARKNESS"
)
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
}
@@ -2022,22 +2163,30 @@ fn the_advanced_graphics_page_reads_the_lighting_cvars_on_select() {
// The dropdowns: the ladder reads the registered default, the resolution reads the seed.
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowLightingQualityDropdownText:GetText()"))
.unwrap(),
s.eval::<String>(&format!(
"return {ADVGFX}RowLightingQualityDropdownText:GetText()"
))
.unwrap(),
"High",
"a fresh registry is the High preset, member for member (cvars::tests)"
);
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowShadowResolutionDropdownText:GetText()"))
.unwrap(),
s.eval::<String>(&format!(
"return {ADVGFX}RowShadowResolutionDropdownText:GetText()"
))
.unwrap(),
"4096"
);
// The checkboxes read the table, not a restated default.
assert!(s
.eval::<bool>(&format!("return {ADVGFX}RowCharacterShadowsCheck:GetChecked()"))
.eval::<bool>(&format!(
"return {ADVGFX}RowCharacterShadowsCheck:GetChecked()"
))
.unwrap());
assert!(!s
.eval::<bool>(&format!("return {ADVGFX}RowInteriorShadowsCheck:GetChecked()"))
.eval::<bool>(&format!(
"return {ADVGFX}RowInteriorShadowsCheck:GetChecked()"
))
.unwrap());
// The sliders, each on its own registry clamp with its own readout grammar.
for (row, text) in [
@@ -2063,7 +2212,11 @@ fn the_advanced_graphics_page_reads_the_lighting_cvars_on_select() {
);
// MOVED, not copied: the Graphics page has no shadow rows left.
for row in ["RowCharacterShadows", "RowWorldShadows", "RowShadowDistance"] {
for row in [
"RowCharacterShadows",
"RowWorldShadows",
"RowShadowDistance",
] {
assert!(
!s.eval::<bool>(&format!(
"return BenillaOptionsFrameContainerBodyGraphics{row} ~= nil"
@@ -2103,8 +2256,10 @@ fn the_lighting_quality_row_writes_one_cvar_and_leaves_the_members_to_the_host()
"one write, and the value is the engine's own string — never the localised label"
);
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowLightingQualityDropdownText:GetText()"))
.unwrap(),
s.eval::<String>(&format!(
"return {ADVGFX}RowLightingQualityDropdownText:GetText()"
))
.unwrap(),
"Low"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -2124,19 +2279,28 @@ fn the_darkness_sliders_read_out_so_that_left_is_darker() {
// The registered default, 0.45 of 0.2..1.5, is (1.5 − 0.45) / 1.3 = 81 % of the way dark.
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowNightDarknessControlValue:GetText()"))
.unwrap(),
s.eval::<String>(&format!(
"return {ADVGFX}RowNightDarknessControlValue:GetText()"
))
.unwrap(),
"81%"
);
// The darkest gain reads 100 %, the brightest 0 %; the reference night is gain 1.0.
for (value, text) in [("0.2", "100%"), ("1.5", "0%"), ("1.0", "38%"), ("0.85", "50%")] {
for (value, text) in [
("0.2", "100%"),
("1.5", "0%"),
("1.0", "38%"),
("0.85", "50%"),
] {
s.run(&format!(
"{ADVGFX}RowNightDarknessControlSlider:SetValue({value})"
))
.unwrap();
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowNightDarknessControlValue:GetText()"))
.unwrap(),
s.eval::<String>(&format!(
"return {ADVGFX}RowNightDarknessControlValue:GetText()"
))
.unwrap(),
text,
"nightGain {value}"
);
@@ -2148,8 +2312,10 @@ fn the_darkness_sliders_read_out_so_that_left_is_darker() {
"the slider writes the gain the engine reads, not the percentage it shows"
);
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowInteriorDarknessControlValue:GetText()"))
.unwrap(),
s.eval::<String>(&format!(
"return {ADVGFX}RowInteriorDarknessControlValue:GetText()"
))
.unwrap(),
"77%",
"interiorGain 0.5 of 0.2..1.5"
);
@@ -2196,8 +2362,10 @@ fn the_advanced_graphics_masters_grey_what_they_gate() {
.unwrap();
assert!(live(&s, "RowShadowResolution"));
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowShadowDistanceControlValue:GetText()"))
.unwrap(),
s.eval::<String>(&format!(
"return {ADVGFX}RowShadowDistanceControlValue:GetText()"
))
.unwrap(),
"80 yd"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -2217,7 +2385,9 @@ fn the_page_repaints_when_a_host_write_moves_a_row_under_it() {
// The page has to be effectively visible before its OnUpdate is in the sweep at all.
s.resolve();
assert!(s
.eval::<bool>(&format!("return {ADVGFX}RowExteriorShadowsCheck:GetChecked()"))
.eval::<bool>(&format!(
"return {ADVGFX}RowExteriorShadowsCheck:GetChecked()"
))
.unwrap());
// What the host does when a preset's members land: a mirror write, with no CVAR_UPDATE and no
@@ -2229,20 +2399,36 @@ fn the_page_repaints_when_a_host_write_moves_a_row_under_it() {
// Under the poll interval: nothing has happened yet.
s.tick(0.1);
assert!(s
.eval::<bool>(&format!("return {ADVGFX}RowExteriorShadowsCheck:GetChecked()"))
.eval::<bool>(&format!(
"return {ADVGFX}RowExteriorShadowsCheck:GetChecked()"
))
.unwrap());
// Past it: one refresh, and the page tells the truth again.
s.tick(0.2);
assert!(!s
.eval::<bool>(&format!("return {ADVGFX}RowExteriorShadowsCheck:GetChecked()"))
.eval::<bool>(&format!(
"return {ADVGFX}RowExteriorShadowsCheck:GetChecked()"
))
.unwrap());
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowMoonShadowsControlValue:GetText()"))
.unwrap(),
s.eval::<String>(&format!(
"return {ADVGFX}RowMoonShadowsControlValue:GetText()"
))
.unwrap(),
"0%"
);
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowWaterQualityDropdownText:GetText()")).unwrap(), "High");
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowLavaGlowControlValue:GetText()")).unwrap(), "50%");
assert_eq!(
s.eval::<String>(&format!(
"return {ADVGFX}RowWaterQualityDropdownText:GetText()"
))
.unwrap(),
"High"
);
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowLavaGlowControlValue:GetText()"))
.unwrap(),
"50%"
);
// The refresh writes NOTHING back — it is a read of the table, not a round trip.
assert!(
s.take_cvar_changes().is_empty(),
@@ -2793,22 +2979,46 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
// string describes a checkbox, and `OPTION_TOOLTIP_GAMMA` cites art this page lacks.
const BENILLA_OWNED: &[(&str, &str)] = &[
// MONKEY (volumetric fog): the row owns a translated tooltip too.
("BENILLA_TOOLTIP_VOLUMETRIC_FOG", "AdvancedGraphicsRowVolumetricFog"),
(
"BENILLA_TOOLTIP_VOLUMETRIC_FOG",
"AdvancedGraphicsRowVolumetricFog",
),
// MONKEY (sky): the sky tier row owns its translated tooltip.
("BENILLA_TOOLTIP_SKY_QUALITY", "AdvancedGraphicsRowSkyQuality"),
(
"BENILLA_TOOLTIP_SKY_QUALITY",
"AdvancedGraphicsRowSkyQuality",
),
// MONKEY (ao): the contact-shadow row's translated tooltip.
("BENILLA_TOOLTIP_AMBIENT_OCCLUSION", "AdvancedGraphicsRowAmbientOcclusion"),
(
"BENILLA_TOOLTIP_AMBIENT_OCCLUSION",
"AdvancedGraphicsRowAmbientOcclusion",
),
// MONKEY (lampfog): the quality row owns a translated tooltip too.
("BENILLA_TOOLTIP_LAMP_FOG", "AdvancedGraphicsRowLampFog"),
// MONKEY (integration): the daylight window split row.
("BENILLA_TOOLTIP_DAYLIGHT_WINDOW_SPLIT", "AdvancedGraphicsRowDaylightWindowSplit"),
(
"BENILLA_TOOLTIP_DAYLIGHT_WINDOW_SPLIT",
"AdvancedGraphicsRowDaylightWindowSplit",
),
// MONKEY (skybox): the zone skybox row.
("BENILLA_TOOLTIP_ZONE_SKYBOXES", "AdvancedGraphicsRowZoneSkyboxes"),
(
"BENILLA_TOOLTIP_ZONE_SKYBOXES",
"AdvancedGraphicsRowZoneSkyboxes",
),
// GFX (volumetric light) / (moonlight)
("BENILLA_TOOLTIP_VOLUMETRIC_LIGHT", "AdvancedGraphicsRowVolumetricLight"),
("BENILLA_TOOLTIP_VOLUMETRIC_LIGHT_STRENGTH", "AdvancedGraphicsRowVolumetricLightStrength"),
(
"BENILLA_TOOLTIP_VOLUMETRIC_LIGHT",
"AdvancedGraphicsRowVolumetricLight",
),
(
"BENILLA_TOOLTIP_VOLUMETRIC_LIGHT_STRENGTH",
"AdvancedGraphicsRowVolumetricLightStrength",
),
("BENILLA_TOOLTIP_MOONLIGHT", "AdvancedGraphicsRowMoonLight"),
("BENILLA_TOOLTIP_WATER_QUALITY", "AdvancedGraphicsRowWaterQuality"),
(
"BENILLA_TOOLTIP_WATER_QUALITY",
"AdvancedGraphicsRowWaterQuality",
),
("BENILLA_TOOLTIP_LAVA_GLOW", "AdvancedGraphicsRowLavaGlow"),
("BENILLA_TOOLTIP_RENDER_SCALE", "GraphicsRowRenderScale"),
("BENILLA_TOOLTIP_DISPLAY_MODE", "GraphicsRowDisplayMode"),
@@ -3881,7 +4091,11 @@ fn the_defaults_button_is_armed_by_rows_not_by_a_category() {
// page, because the era client has no dynamic light and shadow system to put on one. It is
// benilla's own category, seated under Graphics in the System group, and it is held to the
// same bar as the era's nine: it opens onto rows, and Defaults is live on it.
assert_eq!(keys.len(), 10, "the nine 1.15.9 categories + ours: {keys:?}");
assert_eq!(
keys.len(),
10,
"the nine 1.15.9 categories + ours: {keys:?}"
);
for key in &keys {
let has_rows = s
.eval::<bool>(&format!(
@@ -5081,5 +5295,9 @@ fn farclip_slider_range_is_the_view_range() {
assert_eq!(nums[0], *range.start(), "slider min");
assert_eq!(nums[1], benilla_world::view::FARCLIP_MAX, "slider max");
assert_eq!(nums[1], *range.end());
assert_eq!((nums[1] - nums[0]) % nums[2], 0.0, "the step lands on the maximum");
assert_eq!(
(nums[1] - nums[0]) % nums[2],
0.0,
"the step lands on the maximum"
);
}
@@ -456,6 +456,9 @@ pub(super) const CHARACTER_UI: &[&str] = &[
r"Interface\FrameXML\ReputationFrame.xml",
"Interface\\FrameXML\\SkillFrame.xml",
"Interface\\FrameXML\\HonorFrame.xml",
// Ours: the sixth page TurtleWoW's `CharacterFrame.lua` lists and 1.12 never shipped. It loads
// here for the reason it does in `benilla.toc`: the subframe list is swept unguarded.
"CharacterFrameAdapters.xml",
];
/// The manifest slice stock `FriendsFrame.xml` and `RaidFrame.xml` reach at load or on show, in its
+1 -2
View File
@@ -524,8 +524,7 @@ pub(crate) fn on_cvar(
// `0` is MEANINGFUL on both rate rows (the pre-cvar every-frame rebuild), so they floor at
// 0 rather than at 1 — the shadow off-switches are `characterShadows` / `worldShadows`.
"charactershadowrate" => {
cfg.character_shadow_rate =
(v.max(0.0) as u32).min(crate::shadow_core::MAX_SHADOW_RATE)
cfg.character_shadow_rate = (v.max(0.0) as u32).min(crate::shadow_core::MAX_SHADOW_RATE)
}
"worldshadowrate" => {
cfg.world_shadow_rate = (v.max(0.0) as u32).min(crate::shadow_core::MAX_SHADOW_RATE)
+36 -25
View File
@@ -15,18 +15,17 @@ use benilla_world::{
use bevy::{
camera::primitives::{Frustum, Sphere as CullSphere},
core_pipeline::{
FullscreenShader,
core_3d::graph::{Core3d, Node3d},
FullscreenShader,
},
ecs::query::QueryItem,
light::VolumetricLight,
pbr::{
GpuLights, LightMeta, MAX_CASCADES_PER_LIGHT, MAX_DIRECTIONAL_LIGHTS,
ViewLightsUniformOffset, ViewShadowBindings,
GpuLights, LightMeta, ViewLightsUniformOffset, ViewShadowBindings, MAX_CASCADES_PER_LIGHT,
MAX_DIRECTIONAL_LIGHTS,
},
prelude::*,
render::{
Render, RenderApp, RenderStartup, RenderSystems,
diagnostic::RecordDiagnostics,
extract_component::{ExtractComponent, ExtractComponentPlugin},
render_graph::{
@@ -35,6 +34,7 @@ use bevy::{
render_resource::{binding_types::*, *},
renderer::{RenderContext, RenderDevice, RenderQueue},
view::{Msaa, ViewDepthTexture, ViewTarget, ViewUniform, ViewUniformOffset, ViewUniforms},
Render, RenderApp, RenderStartup, RenderSystems,
},
shader::ShaderDefVal,
};
@@ -101,16 +101,17 @@ impl Plugin for VolumetricFogPlugin {
.filter(|v| *v <= 2);
// Dev capture instrument: isolate shafts without changing surface shadows or haze.
// MONKEY (integration): the dev door is `run_mode` (decision 1179).
let shaft_gain = if crate::run_mode::dev_affordances() && std::env::var_os("WOW_CAPTURE").is_some() {
std::env::var("WOW_VOLFOG_SHAFT_GAIN")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.filter(|v| v.is_finite())
.unwrap_or(2.0)
.clamp(0.0, 8.0)
} else {
2.0
};
let shaft_gain =
if crate::run_mode::dev_affordances() && std::env::var_os("WOW_CAPTURE").is_some() {
std::env::var("WOW_VOLFOG_SHAFT_GAIN")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.filter(|v| v.is_finite())
.unwrap_or(2.0)
.clamp(0.0, 8.0)
} else {
2.0
};
app.insert_resource(FogOverride {
fog: value,
lamp,
@@ -324,10 +325,17 @@ fn update_fog(
&& water
.iter()
.any(|w| w.ocean_within(eye[0], eye[1], SEA_PLANE_REACH));
if over && tier != 0 { 1.0 } else { 0.0 }
if over && tier != 0 {
1.0
} else {
0.0
}
});
// MONKEY (fog): rows 1-3 as packed for the light buffer.
let rows = monkey.as_ref().map_or([[0.0; 4]; benilla_world::lighting::MONKEY_FRAME_ROWS], |m| m.pack(0.0, 0.0));
let rows = monkey.as_ref().map_or(
[[0.0; 4]; benilla_world::lighting::MONKEY_FRAME_ROWS],
|m| m.pack(0.0, 0.0),
);
commands.entity(entity).insert(FogView {
mf_fog1: Vec4::from_array(rows[1]),
mf_fog2: Vec4::from_array(rows[2]),
@@ -340,14 +348,13 @@ fn update_fog(
crate::post::grading::rendered_minute(&clock, rendered.as_deref()),
weather_amount,
interior.0.is_some(),
)
* if tier == 0 {
0.0
} else if tier == 2 {
1.6
} else {
1.0
},
) * if tier == 0 {
0.0
} else if tier == 2 {
1.6
} else {
1.0
},
),
sun_strength: Vec3::from_array(lighting.diffuse)
.lerp(Vec3::ONE, 0.5)
@@ -486,7 +493,11 @@ fn prepare_pipelines(
// compile happens under the entry cover and never live when the player turns the row on.
// (Named in `pipe_warm/menagerie.rs`'s custom-lane census.)
for (target, msaa) in &all_views {
specialized.specialize(&cache, &pipeline, (target.main_texture_format(), msaa.samples() > 1));
specialized.specialize(
&cache,
&pipeline,
(target.main_texture_format(), msaa.samples() > 1),
);
}
for (entity, target, msaa) in &views {
let id = specialized.specialize(
+43 -12
View File
@@ -41,17 +41,16 @@ use benilla_world::{
};
use bevy::{
core_pipeline::{
FullscreenShader,
core_3d::graph::{Core3d, Node3d},
FullscreenShader,
},
ecs::query::QueryItem,
pbr::{
GpuLights, LightMeta, MAX_CASCADES_PER_LIGHT, MAX_DIRECTIONAL_LIGHTS,
ViewLightsUniformOffset, ViewShadowBindings,
GpuLights, LightMeta, ViewLightsUniformOffset, ViewShadowBindings, MAX_CASCADES_PER_LIGHT,
MAX_DIRECTIONAL_LIGHTS,
},
prelude::*,
render::{
Render, RenderApp, RenderStartup, RenderSystems,
camera::ExtractedCamera,
diagnostic::RecordDiagnostics,
extract_component::{ExtractComponent, ExtractComponentPlugin},
@@ -62,6 +61,7 @@ use bevy::{
renderer::{RenderContext, RenderDevice, RenderQueue},
texture::{CachedTexture, TextureCache},
view::{Msaa, ViewDepthTexture, ViewTarget, ViewUniform, ViewUniformOffset, ViewUniforms},
Render, RenderApp, RenderStartup, RenderSystems,
},
shader::ShaderDefVal,
};
@@ -259,7 +259,12 @@ fn update_views(
) {
let tier = override_tier.tier(&video);
let tune = tune.map_or(
VolLightTune { density: 1.0, height: HEIGHT_FALLOFF, gain: 1.0, debug: 0.0 },
VolLightTune {
density: 1.0,
height: HEIGHT_FALLOFF,
gain: 1.0,
debug: 0.0,
},
|t| *t,
);
let strength = video.volumetric_light_strength.clamp(0.0, 2.0);
@@ -323,9 +328,14 @@ struct VolLightPipeline {
/// Which of the three stages, plus the keys each is specialised on.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum VolLightKey {
March { multisampled: bool },
March {
multisampled: bool,
},
Blur,
Composite { format: TextureFormat, multisampled: bool },
Composite {
format: TextureFormat,
multisampled: bool,
},
}
#[derive(Component)]
@@ -444,7 +454,12 @@ impl SpecializedRenderPipeline for VolLightPipeline {
}
VolLightKey::Blur => {
defs.push("VL_BLUR".into());
("vol_light_blur", self.blur_layout.clone(), "fs_blur", HALF_FORMAT)
(
"vol_light_blur",
self.blur_layout.clone(),
"fs_blur",
HALF_FORMAT,
)
}
VolLightKey::Composite {
format,
@@ -658,7 +673,13 @@ impl ViewNode for VolLightNode {
&march_bind,
&[view_offset.offset, light_offset.offset],
),
("vol_light_blur", &half.blur.default_view, blur, &blur_bind, &[]),
(
"vol_light_blur",
&half.blur.default_view,
blur,
&blur_bind,
&[],
),
(
"vol_light_composite",
out.destination,
@@ -715,9 +736,15 @@ mod tests {
let clear = extinction(720.0, 0.0, 1400.0);
let foggy = extinction(720.0, 0.0, 120.0);
assert!(foggy > clear * 2.0);
assert_eq!(extinction(720.0, 0.0, 0.0), crate::volumetric_fog::density(720.0, 0.0, false));
assert_eq!(
extinction(720.0, 0.0, 0.0),
crate::volumetric_fog::density(720.0, 0.0, false)
);
let white = fog_tinted(Vec3::ONE, Vec3::new(0.5, 0.5, 0.5));
assert!((white - Vec3::ONE).length() < 1e-5, "a grey fog does not tint");
assert!(
(white - Vec3::ONE).length() < 1e-5,
"a grey fog does not tint"
);
let blue = fog_tinted(Vec3::ONE, Vec3::new(0.2, 0.3, 0.8));
assert!(blue.z > blue.x, "a blue fog leans the shafts blue");
}
@@ -725,7 +752,11 @@ mod tests {
#[test]
fn tiers_disable_classic_and_the_override_wins() {
let mut video = VideoConfig::default();
assert_eq!(VolLightOverride(None).tier(&video), 0, "registered default is off");
assert_eq!(
VolLightOverride(None).tier(&video),
0,
"registered default is off"
);
video.volumetric_light = 2;
assert_eq!(VolLightOverride(None).tier(&video), 2);
assert_eq!(VolLightOverride(Some(1)).tier(&video), 1);
+14 -6
View File
@@ -225,7 +225,9 @@ fn update_world_shadows(
// Its own cvar rather than sharing `characterShadowRate`: this population barely moves, so it
// tolerates a far lower rate than an animated crowd does, and a dial named for characters that
// silently also governs the world is a trap for whoever reads this next.
let env_due = lane.env_rate.due(time.elapsed_secs(), video.world_shadow_rate);
let env_due = lane
.env_rate
.due(time.elapsed_secs(), video.world_shadow_rate);
if let Some(handle) = lane.env_mesh.clone().filter(|_| env_due) {
if let Some(mesh) = meshes.get_mut(&handle) {
let (mut positions, mut indices) = take_mesh_buffers(mesh);
@@ -269,9 +271,9 @@ fn update_world_shadows(
lane.static_mesh = Some(mesh);
lane.static_rebuilt_at = None;
}
let due = lane
.static_rebuilt_at
.map_or(true, |at| at.distance(frame.light_position) > STATIC_REBUILD_STEP);
let due = lane.static_rebuilt_at.map_or(true, |at| {
at.distance(frame.light_position) > STATIC_REBUILD_STEP
});
if !due {
return;
}
@@ -322,8 +324,14 @@ fn update_world_shadows(
ShadowCaster,
))
.id();
lane.cutout
.insert(bucket.texture_id, CutoutCaster { entity, mesh, material });
lane.cutout.insert(
bucket.texture_id,
CutoutCaster {
entity,
mesh,
material,
},
);
}
let mesh_handle = lane.cutout[&bucket.texture_id].mesh.clone();
if let Some(mesh) = meshes.get_mut(&mesh_handle) {
+134
View File
@@ -0,0 +1,134 @@
//! TEMPORARY repro harness — not for commit. Loads the stock MailFrame chain from loose mirror
//! files under /tmp/opencode/vanilla-ui and drives the open-letter path, printing Lua errors.
use benilla_ui::script::{MailInboxRow, MailState, UiScript};
const UI: &str = "/tmp/opencode/vanilla-ui";
const FILES: &[&str] = &[
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\MoneyInputFrame.lua",
r"Interface\FrameXML\MoneyInputFrame.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
r"Interface\FrameXML\UIParent.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\GameTooltip.xml",
r"Interface\FrameXML\ItemButtonTemplate.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\FriendsFrame.xml",
"Interface\\FrameXML\\MailFrame.xml",
];
fn load_ui(script: &UiScript) {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
let read = |req: &str| -> Option<Vec<u8>> {
let p = std::path::Path::new(UI).join(req.replace('\\', "/"));
if let Ok(b) = std::fs::read(&p) {
return Some(b);
}
std::fs::read(dir.join(req)).ok()
};
for entry in FILES {
let bytes = read(entry).unwrap_or_else(|| panic!("{entry}: not found"));
if entry.to_ascii_lowercase().ends_with(".lua") {
script
.run_chunk_named(&bytes, &format!("@{entry}"))
.unwrap_or_else(|e| panic!("{entry}: {e}"));
continue;
}
let doc = benilla_ui::framexml::parse(&benilla_ui::source::decode(&bytes))
.unwrap_or_else(|e| panic!("{entry}: {e}"));
let report = benilla_ui::loader::load_in(script, &doc, &entry.replace('\\', "/"), &read);
assert!(
report.errors.is_empty(),
"{entry}: loader errors: {:#?}",
report.errors
);
let leaf = entry.rsplit(['\\', '/']).next().unwrap_or("");
if leaf.eq_ignore_ascii_case("UIParent.xml") && entry.contains('\\') {
script
.run(
"FCF_OnUpdate = FCF_OnUpdate or function() end \
FCF_DockUpdate = FCF_DockUpdate or function() end \
UnitPopup_OnUpdate = UnitPopup_OnUpdate or function() end \
BattlefieldFrame_OnUpdate = BattlefieldFrame_OnUpdate or function() end \
MultiActionBar_Update = MultiActionBar_Update or function() end \
RaidOptionsFrame_UpdatePartyFrames = RaidOptionsFrame_UpdatePartyFrames or function() end \
LocalizeFrames = LocalizeFrames or function() end \
updateContainerFrameAnchors = updateContainerFrameAnchors or function() end \
UpdateNameplates = UpdateNameplates or function() end \
CloseAllBags = CloseAllBags or function() end \
OpenBackpack = OpenBackpack or function() end \
CloseBackpack = CloseBackpack or function() end \
NUM_CONTAINER_FRAMES = NUM_CONTAINER_FRAMES or 0 \
CONTAINER_OFFSET_X = CONTAINER_OFFSET_X or 0 \
CONTAINER_OFFSET_Y = CONTAINER_OFFSET_Y or 70 \
BATTLEFIELD_TAB_OFFSET_Y = BATTLEFIELD_TAB_OFFSET_Y or 210 \
PETACTIONBAR_YPOS = PETACTIONBAR_YPOS or 98 \
PETACTIONBAR_XPOS = PETACTIONBAR_XPOS or 36",
)
.unwrap();
}
}
}
fn row(sender: Option<&str>, subject: &str, money: u32, cod: u32) -> MailInboxRow {
MailInboxRow {
package_icon: None,
stationery_icon: Some("Interface\\Icons\\INV_Misc_Note_01".to_string()),
sender: sender.map(str::to_string),
subject: subject.to_string(),
money,
cod,
days_left: 29.0,
item_count: 0,
was_read: false,
was_returned: false,
text_created: false,
can_reply: sender.is_some(),
is_gm: false,
body: Some("body".into()),
stationery_texture: "STATIONERYTEST".into(),
is_invoice: false,
invoice: None,
has_body: true,
item_id: 0,
item_name: None,
item_texture: None,
item_quality: None,
can_delete: false,
item_random_property_id: 0,
}
}
#[test]
fn repro() {
// A stale openMailID: open row 1, then the inbox re-lists smaller (row purged), keeping the
// letter open; MAIL_INBOX_UPDATE re-runs InboxFrame_Update + OpenMail_Update on the stale id.
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_mail(Some(MailState {
inbox: vec![row(None, "Welcome", 0, 0), row(Some("Jaina"), "second", 0, 0)],
}));
s.fire_event("MAIL_SHOW", vec![]);
s.fire_event("MAIL_INBOX_UPDATE", vec![]);
let _ = s.take_errors();
s.run("MailItem1Button:Click()").unwrap();
let errs = s.take_errors();
eprintln!("errors after click: {errs:#?}");
// The letter stays open while the list empties out (rows purged) — row 1 is now out of range.
s.set_mail(Some(MailState { inbox: vec![] }));
s.fire_event("MAIL_INBOX_UPDATE", vec![]);
let errs = s.take_errors();
eprintln!("errors after empty re-list: {errs:#?}");
assert!(true);
}
+8 -2
View File
@@ -50,8 +50,14 @@ mod tex_filter;
pub use tex_filter::{publish_tex_filter, tex_filter, TexFilterSetting, ANISO_RANGE};
mod m2;
pub use m2::{
EmitterBillboard, M2Model, M2ModelLoader, M2SequenceInfo, ModelEmitter, ModelLight,
ModelRibbon, PortraitCamera,
EmitterBillboard,
M2Model,
M2ModelLoader,
M2SequenceInfo,
ModelEmitter,
ModelLight,
ModelRibbon,
PortraitCamera,
// MONKEY (spell light): what a spell/firework-derived light carries beyond the light itself
// (its school and its onset) — see [`ModelLight::spell`].
SpellLightInfo,
+47 -47
View File
@@ -467,57 +467,57 @@ impl AssetLoader for M2ModelLoader {
// is flagged ([`ModelLight::spell`]) so the PLACED lanes can keep refusing it exactly
// as they do today — only the entity lanes, which reap a light with its effect, take
// one.
let synth = benilla_formats::synthesize_spell_light(
&path,
emitters.iter().map(|e| &e.def),
)
.map(|fx| {
let src = &emitters[fx.emitter].def;
(
src.position,
src.bone,
fx.color,
fx.intensity,
// Never a FLAME for the flicker's purposes: a spell light runs its own
// lifecycle envelope (ramp in / hold / decay) and a fire wobble on top of it
// would read as the effect stuttering, not as fire breathing.
false,
Some(SpellLightInfo {
kind: fx.kind,
onset: fx.onset,
}),
)
})
.or_else(|| {
benilla_formats::synthesize_fire_light(&path, emitters.iter().map(|e| &e.def)).map(
|fire| {
let src = &emitters[fire.emitter].def;
let synth =
benilla_formats::synthesize_spell_light(&path, emitters.iter().map(|e| &e.def))
.map(|fx| {
let src = &emitters[fx.emitter].def;
(
src.position,
src.bone,
fire.color,
fire.intensity,
true,
None,
fx.color,
fx.intensity,
// Never a FLAME for the flicker's purposes: a spell light runs its own
// lifecycle envelope (ramp in / hold / decay) and a fire wobble on top of it
// would read as the effect stuttering, not as fire breathing.
false,
Some(SpellLightInfo {
kind: fx.kind,
onset: fx.onset,
}),
)
},
)
})
.or_else(|| {
// MONKEY (lamp lights): a lamppost/lantern/chandelier authors NO particle emitter
// at all — its glow is an UNLIT (render-flag 0x01) glass geoset. The rule reads the
// already-built render batches, so nothing extra is parsed; the position it returns
// is that geoset's CENTROID (the lamp head, 4-5 yd up a lamppost), never the model
// origin at the base of the pole. `bounds` is the fallback for a name-route hit
// whose glass we can't find — see `fire_light::lamp_position`.
let batches: Vec<benilla_formats::EmissiveBatch<'_>> = submeshes
.iter()
.map(|s| benilla_formats::EmissiveBatch::from(&*s.geometry))
.collect();
let bbox = bounds.as_ref().map(|b| (b.bbox_min, b.bbox_max));
benilla_formats::synthesize_lamp_light(&path, &batches, bbox)
.map(|l| (l.position, l.bone, l.color, l.intensity, false, None))
});
})
.or_else(|| {
benilla_formats::synthesize_fire_light(
&path,
emitters.iter().map(|e| &e.def),
)
.map(|fire| {
let src = &emitters[fire.emitter].def;
(
src.position,
src.bone,
fire.color,
fire.intensity,
true,
None,
)
})
})
.or_else(|| {
// MONKEY (lamp lights): a lamppost/lantern/chandelier authors NO particle emitter
// at all — its glow is an UNLIT (render-flag 0x01) glass geoset. The rule reads the
// already-built render batches, so nothing extra is parsed; the position it returns
// is that geoset's CENTROID (the lamp head, 4-5 yd up a lamppost), never the model
// origin at the base of the pole. `bounds` is the fallback for a name-route hit
// whose glass we can't find — see `fire_light::lamp_position`.
let batches: Vec<benilla_formats::EmissiveBatch<'_>> = submeshes
.iter()
.map(|s| benilla_formats::EmissiveBatch::from(&*s.geometry))
.collect();
let bbox = bounds.as_ref().map(|b| (b.bbox_min, b.bbox_max));
benilla_formats::synthesize_lamp_light(&path, &batches, bbox)
.map(|l| (l.position, l.bone, l.color, l.intensity, false, None))
});
if let Some((position, src_bone, color, intensity, flame, spell)) = synth {
// The light sits at the FLAME (or the lamp glass), not the model origin: a brazier's
// origin is under its bowl, and a light there back-lights the bowl into every
+12 -2
View File
@@ -159,7 +159,12 @@ pub struct WowModelExt {
/// `Depth`, which `Depth32Float` is). The image is created ONCE at app startup regardless of
/// the cvars — an absent image would stall every model material's bind group on
/// `RetryNextUpdate` and blank every model. Fragment-only, like static_gx's group 3.
#[texture(91, dimension = "2d_array", sample_type = "depth", visibility(fragment))]
#[texture(
91,
dimension = "2d_array",
sample_type = "depth",
visibility(fragment)
)]
#[sampler(92, sampler_type = "comparison", visibility(fragment))]
pub torch_depth: Handle<Image>,
/// MONKEY (static torch cache): the ≤16-fixture torch TABLE — the same 6416-byte
@@ -520,7 +525,12 @@ pub struct TerrainExtension {
///
/// Fragment-only, like the splat/alpha/shadow arrays above (Bevy 0.18 narrows textures and
/// samplers but not uniforms) - the vertex stage only picks WHICH lights, it never samples.
#[texture(91, dimension = "2d_array", sample_type = "depth", visibility(fragment))]
#[texture(
91,
dimension = "2d_array",
sample_type = "depth",
visibility(fragment)
)]
#[sampler(92, sampler_type = "comparison", visibility(fragment))]
pub torch_depth: Handle<Image>,
/// The 6416-byte `TorchTableUniform` bytes (count / positions[16] / view_projs[96]) as the same
+17 -7
View File
@@ -35,7 +35,9 @@ impl Default for WaterUniform {
pub struct WaterQuality(pub u8);
impl Default for WaterQuality {
fn default() -> Self { Self(1) }
fn default() -> Self {
Self(1)
}
}
/// The world view's opaque depth, resolved to a sampleable R32Float image.
@@ -46,11 +48,15 @@ impl FromWorld for WaterDepthImage {
fn from_world(world: &mut World) -> Self {
use bevy::{asset::RenderAssetUsages, render::render_resource::*};
let mut image = Image::new_fill(
Extent3d::default(), TextureDimension::D2, &0f32.to_le_bytes(),
TextureFormat::R32Float, RenderAssetUsages::default(),
Extent3d::default(),
TextureDimension::D2,
&0f32.to_le_bytes(),
TextureFormat::R32Float,
RenderAssetUsages::default(),
);
image.texture_descriptor.usage = TextureUsages::TEXTURE_BINDING
| TextureUsages::RENDER_ATTACHMENT | TextureUsages::COPY_DST;
| TextureUsages::RENDER_ATTACHMENT
| TextureUsages::COPY_DST;
// Render-only: no CPU copy, so a resize does not allocate (and upload) a screen of zeros.
image.data = None;
Self(world.resource_mut::<Assets<Image>>().add(image))
@@ -67,11 +73,15 @@ impl FromWorld for WaterColourImage {
fn from_world(world: &mut World) -> Self {
use bevy::{asset::RenderAssetUsages, render::render_resource::*};
let mut image = Image::new_fill(
Extent3d::default(), TextureDimension::D2, &[0u8; 8],
TextureFormat::Rgba16Float, RenderAssetUsages::default(),
Extent3d::default(),
TextureDimension::D2,
&[0u8; 8],
TextureFormat::Rgba16Float,
RenderAssetUsages::default(),
);
image.texture_descriptor.usage = TextureUsages::TEXTURE_BINDING
| TextureUsages::RENDER_ATTACHMENT | TextureUsages::COPY_DST;
| TextureUsages::RENDER_ATTACHMENT
| TextureUsages::COPY_DST;
// Render-only: no CPU copy, so a resize does not allocate (and upload) a screen of zeros.
image.data = None;
Self(world.resource_mut::<Assets<Image>>().add(image))
@@ -324,7 +324,10 @@ pub fn m2firescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
};
scanned += 1;
// The authored block wins entirely — same gate the asset bake applies.
if benilla_formats::parse_m2_lights(&bytes).iter().any(|l| l.casts()) {
if benilla_formats::parse_m2_lights(&bytes)
.iter()
.any(|l| l.casts())
{
authored += 1;
continue;
}
@@ -332,9 +335,9 @@ pub fn m2firescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
// MONKEY (lamp lights): route 1 is the flame emitter; routes 2/3 read the model's RENDER
// BATCHES, so the submesh parse only runs where the flame route found nothing (which is
// most of the corpus, but the parse is the same one the asset bake does anyway).
let (route, color, intensity, bucket, detail) = match
benilla_formats::synthesize_fire_light(&name, &emitters)
{
let (route, color, intensity, bucket, detail) = match benilla_formats::synthesize_fire_light(
&name, &emitters,
) {
Some(fire) => {
let e = &emitters[fire.emitter];
let detail = format!(
@@ -359,8 +362,10 @@ pub fn m2firescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, "", &[]) else {
continue;
};
let batches: Vec<benilla_formats::EmissiveBatch<'_>> =
subs.iter().map(benilla_formats::EmissiveBatch::from).collect();
let batches: Vec<benilla_formats::EmissiveBatch<'_>> = subs
.iter()
.map(benilla_formats::EmissiveBatch::from)
.collect();
let bounds = benilla_formats::parse_m2_bounds(&bytes).ok();
let bbox = bounds.as_ref().map(|b| (b.bbox_min, b.bbox_max));
let Some(lamp) = benilla_formats::synthesize_lamp_light(&name, &batches, bbox)
@@ -379,7 +384,9 @@ pub fn m2firescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
lamp.batch
.and_then(|i| subs[i].texture.as_deref())
.unwrap_or("(bbox top centre)"),
lamp.position[0], lamp.position[1], lamp.position[2],
lamp.position[0],
lamp.position[1],
lamp.position[2],
lamp.position[2] - base,
lamp.bone,
);
@@ -395,12 +402,18 @@ pub fn m2firescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
(color[1] * 100.0).round() as i32,
(color[2] * 100.0).round() as i32,
);
color_tally.entry(key).or_insert_with(|| (0, name.clone())).0 += 1;
color_tally
.entry(key)
.or_insert_with(|| (0, name.clone()))
.0 += 1;
rows.push(format!(
"{name}\n [{:<8}] rgb ({:.3}, {:.3}, {:.3}) {:<11} x{:.2} {detail}",
route.label(),
color[0], color[1], color[2],
bucket, intensity,
color[0],
color[1],
color[2],
bucket,
intensity,
));
}
@@ -627,14 +640,15 @@ fn batch_class_table(
continue;
};
// The MOGP batch-section counts — the SAME two reads `build_wmo_group_submeshes` makes.
let (trans_n, int_n) = mogp_payload(&gbytes)
.filter(|m| m.len() >= 0x2c)
.map_or((0usize, 0usize), |m| {
(
u16::from_le_bytes([m[0x28], m[0x29]]) as usize,
u16::from_le_bytes([m[0x2a], m[0x2b]]) as usize,
)
});
let (trans_n, int_n) =
mogp_payload(&gbytes)
.filter(|m| m.len() >= 0x2c)
.map_or((0usize, 0usize), |m| {
(
u16::from_le_bytes([m[0x28], m[0x29]]) as usize,
u16::from_le_bytes([m[0x2a], m[0x2b]]) as usize,
)
});
// The colours AS UPLOADED (doorway fade applied), so the alpha printed is the alpha the
// shader interpolates. BGRA — index 3 is alpha.
let colors = benilla_formats::wmo_group_fixed_colors(&gbytes, root);
@@ -708,7 +722,11 @@ fn batch_class_table(
}
seen.push(g);
let p = &group.vertex_positions[g];
let c = colors.as_ref().and_then(|c| c.get(g)).copied().unwrap_or([255; 4]);
let c = colors
.as_ref()
.and_then(|c| c.get(g))
.copied()
.unwrap_or([255; 4]);
let a = c[3];
println!(
" v{g:<5} ({:>7.2},{:>7.2},{:>6.2}) a {a:>3} ({:.3}) rgb ({:>3},{:>3},{:>3})",
@@ -717,7 +735,13 @@ fn batch_class_table(
}
print!(" tris");
for t in idx.chunks(3) {
print!(" [{}]", t.iter().map(|v| v.to_string()).collect::<Vec<_>>().join(","));
print!(
" [{}]",
t.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(",")
);
}
println!();
}
@@ -865,8 +889,10 @@ pub fn wmolights(chain: &mut Chain, raw_path: &str, verts: Option<usize>) -> Res
// on ([`benilla_formats::room_claim::SPLIT_PORTAL_MIN_AREA`]) — a doorway is small, a room the
// artist cut in half is not — so it prints beside the groups each portal joins and a `SPLIT`
// marker for the ones a containment claim now crosses at full weight instead of fading.
println!("=== portals (polygon area vs SPLIT_PORTAL_MIN_AREA = {:.0} yd^2) ===",
benilla_formats::room_claim::SPLIT_PORTAL_MIN_AREA);
println!(
"=== portals (polygon area vs SPLIT_PORTAL_MIN_AREA = {:.0} yd^2) ===",
benilla_formats::room_claim::SPLIT_PORTAL_MIN_AREA
);
let graph = rr.graph();
for pi in 0..rr.portals.infos.len() {
let area = benilla_formats::room_claim::portal_area(&graph, pi as u16).unwrap_or(0.0);
@@ -905,7 +931,8 @@ pub fn wmolights(chain: &mut Chain, raw_path: &str, verts: Option<usize>) -> Res
});
let (diag, ctr) = pbox.map_or((0.0, [0.0; 3]), |(lo, hi)| {
(
((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2)).sqrt(),
((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2))
.sqrt(),
[
0.5 * (lo[0] + hi[0]),
0.5 * (lo[1] + hi[1]),
@@ -1225,7 +1252,10 @@ pub fn wmolamps(chain: &mut Chain, prefix: Option<&str>, detail: Option<&str>) -
.collect();
if detail.is_some_and(|d| root_path.contains(&d.to_ascii_lowercase())) && !dark.is_empty() {
let names: Vec<String> = dark.iter().map(|g| format!("g{g}")).collect();
println!("{root_path}: interior groups still unlit: {}", names.join(" "));
println!(
"{root_path}: interior groups still unlit: {}",
names.join(" ")
);
}
rows.push(LampRow {
path: root_path.clone(),
+39 -1
View File
@@ -54,6 +54,19 @@ pub struct NpcAppearance {
pub bake_name: Option<String>,
}
/// The archive path of a baked NPC atlas: `Textures\BakedNpcTextures\<bake>`, with `.blp` appended
/// when the bake name lacks it, as the reference's texture create appends the extension a name has
/// not (`CImageManager::CreateTexture`). The atlas ships with the extension, and custom chains
/// (TurtleWoW) store the bare name.
pub fn baked_atlas_path(bake_name: &str) -> String {
let path = format!("Textures\\BakedNpcTextures\\{bake_name}");
if bake_name.to_ascii_lowercase().ends_with(".blp") {
path
} else {
format!("{path}.blp")
}
}
/// One CreatureDisplayInfo row (the parts we use).
#[derive(Debug, Clone)]
struct DisplayRow {
@@ -647,7 +660,7 @@ mod tests {
"an extended-display NPC wears a Character\\ body, got {}",
m.model_path
);
let path = format!("Textures\\BakedNpcTextures\\{bake}");
let path = baked_atlas_path(bake);
assert!(
chain.read_file(&path).is_ok(),
"the baked body atlas ships: {path}"
@@ -661,6 +674,31 @@ mod tests {
verified >= 5,
"found + verified several baked character-model NPCs (got {verified})"
);
// The full surface: every bake name resolves by the client rule (`baked_atlas_path`), and
// a bare-name row whose atlas ships only once `.blp` is appended is the TurtleWoW case.
// Rows that ship neither way name content the reference cannot load either, so only the
// rule is asserted; the count is printed.
let mut shipped = 0;
let mut missing: Vec<&str> = Vec::new();
for app in cat.extra.values() {
let Some(bake) = app.bake_name.as_deref() else {
continue;
};
if chain.read_file(&baked_atlas_path(bake)).is_ok() {
shipped += 1;
} else {
missing.push(bake);
}
}
eprintln!(
"baked atlases ship for {shipped} rows; {} rows name an absent atlas",
missing.len()
);
assert!(
shipped > 1000,
"the baked NPC atlases ship across the catalog (got {shipped})"
);
}
/// The three arms of `0x625500`: an override, a `−1` deferral and an agreeing row.
+144 -45
View File
@@ -37,7 +37,14 @@ use crate::{OverLife, ParticleBlend, ParticleEmitterDef};
/// same reason from the other side: they ride real fires as SECOND emitters, and letting one win
/// would put the light at the smoke column's height in ash grey.
pub const FIRE_TEXTURE_KEYS: [&str; 8] = [
"FLAMELICK", "BONFIRE", "BRAZIER", "CANDLE", "TORCH", "FLAME", "EMBER", "FIRE",
"FLAMELICK",
"BONFIRE",
"BRAZIER",
"CANDLE",
"TORCH",
"FLAME",
"EMBER",
"FIRE",
];
/// The fallback hue for a fire whose over-life ramp is all-but-white (an emitter that tints from
@@ -224,12 +231,13 @@ pub fn synthesize_fire_light<'a>(
// A hand-rolled max rather than `max_by`, which keeps the LAST maximum on a tie: the
// FIRST-wins arm (`>=` on the incumbent) is what makes the pick stable when a model
// authors two identical flames, and the ties are real (mirrored torch pairs).
.fold(None, |best: Option<(usize, &ParticleEmitterDef, f32)>, cur| {
match best {
.fold(
None,
|best: Option<(usize, &ParticleEmitterDef, f32)>, cur| match best {
Some(b) if b.2 >= cur.2 => Some(b),
_ => Some(cur),
}
})?;
},
)?;
let (intensity, bucket) = fire_intensity(strength);
Some(SyntheticFire {
emitter,
@@ -666,7 +674,11 @@ pub fn lamp_position(
return None;
}
let (lo, hi) = bounds?;
Some((None, [(lo[0] + hi[0]) * 0.5, (lo[1] + hi[1]) * 0.5, hi[2]], 0))
Some((
None,
[(lo[0] + hi[0]) * 0.5, (lo[1] + hi[1]) * 0.5, hi[2]],
0,
))
}
/// Derive **at most one** light for a model that authors neither a casting light block nor a flame
@@ -693,10 +705,7 @@ pub fn synthesize_lamp_light(
batches: &[EmissiveBatch<'_>],
bounds: Option<([f32; 3], [f32; 3])>,
) -> Option<SyntheticLamp> {
if is_spell_path(path)
|| is_broken_or_off(path)
|| is_light_beam(path)
|| !lamp_family_ok(path)
if is_spell_path(path) || is_broken_or_off(path) || is_light_beam(path) || !lamp_family_ok(path)
{
return None;
}
@@ -1099,12 +1108,13 @@ pub fn synthesize_spell_light<'a>(
.map(|(i, d)| (i, d, spell_strength(d)))
// First-wins on a tie, as in [`synthesize_fire_light`] — mirrored emitter pairs are real
// here too (a firework shell authors three identical bursts on three bones).
.fold(None, |best: Option<(usize, &ParticleEmitterDef, f32)>, cur| {
match best {
.fold(
None,
|best: Option<(usize, &ParticleEmitterDef, f32)>, cur| match best {
Some(b) if b.2 >= cur.2 => Some(b),
_ => Some(cur),
}
})?;
},
)?;
let ramp = spell_color(&def.over_life);
// The NAME wins over the HUE wherever both speak: an artist naming a model `Frostbolt` has
// said more about it than one warm key in its ramp has. The hue decides for the unnamed
@@ -1372,13 +1382,31 @@ mod tests {
let keys = [[1.0, 0.3, 0.0, 1.0]; 3];
let fire = |t: Option<&str>, b| def(t, b, keys, 0.3, 20.0);
assert!(fire_emitter(&fire(Some("X\\FLAMELICKSMALL.BLP"), ParticleBlend::Add)));
assert!(fire_emitter(&fire(Some("X\\CANDLEFLAME.BLP"), ParticleBlend::Add)));
assert!(fire_emitter(&fire(Some("X\\TORCHFIRE.BLP"), ParticleBlend::Add)));
assert!(fire_emitter(&fire(
Some("X\\FLAMELICKSMALL.BLP"),
ParticleBlend::Add
)));
assert!(fire_emitter(&fire(
Some("X\\CANDLEFLAME.BLP"),
ParticleBlend::Add
)));
assert!(fire_emitter(&fire(
Some("X\\TORCHFIRE.BLP"),
ParticleBlend::Add
)));
// Art that is not fire.
assert!(!fire_emitter(&fire(Some("X\\GLOW.BLP"), ParticleBlend::Add)));
assert!(!fire_emitter(&fire(Some("X\\SPELLGLOW32.BLP"), ParticleBlend::Add)));
assert!(!fire_emitter(&fire(Some("X\\SMOKE01.BLP"), ParticleBlend::Add)));
assert!(!fire_emitter(&fire(
Some("X\\GLOW.BLP"),
ParticleBlend::Add
)));
assert!(!fire_emitter(&fire(
Some("X\\SPELLGLOW32.BLP"),
ParticleBlend::Add
)));
assert!(!fire_emitter(&fire(
Some("X\\SMOKE01.BLP"),
ParticleBlend::Add
)));
assert!(!fire_emitter(&fire(None, ParticleBlend::Add)));
// Fire art on a NON-additive emitter is the same model's smoke plume, not its flame.
assert!(!fire_emitter(&fire(
@@ -1461,10 +1489,11 @@ mod tests {
);
// The `NoOmni` twin DOES synthesise — it is Orgrimmar's street lighting (123 placed);
// only the absence of a flame emitter keeps a brazier cold.
assert!(
synthesize_fire_light("World\\Generic\\Orc\\Braziers\\TallBrazierNoOmni01.m2", [&flame])
.is_some()
);
assert!(synthesize_fire_light(
"World\\Generic\\Orc\\Braziers\\TallBrazierNoOmni01.m2",
[&flame]
)
.is_some());
assert_eq!(synthesize_fire_light("WORLD\\X.M2", [&smoke]), None);
}
@@ -1506,7 +1535,12 @@ mod tests {
];
let batches = [
batch(Some("X\\STREETLAMP.BLP"), false, false, &post),
batch(Some("DUNGEONS\\STORMWINDLAMPGLASS.BLP"), true, false, &glass),
batch(
Some("DUNGEONS\\STORMWINDLAMPGLASS.BLP"),
true,
false,
&glass,
),
];
let got = synthesize_lamp_light(
"World\\Generic\\Human\\Passive Doodads\\Lamps\\StormwindStreetlamp01.m2",
@@ -1674,16 +1708,28 @@ mod tests {
/// the artist wrote into the filename precisely because the warm default would be wrong there.
#[test]
fn lamp_kind_colour_and_intensity() {
assert_eq!(lamp_kind("x\\KarazanChandelier_01.m2"), Some(LampKind::Chandelier));
assert_eq!(lamp_kind("x\\Scholme_Candelabra.m2"), Some(LampKind::Candle));
assert_eq!(
lamp_kind("x\\KarazanChandelier_01.m2"),
Some(LampKind::Chandelier)
);
assert_eq!(
lamp_kind("x\\Scholme_Candelabra.m2"),
Some(LampKind::Candle)
);
assert_eq!(lamp_kind("x\\SkullCandle01.m2"), Some(LampKind::Candle));
assert_eq!(lamp_kind("x\\DuskwoodLamppost.m2"), Some(LampKind::Lamp));
assert_eq!(lamp_kind("x\\GeneralHangingLantern01.m2"), Some(LampKind::Lamp));
assert_eq!(
lamp_kind("x\\GeneralHangingLantern01.m2"),
Some(LampKind::Lamp)
);
assert_eq!(lamp_kind("x\\UldamanBrazier01.m2"), Some(LampKind::Brazier));
assert_eq!(lamp_kind("x\\NA_Torch01.m2"), Some(LampKind::Torch));
assert_eq!(lamp_kind("x\\BE_Banner02.m2"), None);
assert_eq!(lamp_intensity(Some(LampKind::Chandelier)), (2.0, "chandelier"));
assert_eq!(
lamp_intensity(Some(LampKind::Chandelier)),
(2.0, "chandelier")
);
assert_eq!(lamp_intensity(Some(LampKind::Candle)), (0.6, "candle"));
assert_eq!(lamp_intensity(Some(LampKind::Lamp)), (1.5, "lamp"));
assert_eq!(lamp_intensity(None), (1.5, "lamp"));
@@ -1697,13 +1743,19 @@ mod tests {
Some(LampKind::Torch),
] {
let i = lamp_intensity(k).0;
assert!([0.6f32, 1.5, 2.0, 3.0].contains(&i), "off-rung intensity {i}");
assert!(
[0.6f32, 1.5, 2.0, 3.0].contains(&i),
"off-rung intensity {i}"
);
}
assert_eq!(lamp_color("x\\StormwindStreetlamp01.m2"), DEFAULT_LAMP);
assert_eq!(lamp_color("x\\NE_LanternBlue01.m2"), [0.45, 0.65, 1.0]);
assert_eq!(lamp_color("x\\BE_lantern_red_001.m2"), [1.0, 0.35, 0.25]);
assert_eq!(lamp_color("x\\Scholme_GreenCandelabra.m2"), [0.40, 1.0, 0.45]);
assert_eq!(
lamp_color("x\\Scholme_GreenCandelabra.m2"),
[0.40, 1.0, 0.45]
);
// No lamp word at all ⇒ the open-flame default, shared with the particle route.
assert_eq!(lamp_color("x\\Scholme_Wax03.m2"), DEFAULT_WARM);
}
@@ -1751,7 +1803,10 @@ mod tests {
0.3167,
60.0,
);
assert_eq!(synthesize_spell_light("Spells\\Frostbolt.m2", [&frostbolt]), None);
assert_eq!(
synthesize_spell_light("Spells\\Frostbolt.m2", [&frostbolt]),
None
);
// Holy Light's strongest emitter: every saturated key is authored INVISIBLE, so the ramp
// yields nothing and the holy default (warm white) stands in. A most-saturated-key rule
@@ -1777,7 +1832,11 @@ mod tests {
let shadow = def(
Some("Spells\\Clouds8x8.blp"),
ParticleBlend::Add,
[[0.4, 0.0, 0.6, 1.0], [0.2, 0.0, 0.4, 1.0], [0.1, 0.0, 0.2, 0.0]],
[
[0.4, 0.0, 0.6, 1.0],
[0.2, 0.0, 0.4, 1.0],
[0.1, 0.0, 0.2, 0.0],
],
0.5,
60.0,
);
@@ -1798,7 +1857,10 @@ mod tests {
synthesize_spell_light("Spells\\EntanglingRoots_State.m2", [&green]),
None
);
assert_eq!(synthesize_spell_light("Spells\\Wrath_Missile.m2", [&green]), None);
assert_eq!(
synthesize_spell_light("Spells\\Wrath_Missile.m2", [&green]),
None
);
// The imp's Firebolt: NAMED fire (the `FIRE` key), and its green ramp still supplies the
// colour — a green flame is a flame with odd chemistry, exactly as the world route holds.
@@ -1902,8 +1964,14 @@ mod tests {
spell_name_kind("Spells\\HolyLight_Impact_Head.m2"),
Some(SpellLightKind::Holy)
);
assert_eq!(spell_name_kind("Spells\\Immolate_State.m2"), Some(SpellLightKind::Fire));
assert_eq!(spell_name_kind("Spells\\ArcaneExplosion_Base.m2"), Some(SpellLightKind::None));
assert_eq!(
spell_name_kind("Spells\\Immolate_State.m2"),
Some(SpellLightKind::Fire)
);
assert_eq!(
spell_name_kind("Spells\\ArcaneExplosion_Base.m2"),
Some(SpellLightKind::None)
);
assert_eq!(spell_name_kind("Spells\\Doomguard_Summon.m2"), None); // hue decides
// A FROST effect carrying one warm spark stays dark — the name arm is unconditional.
@@ -1914,7 +1982,10 @@ mod tests {
0.5,
60.0,
);
assert_eq!(synthesize_spell_light("Spells\\FrostNova_Base.m2", [&spark]), None);
assert_eq!(
synthesize_spell_light("Spells\\FrostNova_Base.m2", [&spark]),
None
);
// …the same emitter under an unnamed model lights, by hue alone.
assert_eq!(
synthesize_spell_light("Spells\\Doomguard_Summon.m2", [&spark])
@@ -1930,10 +2001,26 @@ mod tests {
assert_eq!(spell_hue_kind([1.0, 0.55, 0.2]), SpellLightKind::Fire);
assert_eq!(spell_hue_kind([1.0, 0.95, 0.78]), SpellLightKind::Holy);
assert_eq!(spell_hue_kind([0.2, 1.0, 0.2]), SpellLightKind::Fel);
assert_eq!(spell_hue_kind([0.1, 0.44, 0.93]), SpellLightKind::None, "frost");
assert_eq!(spell_hue_kind([0.71, 0.87, 0.98]), SpellLightKind::None, "ice white-blue");
assert_eq!(spell_hue_kind([0.7, 0.4, 1.0]), SpellLightKind::None, "arcane violet");
assert_eq!(spell_hue_kind([0.3, 0.3, 0.3]), SpellLightKind::None, "grey");
assert_eq!(
spell_hue_kind([0.1, 0.44, 0.93]),
SpellLightKind::None,
"frost"
);
assert_eq!(
spell_hue_kind([0.71, 0.87, 0.98]),
SpellLightKind::None,
"ice white-blue"
);
assert_eq!(
spell_hue_kind([0.7, 0.4, 1.0]),
SpellLightKind::None,
"arcane violet"
);
assert_eq!(
spell_hue_kind([0.3, 0.3, 0.3]),
SpellLightKind::None,
"grey"
);
}
/// The gates: a non-spell path never reaches the route (the world routes own it), a
@@ -1960,7 +2047,10 @@ mod tests {
0.5,
60.0,
);
assert_eq!(synthesize_spell_light("Spells\\Fire_Nova.m2", [&smoke]), None);
assert_eq!(
synthesize_spell_light("Spells\\Fire_Nova.m2", [&smoke]),
None
);
let sparks = def(
Some("Spells\\Clouds8x8.blp"),
ParticleBlend::Add,
@@ -1968,7 +2058,10 @@ mod tests {
0.02,
60.0,
);
assert_eq!(synthesize_spell_light("Spells\\Fire_Nova.m2", [&sparks]), None);
assert_eq!(
synthesize_spell_light("Spells\\Fire_Nova.m2", [&sparks]),
None
);
assert_eq!(synthesize_spell_light("Spells\\Fire_Nova.m2", []), None);
// A constant-rate emitter has no fuse: the light is up at t=0.
assert_eq!(emit_onset(&flame), 0.0);
@@ -2059,9 +2152,15 @@ mod tests {
);
// A school veto beats a warm ramp: a frost model with one red spark stays dark even where
// its path says nothing.
assert_eq!(area_light_kind("spells\\noname_area.m2", FROST, Some(F)), Dark);
assert_eq!(
area_light_kind("spells\\noname_area.m2", FROST, Some(F)),
Dark
);
// …and with nothing to go on at all, dark is the answer (the reference's own behaviour).
assert_eq!(area_light_kind("spells\\noname_area.m2", PHYSICAL, None), Dark);
assert_eq!(
area_light_kind("spells\\noname_area.m2", PHYSICAL, None),
Dark
);
}
/// The `\FLARE_` key is anchored to a path separator so it takes the hunter's Flare and NOT the
+7 -6
View File
@@ -40,7 +40,8 @@ pub use cinematics::{
CinematicSequence, CinematicView, SEQUENCE_CAMERAS,
};
pub use creatures::{
load_creature_catalog, CreatureCatalog, CreatureModel, FootprintParams, NpcAppearance,
baked_atlas_path, load_creature_catalog, CreatureCatalog, CreatureModel, FootprintParams,
NpcAppearance,
};
pub use macro_icons::load_macro_icons;
mod macro_icons;
@@ -287,11 +288,11 @@ pub use models::{
CoverageReader, EmitterBoneLink, EventMarker, FogPolicy, FootprintTris, GlobalSeqBone,
GlobalSeqChannel, GroundQuad, KeyAnim, M2AnimSummary, M2Attachment, M2Bounds, M2CameraTracks,
M2Light, M2PaneCamera, M2PortraitCamera, ModelAnimation, ModelBlend, ParentArm, ParentBasis,
PlayableAnim, RenderSubmesh, RgbAnim, StageTwo, ScalarAnim, SeqLoops, ShippedGlueScene, Skeleton,
SkeletonBone, StringAnchors, UvAnim, UvRotAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog,
WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot,
ALPHA_KEY_REF, DEGENERATE_RING_FOOTPRINT, GLUE_AUTHORED_ASPECT, NO_GROUP_LIQUID,
OWNER_RUNG_BUCKETS, SHIPPED_GLUE_SCENES,
PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim, SeqLoops, ShippedGlueScene, Skeleton,
SkeletonBone, StageTwo, StringAnchors, UvAnim, UvRotAnim, WmoBatchClass, WmoDoodad,
WmoDoodadSet, WmoFog, WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef,
WmoPortals, WmoRoot, ALPHA_KEY_REF, DEGENERATE_RING_FOOTPRINT, GLUE_AUTHORED_ASPECT,
NO_GROUP_LIQUID, OWNER_RUNG_BUCKETS, SHIPPED_GLUE_SCENES,
};
mod terrain;
pub use terrain::{
+4 -4
View File
@@ -20,10 +20,6 @@ mod tables;
pub use atmosphere::Atmosphere;
pub use fog_band::{FogBand, FogBandCatalog, FOG_BANDS_PER_PARAM};
// MONKEY (leftovers): + the 0x8 / 0x10 flags.
pub use skybox::{
SkyboxDef, ZoneSkybox, SKYBOX_FOG_BLEND, SKYBOX_FORCE_SUN_SHAFTS, SKYBOX_FULL_DAY,
SKYBOX_KEEP_CELESTIAL, SKYBOX_NO_SUN_FOG,
};
use atmosphere::{
FB_CLOUD_DENSITY, FB_FOG_END, FB_FOG_START_MULT, IB_AMBIENT, IB_CLOUD_GBASE, IB_CLOUD_SLOPE,
IB_CLOUD_SUN, IB_DIFFUSE, IB_FOG_COLOR, IB_OCEAN_DEEP, IB_OCEAN_SHALLOW, IB_RIVER_DEEP,
@@ -31,6 +27,10 @@ use atmosphere::{
LP_OCEAN_SHALLOW_ALPHA, LP_SKYBOX, LP_WATER_DEEP_ALPHA, LP_WATER_SHALLOW_ALPHA,
};
pub use atmosphere::{ZERO_KEY_COLOR, ZERO_KEY_SCALAR};
pub use skybox::{
SkyboxDef, ZoneSkybox, SKYBOX_FOG_BLEND, SKYBOX_FORCE_SUN_SHAFTS, SKYBOX_FULL_DAY,
SKYBOX_KEEP_CELESTIAL, SKYBOX_NO_SUN_FOG,
};
use tables::{band_schema, load_bands, load_float_bands, sample_color, sample_float, Band, DAY};
const LIGHT: &str = "DBFilesClient\\Light.dbc";
+3 -1
View File
@@ -198,7 +198,9 @@ mod tests {
let id = 11 * FOG_BANDS_PER_PARAM + 7 + 1;
let c = FogBandCatalog::from_rows(&[(id, &[(0, 1.0)])]);
// Base 13 (no rows → fallback strength 0), then param 12 at weight 0.25.
let out = c.sample_chain(&[(13, 1.0), (12, 0.25)], 100, &neutral()).unwrap();
let out = c
.sample_chain(&[(13, 1.0), (12, 0.25)], 100, &neutral())
.unwrap();
assert!((out.sun_strength - 0.25).abs() < 1e-6);
}
}
+5 -1
View File
@@ -157,7 +157,11 @@ impl LightCatalog {
0 => params[SLOT_CLEAR],
p => p,
};
of(param).or_else(|| (slot != SLOT_CLEAR).then(|| of(params[SLOT_CLEAR])).flatten())
of(param).or_else(|| {
(slot != SLOT_CLEAR)
.then(|| of(params[SLOT_CLEAR]))
.flatten()
})
}
/// The weighted skyboxes at `pos` for the living: the global (or fallback) light at alpha 1,
+152 -36
View File
@@ -333,8 +333,7 @@ pub const CLAIM_EXT_SHELL_YD: f32 = 96.0;
/// groups trivially are (the exterior lane never draws them); an exterior group only at building
/// scale (see [`CLAIM_EXT_SHELL_YD`]).
pub fn claimable_by_box(g: &WmoGroupInfo) -> bool {
g.interior
|| (0..2).all(|a| (g.bbox_max[a] - g.bbox_min[a]) <= CLAIM_EXT_SHELL_YD)
g.interior || (0..2).all(|a| (g.bbox_max[a] - g.bbox_min[a]) <= CLAIM_EXT_SHELL_YD)
}
/// MONKEY (ext-class night law): is this group EXTERIOR-class but at BUILDING scale — an inn's
@@ -666,7 +665,10 @@ mod tests {
g(false, [-100.0, -100.0, -10.0], [100.0, 100.0, 40.0]),
];
assert!(enclosed_by_building_shell(&groups, 0));
assert!(!enclosed_by_building_shell(&groups, 1), "a shell is not a room");
assert!(
!enclosed_by_building_shell(&groups, 1),
"a shell is not a room"
);
assert!(
!enclosed_by_building_shell(&groups, 2),
"only the DISTRICT shell holds it, and that is not a building"
@@ -686,7 +688,10 @@ mod tests {
g(true, [0.0, 0.0, 0.0], [10.0, 10.0, 5.0]),
g(false, [-5.0, -5.0, -1.0], [52.0, 27.0, 2.3]),
];
assert!(enclosed_by_building_shell(&tight, 0), "centre z 2.5 vs shell top 2.3 + 0.5");
assert!(
enclosed_by_building_shell(&tight, 0),
"centre z 2.5 vs shell top 2.3 + 0.5"
);
}
/// A quad portal in the x = 5 plane, spanning y 0..2, z 0..3.
@@ -794,8 +799,16 @@ mod tests {
];
let (verts, infos) = one_portal();
let refs = [
WmoPortalRef { portal: 0, group: 1, side: 1 },
WmoPortalRef { portal: 0, group: 0, side: -1 },
WmoPortalRef {
portal: 0,
group: 1,
side: 1,
},
WmoPortalRef {
portal: 0,
group: 0,
side: -1,
},
];
let slices = [(0u16, 1u16), (1u16, 1u16)];
let portals = PortalGraph {
@@ -806,7 +819,10 @@ mod tests {
};
// 3 yd short of the doorway (x = 5), reach 8 ⇒ 5 yd of reach left on the far side.
let c = room_claims(&groups, portals, [2.0, 1.0, 1.0], 8.0, &[]);
assert_eq!(c[0].fade_radius, 0.0, "a containment claim is HARD: weight 1 everywhere");
assert_eq!(
c[0].fade_radius, 0.0,
"a containment claim is HARD: weight 1 everywhere"
);
assert_eq!(c[0].hops, 0);
let door = c[1];
assert_eq!(door.how, ClaimHow::Portal);
@@ -815,9 +831,18 @@ mod tests {
// sqrt(0 + 1 + 1.5^2) = 1.803.
assert_eq!(door.fade_center, [5.0, 1.0, 1.5]);
assert!((door.fade_slack - 1.802_775).abs() < 1e-4, "{door:?}");
assert!((door.distance - 3.0).abs() < 1e-5, "closest point on the quad, not its centre");
assert!((door.fade_radius - 5.0).abs() < 1e-5, "reach 8 minus the 3 yd spent");
assert_eq!(door.fade_entry, 1.0, "a first hop is at full weight in its own doorway");
assert!(
(door.distance - 3.0).abs() < 1e-5,
"closest point on the quad, not its centre"
);
assert!(
(door.fade_radius - 5.0).abs() < 1e-5,
"reach 8 minus the 3 yd spent"
);
assert_eq!(
door.fade_entry, 1.0,
"a first hop is at full weight in its own doorway"
);
// The floor on the fade: a fixture that only just reaches the doorway must still FADE
// there, not step. Reach 3.0 leaves 0 yd, and a 0-yard smoothstep is the hard edge again.
@@ -838,43 +863,97 @@ mod tests {
g(true, [15.0, -5.0, 0.0], [25.0, 5.0, 6.0]),
];
let verts = vec![
[5.0, -2.5, 0.0], [5.0, 2.5, 0.0], [5.0, 2.5, 6.0], [5.0, -2.5, 6.0],
[15.0, 0.0, 0.0], [15.0, 2.0, 0.0], [15.0, 2.0, 3.0], [15.0, 0.0, 3.0],
[5.0, -2.5, 0.0],
[5.0, 2.5, 0.0],
[5.0, 2.5, 6.0],
[5.0, -2.5, 6.0],
[15.0, 0.0, 0.0],
[15.0, 2.0, 0.0],
[15.0, 2.0, 3.0],
[15.0, 0.0, 3.0],
];
let infos = vec![
WmoPortalInfo { start_vertex: 0, count: 4, plane: [1.0, 0.0, 0.0, -5.0] },
WmoPortalInfo { start_vertex: 4, count: 4, plane: [1.0, 0.0, 0.0, -15.0] },
WmoPortalInfo {
start_vertex: 0,
count: 4,
plane: [1.0, 0.0, 0.0, -5.0],
},
WmoPortalInfo {
start_vertex: 4,
count: 4,
plane: [1.0, 0.0, 0.0, -15.0],
},
];
let refs = [
WmoPortalRef { portal: 0, group: 1, side: 1 },
WmoPortalRef { portal: 0, group: 0, side: -1 },
WmoPortalRef { portal: 1, group: 2, side: 1 },
WmoPortalRef { portal: 1, group: 1, side: -1 },
WmoPortalRef {
portal: 0,
group: 1,
side: 1,
},
WmoPortalRef {
portal: 0,
group: 0,
side: -1,
},
WmoPortalRef {
portal: 1,
group: 2,
side: 1,
},
WmoPortalRef {
portal: 1,
group: 1,
side: -1,
},
];
let slices = [(0u16, 1u16), (1u16, 2u16), (3u16, 1u16)];
let portals = PortalGraph { vertices: &verts, infos: &infos, refs: &refs, slices: &slices };
let portals = PortalGraph {
vertices: &verts,
infos: &infos,
refs: &refs,
slices: &slices,
};
assert!((portal_area(&portals, 0).unwrap() - 30.0).abs() < 1e-3);
assert!((portal_area(&portals, 1).unwrap() - 6.0).abs() < 1e-3);
let c = room_claims(&groups, portals, [2.0, 0.0, 1.0], 20.0, &[]);
assert_eq!(
c.iter().map(|c| (c.group, c.how, c.hops)).collect::<Vec<_>>(),
c.iter()
.map(|c| (c.group, c.how, c.hops))
.collect::<Vec<_>>(),
vec![
(0, ClaimHow::Contains, 0),
(1, ClaimHow::Split, 0),
(2, ClaimHow::Portal, 1),
],
);
assert_eq!(c[1].fade_radius, 0.0, "a split is HARD — weight 1 across the far half");
assert_eq!(c[2].fade_entry, 1.0, "…so the door off it is a FIRST hop at full entry");
assert_eq!(
c[1].fade_radius, 0.0,
"a split is HARD — weight 1 across the far half"
);
assert_eq!(
c[2].fade_entry, 1.0,
"…so the door off it is a FIRST hop at full entry"
);
// Under the bar the same portal is an ordinary doorway again: g1 fades, g2 is hop TWO.
let small = vec![
[5.0, -1.0, 0.0], [5.0, 1.0, 0.0], [5.0, 1.0, 3.0], [5.0, -1.0, 3.0],
[15.0, 0.0, 0.0], [15.0, 2.0, 0.0], [15.0, 2.0, 3.0], [15.0, 0.0, 3.0],
[5.0, -1.0, 0.0],
[5.0, 1.0, 0.0],
[5.0, 1.0, 3.0],
[5.0, -1.0, 3.0],
[15.0, 0.0, 0.0],
[15.0, 2.0, 0.0],
[15.0, 2.0, 3.0],
[15.0, 0.0, 3.0],
];
let portals = PortalGraph { vertices: &small, ..portals };
let portals = PortalGraph {
vertices: &small,
..portals
};
let c = room_claims(&groups, portals, [2.0, 0.0, 1.0], 20.0, &[]);
assert_eq!(
c.iter().map(|c| (c.group, c.how, c.hops)).collect::<Vec<_>>(),
c.iter()
.map(|c| (c.group, c.how, c.hops))
.collect::<Vec<_>>(),
vec![
(0, ClaimHow::Contains, 0),
(1, ClaimHow::Portal, 1),
@@ -895,18 +974,48 @@ mod tests {
g(true, [15.0, -5.0, 0.0], [25.0, 5.0, 6.0]),
];
let verts = vec![
[5.0, 0.0, 0.0], [5.0, 2.0, 0.0], [5.0, 2.0, 3.0], [5.0, 0.0, 3.0],
[15.0, 0.0, 0.0], [15.0, 2.0, 0.0], [15.0, 2.0, 3.0], [15.0, 0.0, 3.0],
[5.0, 0.0, 0.0],
[5.0, 2.0, 0.0],
[5.0, 2.0, 3.0],
[5.0, 0.0, 3.0],
[15.0, 0.0, 0.0],
[15.0, 2.0, 0.0],
[15.0, 2.0, 3.0],
[15.0, 0.0, 3.0],
];
let infos = vec![
WmoPortalInfo { start_vertex: 0, count: 4, plane: [1.0, 0.0, 0.0, -5.0] },
WmoPortalInfo { start_vertex: 4, count: 4, plane: [1.0, 0.0, 0.0, -15.0] },
WmoPortalInfo {
start_vertex: 0,
count: 4,
plane: [1.0, 0.0, 0.0, -5.0],
},
WmoPortalInfo {
start_vertex: 4,
count: 4,
plane: [1.0, 0.0, 0.0, -15.0],
},
];
let refs = [
WmoPortalRef { portal: 0, group: 1, side: 1 }, // g0 -> g1
WmoPortalRef { portal: 0, group: 0, side: -1 }, // g1 -> g0
WmoPortalRef { portal: 1, group: 2, side: 1 }, // g1 -> g2
WmoPortalRef { portal: 1, group: 1, side: -1 }, // g2 -> g1
WmoPortalRef {
portal: 0,
group: 1,
side: 1,
}, // g0 -> g1
WmoPortalRef {
portal: 0,
group: 0,
side: -1,
}, // g1 -> g0
WmoPortalRef {
portal: 1,
group: 2,
side: 1,
}, // g1 -> g2
WmoPortalRef {
portal: 1,
group: 1,
side: -1,
}, // g2 -> g1
];
let slices = [(0u16, 1u16), (1u16, 2u16), (3u16, 1u16)];
let portals = PortalGraph {
@@ -922,8 +1031,15 @@ mod tests {
vec![(0, 0), (1, 1), (2, 2)],
"one claim per room, in hop order"
);
assert_eq!(c[2].fade_center[0], 15.0, "the SECOND door is what g2 fades from");
assert!((c[2].fade_radius - 7.0).abs() < 1e-5, "20 yd reach, 13 spent: {:?}", c[2]);
assert_eq!(
c[2].fade_center[0], 15.0,
"the SECOND door is what g2 fades from"
);
assert!(
(c[2].fade_radius - 7.0).abs() < 1e-5,
"20 yd reach, 13 spent: {:?}",
c[2]
);
// CONTINUITY at the second threshold: g2 must START at exactly the weight g1's own fade
// has reached there, or the far room reads BRIGHTER than the near one across one plank.
// g1's door is at (5, 1, 1.5) with slack 1.803 and radius 17; g2's is at (15, 1, 1.5),
+9
View File
@@ -213,6 +213,15 @@ pub fn write_logon_proof(
pub fn read_proof_reply(r: &mut impl Read) -> Result<[u8; 20]> {
let opcode = read_u8(r)?;
if opcode != CMD_AUTH_LOGON_PROOF {
// mangos-family quirk: a build realmd will not accept is rejected at the proof stage with a
// *challenge-shaped* reply — `CMD_AUTH_LOGON_CHALLENGE, 0x00, WOW_FAIL_VERSION_INVALID`
// (vmangos `AuthSocket::_HandleLogonProof`, the no-patch arm; byte-identical in
// Turtle-derived cores). Surface the trailing result byte instead of a baffling "got 0x0".
if opcode == CMD_AUTH_LOGON_CHALLENGE {
let _padding = read_u8(r)?;
let result = read_u8(r)?;
return Err(AuthReject { code: result }.into());
}
bail!("expected CMD_AUTH_LOGON_PROOF (0x01), got {opcode:#x}");
}
let result = read_u8(r)?;
+14 -2
View File
@@ -30,8 +30,20 @@ use benilla_srp::{NormalizedString, PublicKey, SrpClientChallenge, SESSION_KEY_L
/// The port a stock vmangos `realmd` listens on.
pub const AUTH_PORT: u16 = 3724;
/// The 1.12.1 client build we present to the server.
/// The 1.12.1 client build we present to the **world server** (mangosd).
pub const CLIENT_BUILD: u16 = 5875;
/// The build we present to **realmd** — Turtle-WoW's 1.18.1 build, not our own 5875.
///
/// The mangos-family realmd gates the *proof* stage on the build (`AuthSocket::_HandleLogonProof`
/// → `FindBuildInfo`), and Turtle-derived realmd only accepts builds ≥ its own (this fork: 7272).
/// A 5875-presenting client is answered with a challenge-shaped reject — `0x00, 0x00,
/// WOW_FAIL_VERSION_INVALID` — which is exactly the "got 0x0" the proof reader used to choke on
/// (now surfaced as an [`AuthReject`] there). A stock vanilla realmd accepts any build ≥ 5875
/// through the same `FindBuildInfo`, and its strict-version hash keys on the entry the build maps
/// to — the 5875 one — so presenting 7272 still verifies against the hashes we hold. The world hop
/// keeps [`CLIENT_BUILD`]: mangosd accepts exactly 5875 (`IsAcceptableClientBuild`), Turtle's
/// included.
pub const REALMD_CLIENT_BUILD: u16 = 7272;
/// Challenges [`logon`] draws for an unambiguous `B`; one in ~137 is not, so all 8 fail ~10⁻¹⁷.
const MAX_CHALLENGE_DIALS: u32 = 8;
@@ -178,7 +190,7 @@ pub fn logon(host: &str, username: &str, password: &str) -> Result<Logon> {
let mut dialed = None;
for _ in 0..MAX_CHALLENGE_DIALS {
let mut stream = dial(host, port)?;
auth::write_logon_challenge(&mut stream, &username.to_uppercase(), CLIENT_BUILD)
auth::write_logon_challenge(&mut stream, &username.to_uppercase(), REALMD_CLIENT_BUILD)
.context("sending logon challenge")?;
let reply =
auth::read_challenge_reply(&mut stream).context("reading logon challenge reply")?;
@@ -0,0 +1,156 @@
//! `uiparent_probe`: run the reported spot, not a paraphrase of it.
//!
//! ```text
//! cargo run -q -p benilla-ui --example uiparent_probe -- /path/to/UIParent.lua
//! cargo run -q -p benilla-ui --example uiparent_probe -- /path/to/FriendsFrame.lua
//! ```
//!
//! Loads a real TurtleWoW `Interface\FrameXML` file through benilla's VM and calls the function its
//! 1.12-era error was reported in, so the raise is reproduced at the exact line and then cleared:
//!
//! - `UIParent.lua` — `UIFrameFade_CreateInfo`, whose `wipe(frame.fadeInfo)` (line 1420) raised
//! `attempt to call global 'wipe' (a nil value)`.
//! - `FriendsFrame.lua` — the guild-roster filter's `trim(strlower(...))` (line 490).
//!
//! These files load far past the reported line in a real session, so a clean `OK` means the chunk
//! parsed, its globals landed, and the call itself behaved. Exits 1 on any raise, naming the chunk.
use std::path::Path;
use benilla_ui::script::UiScript;
fn main() {
let mut args = std::env::args().skip(1);
let path = args
.next()
.unwrap_or_else(|| panic!("usage: uiparent_probe <UIParent.lua|FriendsFrame.lua>..."));
let bytes = std::fs::read(Path::new(&path)).expect("read the FrameXML file");
let name = format!("Interface\\FrameXML\\{}", file_name(&path));
let script = UiScript::new().expect("new UiScript");
script
.run_chunk_named(&bytes, &name)
.unwrap_or_else(|e| fail(&format!("loading {name}"), e));
// Each file is probed at the function a 1.12-era error was reported in, chosen by its name.
let call = match file_name(&path).as_str() {
"UIParent.lua" => {
r#"
local f = CreateFrame("Frame")
local first = UIFrameFade_CreateInfo(f)
first.mode = "IN"
first.timeToFade = 0.5
local second = UIFrameFade_CreateInfo(f)
-- the wipe branch never reassigns `frame.fadeInfo`, so it returns the SAME table,
-- emptied: which is what makes `wipe` a same-identity clear, not a replacement
return first == second and first.mode == nil and first.timeToFade == nil
and getn(first) == 0
"#
}
"FriendsFrame.lua" => {
r#"
-- The reported line 490 sits inside `GuildStatus_Update`, so the probe calls THAT, not a
-- transcription of the line — the traceback then names FriendsFrame.lua:490 the way the
-- session did. The handful of globals it touches first are stubbed here; each is a
-- no-argument answer the real binding supplies, and an empty roster means the filter
-- loop never runs, which leaves the trim/wipe pair as the reachable work.
local function stub(n) return function() return n end end
-- Every widget this file reaches for is created by `FriendsFrame.xml` at load time, not
-- by its Lua, so a Lua-only chunk starts with none of them. Naming each one would be a
-- list as long as the file; a `_G` metatable answers them all the way the XML would, and
-- it is keyed on the name so the API the probe IS testing still raises.
local FRAME_NAMES = {
FriendsFrame = true, GuildFrame = true, SearchResult = true,
}
local function frame_stub()
return { Enable = stub(nil), Disable = stub(nil), Show = stub(nil),
Hide = stub(nil), SetText = stub(nil), SetTextColor = stub(nil),
SetPoint = stub(nil), ClearAllPoints = stub(nil), SetParent = stub(nil),
SetHeight = stub(nil), SetWidth = stub(nil), SetScale = stub(nil),
ShowChildren = stub(nil), GetText = stub(" Guild Name "),
GetName = stub("ProbeFrame"), GetObjectType = stub("Frame"),
GetParent = stub(nil), IsShown = stub(nil), GetNumChildren = stub(0),
GetChildren = stub({}), SetAllPoints = stub(nil), SetFocus = stub(nil) }
end
local under_test = { wipe = true, trim = true, strsplit = true, strjoin = true }
-- Answered as frames, but really bare functions in the 1.12 UI: a frame stub would
-- raise `attempt to call global 'X' (a table value)` on the first one the file reaches.
-- 1.12's FauxScrollFrame API is bare functions (`FauxScrollFrame_Update`, `..._SetOffset`),
-- and the family is long enough that listing it is how the probe goes stale. Every one
-- is matched by prefix; the `Get*` shapes answer 0, which is an empty scrollbar.
local function is_bare(name)
return strfind(name, "FauxScrollFrame_") ~= nil
end
setmetatable(_G, {
__index = function(_, name)
-- The four names the probe is here to test stay nil, so a missing one still
-- raises `attempt to call global '<name>'` at the real line.
if under_test[name] then return nil end
if is_bare(name) then
local gets = strfind(name, "_Get") ~= nil
local fn = stub(gets and 0 or nil)
rawset(_G, name, fn)
return fn
end
if not (strfind(name, "Frame") or strfind(name, "Button")
or strfind(name, "Text") or FRAME_NAMES[name]) then
return nil
end
local f = frame_stub()
rawset(_G, name, f)
return f
end,
})
-- Bare-API globals the metatable above would otherwise answer with a frame stub, because
-- their names read like widgets: `GetText("GUILD_TOTAL", ...)` is 1.12's localisation
-- lookup, not a widget method.
GetText = stub("%d")
GUILD_TOTAL = "Guild Total"
GUILD_TOTALONLINE = "Guild Total Online"
-- The same trap for the FauxScrollFrame API: `FauxScrollFrame_*` are bare functions in
-- the 1.12 UI, but the name matches no widget suffix, so they are listed as one.
PanelTemplates_SetTab = stub(nil)
ShowUIPanel = stub(nil)
HideUIPanel = stub(nil)
GetNumGuildMembers = stub(0)
GetGuildRosterInfo = stub("")
GetGuildRosterSelection = stub(0)
GetGuildRosterMOTD = stub("")
FRIENDS_LEVEL_TEMPLATE = "%d %s"
IsGuildLeader = stub(nil)
CanGuildInvite = stub(nil)
CanEditMOTD = stub(nil)
GetGuildInfo = stub(nil)
GetGuildLeaderInfo = stub(nil)
UnitName = stub("probe")
GetClassInfo = stub("Warrior")
GUILD_MOTD_EDITLABEL = "motd"
GuildStatus_Update()
-- the wipe ran on the global roster table, and the trim fed it a clean search string
return getn(SearchResult) == 0
"#
}
other => panic!("no probe written for {other} — add the function its error named"),
};
let ok: bool = script
.eval(call)
.unwrap_or_else(|e| fail(&format!("the reported call in {name}"), e));
if !ok {
eprintln!(
"FAILED: the reported call in {name} ran but did not behave as 1.12-era code expects"
);
std::process::exit(1);
}
println!("OK: {name} loaded and the reported call ran clean");
}
fn file_name(path: &str) -> String {
path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()
}
fn fail(stage: &str, e: mlua::Error) -> ! {
eprintln!("FAILED while {stage}: {e}");
std::process::exit(1);
}
+32 -3
View File
@@ -243,7 +243,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetInboxHeaderInfo(index): the 13 values `MailFrame.lua:105` reads; nil out of range.
// GetInboxHeaderInfo(index): the 13 values `MailFrame.lua:105` reads; out of range, money,
// CODAmount and daysLeft are 0 and the rest nil, never a single nil — the reference answers
// every path with the full tuple, as its siblings do (`0x4af559`, `0x4af5d0`), and a stale
// open letter that reads past the end would otherwise raise `MailFrame.lua:399`'s
// `CODAmount > 0` on a nil.
g.set(
"GetInboxHeaderInfo",
lua.create_function(|lua, index: usize| {
@@ -252,7 +256,21 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
row_at(&model, index)
};
let Some(r) = row else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
return Ok(MultiValue::from_vec(vec![
Value::Nil, // packageIcon
Value::Nil, // stationeryIcon
Value::Nil, // sender
Value::Nil, // subject
Value::Integer(0), // money
Value::Integer(0), // CODAmount
Value::Number(0.0), // daysLeft
Value::Nil, // hasItem
Value::Nil, // wasRead
Value::Nil, // wasReturned
Value::Nil, // textCreated
Value::Nil, // canReply
Value::Nil, // isGM
]));
};
let opt_str = |s: &Option<String>| -> mlua::Result<Value> {
Ok(match s {
@@ -889,8 +907,19 @@ mod tests {
fn inbox_header_reads_the_reference_tuple() {
let mut s = UiScript::new().unwrap();
assert_eq!(s.eval::<i64>("return GetInboxNumItems()").unwrap(), 0);
// Out of range is still the full 13-tuple: the numeric fields are 0, so a stale open
// letter reading past the end never feeds `MailFrame.lua:399`'s `CODAmount > 0` a nil.
assert!(s
.eval::<bool>("return GetInboxHeaderInfo(1) == nil")
.eval::<bool>(
"local a,b,c,d,e,f,g,h,i,j,k,l,m = GetInboxHeaderInfo(1)\n\
return a == nil and b == nil and c == nil and d == nil \
and e == 0 and f == 0 and g == 0 \
and h == nil and i == nil and j == nil and k == nil and l == nil and m == nil",
)
.unwrap());
// The first value is still nil, as the reference's miss leads with nils.
assert!(s
.eval::<bool>("return GetInboxHeaderInfo(9) == nil")
.unwrap());
s.set_mail(Some(state()));
+69 -8
View File
@@ -286,14 +286,75 @@ __benilla_game_hour = 0
__benilla_game_minute = 0
function GetGameTime() return __benilla_game_hour, __benilla_game_minute end
-- ── NOT here: wipe / tostringall / strsplit / strjoin / strconcat / strtrim ────────────────────
-- Six 2.0+ names benilla used to define. 1.12 has none of them (absent from
-- `reference/1.12-globals.tsv`, from the stock chain, and from every registrar table), and the
-- reason they were kept — that the vanilla addon ecosystem assumes them — did not survive being
-- checked: the corpus callers are multi-client addons whose vanilla paths raise on a real 1.12
-- client too, or that define the name themselves under their own namespace.
-- An addon reaching for one of these is reaching for a later client's API, and it should find
-- what it would find there: nothing.
-- ── Beyond 1.12: wipe / strsplit / strjoin / trim ─────────────────────────────────────────────
-- Four names 1.12 does NOT have: absent from `reference/1.12-globals.tsv`, absent from the stock
-- chain (no stock `Interface\FrameXML` file calls any of them — `UIParent.lua`, `ChatFrame.lua`,
-- `MainMenuBar.lua` and `FriendsFrame.lua` read clean), and absent from every registrar table. They
-- are here anyway because a MODIFIED 1.12 UI ships them: TurtleWoW's `patch-4.mpq` replaces
-- `UIParent.lua` with a longer 2.0-era file whose `UIFrameFade_CreateInfo` calls `wipe(frame.fadeInfo)`
-- at line 1420, and replaces `FriendsFrame.lua` with one whose guild-roster filter calls
-- `trim(strlower(GuildFrameSearchBox:GetText()))` at line 490. Each raises
-- `attempt to call global '<name>' (a nil value)` and takes the whole UI down with it. The exception
-- list in `tests/reference_surface.rs` carries the same reason.
--
-- `trim` is not Blizzard's name at all — 1.12 has no `strtrim` either. It is TurtleWoW's own, for
-- `strtrim` semantics: every caller defines it identically as `gsub(str, "^%s*(.-)%s*$", "%1")`
-- (aux-addon's `util.lua`, ShaguDPS's `parser.lua`), which is the shape transcribed here.
--
-- This is a deviation from the reference, kept because 1.12's own UI cannot load without it. The
-- shapes are 2.0's, not 1.12's, and each is scoped to what the reference-corpus callers use:
-- `wipe` is single-argument (no corpus caller passes a key), and `strsplit` splits on PLAIN text.
do
local _find, _sub, _insert, _concat = string.find, string.sub, table.insert, table.concat
local _gsub = string.gsub
function wipe(t)
for k in pairs(t) do t[k] = nil end
end
function strsplit(delimiter, subject)
-- 5.0's `find` answers an empty plain pattern as `(from, from - 1)`, a zero-width match that
-- never advances `from`; looping on it fills the table until it overflows. There is nothing
-- to split on, so the subject comes back whole — the one shape a plain find cannot produce.
if delimiter == "" then return subject end
local result = {}
local from = 1
local dfrom, dto = _find(subject, delimiter, from, true)
while dfrom do
_insert(result, _sub(subject, from, dfrom - 1))
from = dto + 1
dfrom, dto = _find(subject, delimiter, from, true)
end
_insert(result, _sub(subject, from))
return unpack(result)
end
-- `...` reads as an EXPRESSION nowhere in 5.0 (`lua50` deletes that `simpleexp` arm), so
-- `strjoin` takes its arguments out of `arg`, which holds the VARARGS alone — 5.0's rule, and
-- `function(a, ...)` called with three answers `arg.n == 2`. The named `delimiter` is not in it.
function strjoin(delimiter, ...)
local out = {}
local n = arg.n
for i = 1, n do
out[i] = arg[i]
end
return _concat(out, delimiter)
end
-- `(.-)` is lazy, so the trailing `%s*` consumes to the LAST space and the leading one to the
-- first: both ends go, interior spacing stays. Only `%s` counts as space, as in every caller.
function trim(str)
local trimmed = _gsub(str, "^%s*(.-)%s*$", "%1")
return trimmed
end
end
-- ── NOT here: tostringall / strconcat / strtrim ────────────────────────────────────────────────
-- Three more 2.0+ names benilla used to define, kept out for the reason the four above were:
-- 1.12 has none of them, and no modified-1.12 UI ships a caller that needs them. The corpus
-- callers are multi-client addons whose vanilla paths raise on a real 1.12 client too, or that
-- define the name themselves under their own namespace. An addon reaching for one of these is
-- reaching for a later client's API, and it should find what it would find there: nothing.
-- ── Blizzard's positional string.format (%N$) ──────────────────────────────────────────────────
do
@@ -32,6 +32,17 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
// 1.12's base library does not export `_G` (an addon reaches the globals with
// `getfenv(0)`); ours does, as our `getglobal`/`setglobal` are written over it.
"_G",
// ── 2.0+ names a MODIFIED 1.12 UI calls ──
// 1.12 has none of these (absent from the reference table and from the stock chain), but
// TurtleWoW's `patch-4.mpq` ships 2.0-era `UIParent.lua` and `FriendsFrame.lua` whose
// `UIFrameFade_CreateInfo` calls `wipe` and whose guild-roster filter calls `trim`; both
// raise and take the UI down. `strsplit`/`strjoin` ride along because the modified FrameXML
// and the addon corpus behind it reach for the same string family. `trim` is TurtleWoW's own
// name for `strtrim` semantics — 1.12 has `strtrim` neither.
"wipe",
"strsplit",
"strjoin",
"trim",
// ── WoW API past 1.12 ──
// Only `SubmitChatInput` is called from our UI files (`ScriptLogFrame.xml`'s slash
// commands); each of the rest awaits its 1.12 equivalent, removal or a reason.
+126 -3
View File
@@ -57,14 +57,137 @@ fn stdlib_aliases_and_helpers() {
tremove(t, 1)
assert(t[1] == 20)
-- and the six 2.0 names that are NOT here
assert(wipe == nil and tostringall == nil)
assert(strsplit == nil and strjoin == nil and strconcat == nil and strtrim == nil)
-- the four names a MODIFIED 1.12 UI calls are here (see stdlib.rs and the exception
-- list): TurtleWoW's `UIParent.lua` needs `wipe` and its `FriendsFrame.lua` needs `trim`,
-- or the UI does not load
assert(type(wipe) == "function" and type(strsplit) == "function")
assert(type(strjoin) == "function" and type(trim) == "function")
-- and the two 2.0 names that are still NOT here
assert(tostringall == nil and strconcat == nil)
"#,
)
.unwrap();
}
/// `wipe` empties the table IN PLACE (the reference's `UIFrameFade_CreateInfo` reuses the same
/// `fadeInfo` table across calls) and returns nothing.
#[test]
fn wipe_empties_in_place_and_returns_nothing() {
let s = script();
assert_eq!(
s.eval::<i64>(
r#"
local t = { "a", 2, [ "k" ] = true }
local held = t
wipe(t)
return getn(t) == 0 and t.k == nil and held == t and 1 or 0
"#
)
.unwrap(),
1,
"wipe must empty the caller's own table, not replace it, and must leave no keys"
);
// 1.12's VM has no `select` (a 5.1 addition), so the arity is read by packing into locals:
// only a no-value return leaves both nil.
let returns_nothing: bool = s
.eval("local a, b = wipe({1}) return a == nil and b == nil")
.unwrap();
assert!(returns_nothing, "wipe returns no values");
}
/// `strsplit` splits on PLAIN text, not Lua patterns: a `.` is a full stop, not "any character".
#[test]
fn strsplit_splits_plain_and_keeps_empty_fields() {
let s = script();
// the version-string shape addons use: strsplit(".", "1.2.3")
assert_eq!(
s.eval::<String>(r#"local a, b, c = strsplit(".", "1.2.3") return a .. b .. c"#)
.unwrap(),
"123"
);
// "." as a PATTERN would match any char and answer six empty fields; plain text does not.
// Read through locals: 1.12's VM has no `select`, so arity is "is the second value nil".
assert!(
s.eval::<bool>(r#"local a, b = strsplit(".", "abc") return a == "abc" and b == nil"#)
.unwrap(),
"a plain-text delimiter that is absent must yield the whole subject, not a pattern match"
);
// a trailing delimiter keeps the trailing empty field
assert_eq!(
s.eval::<i64>(r#"local a, b, c = strsplit(":", "a:b:") return (a == "a" and b == "b" and c == "") and 1 or 0"#)
.unwrap(),
1
);
// an empty subject is one empty field
assert_eq!(
s.eval::<i64>(r#"local a = strsplit(":", "") return (a == "") and 1 or 0"#)
.unwrap(),
1
);
// an empty delimiter must not hang the loop (5.0's find answers it as a zero-width match)
assert_eq!(
s.eval::<i64>(
r#"local a, b = strsplit("", "ab") return (a == "ab" and b == nil) and 1 or 0"#
)
.unwrap(),
1
);
}
/// `trim` is TurtleWoW's own name for `strtrim` semantics, and every one of its callers writes it
/// identically as `gsub(str, "^%s*(.-)%s*$", "%1")` (aux-addon `util.lua`, ShaguDPS `parser.lua`).
/// Both ends go, interior spacing stays — the guild-roster filter relies on the whole of it.
#[test]
fn trim_strips_both_ends_and_keeps_interior_spacing() {
let s = script();
assert_eq!(
s.eval::<String>(r#"return trim(" guild name ")"#)
.unwrap(),
"guild name"
);
// the reported call: a lowercased search-box read, which is usually already tight
assert_eq!(
s.eval::<String>(r#"return trim(strlower(" Smith "))"#)
.unwrap(),
"smith"
);
// lazy capture: trailing `%s*` runs to the LAST space, so interior spacing survives whole
assert_eq!(
s.eval::<String>(r#"return trim(" a b c ")"#)
.unwrap(),
"a b c"
);
// tabs and newlines are `%s` too, and an all-space string trims to ""
assert_eq!(
s.eval::<String>(r#"return trim("\t\n x \n")"#).unwrap(),
"x"
);
assert_eq!(s.eval::<String>(r#"return trim(" ")"#).unwrap(), "");
assert_eq!(s.eval::<String>(r#"return trim("")"#).unwrap(), "");
}
/// `strjoin` is the inverse: one delimiter between every argument, no trailing delimiter.
#[test]
fn strjoin_places_the_delimiter_between_arguments_only() {
let s = script();
assert_eq!(
s.eval::<String>(r#"return strjoin(":", "a", "b", "c")"#)
.unwrap(),
"a:b:c"
);
assert_eq!(
s.eval::<String>(r#"return strjoin(":", "solo")"#).unwrap(),
"solo"
);
assert_eq!(s.eval::<String>(r#"return strjoin(":", "")"#).unwrap(), "");
// round-trips against strsplit, the pair's real use
assert_eq!(
s.eval::<String>(r#"return strjoin("|", strsplit("|", "a|b|c"))"#)
.unwrap(),
"a|b|c"
);
}
// ── Sandbox holes ──
#[test]
+3 -1
View File
@@ -61,7 +61,9 @@ pub struct CloudFxKey {
impl From<&CloudExt> for CloudFxKey {
fn from(e: &CloudExt) -> Self {
Self { high: e.fx.x >= 1.5 }
Self {
high: e.fx.x >= 1.5,
}
}
}
+285 -119
View File
@@ -649,15 +649,16 @@ where
// whose missing side is exterior by definition (there is no group out there).
let mut sides: Vec<(u16, Vec<u16>)> = Vec::new();
if want_portals {
let note = |portal: u16, group: u16, sides: &mut Vec<(u16, Vec<u16>)>| {
match sides.iter_mut().find(|(p, _)| *p == portal) {
Some((_, gs)) => {
if !gs.contains(&group) {
gs.push(group);
}
let note = |portal: u16, group: u16, sides: &mut Vec<(u16, Vec<u16>)>| match sides
.iter_mut()
.find(|(p, _)| *p == portal)
{
Some((_, gs)) => {
if !gs.contains(&group) {
gs.push(group);
}
None => sides.push((portal, vec![group])),
}
None => sides.push((portal, vec![group])),
};
for (gi, (start, count)) in portals.slices.iter().enumerate() {
let (start, count) = (usize::from(*start), usize::from(*count));
@@ -1469,15 +1470,16 @@ pub fn bleed_seeds(groups: &[WmoGroupInfo], portals: PortalGraph<'_>) -> Vec<Ble
return Vec::new();
}
let mut sides: Vec<(u16, Vec<u16>)> = Vec::new();
let note = |portal: u16, group: u16, sides: &mut Vec<(u16, Vec<u16>)>| {
match sides.iter_mut().find(|(p, _)| *p == portal) {
Some((_, gs)) => {
if !gs.contains(&group) {
gs.push(group);
}
let note = |portal: u16, group: u16, sides: &mut Vec<(u16, Vec<u16>)>| match sides
.iter_mut()
.find(|(p, _)| *p == portal)
{
Some((_, gs)) => {
if !gs.contains(&group) {
gs.push(group);
}
None => sides.push((portal, vec![group])),
}
None => sides.push((portal, vec![group])),
};
for (gi, (start, count)) in portals.slices.iter().enumerate() {
let (start, count) = (usize::from(*start), usize::from(*count));
@@ -1626,7 +1628,12 @@ pub fn quota_select(
// and 1 for bleed — the tie the inn's p0/p1 pair lands on. `index` makes the order TOTAL, so
// two identical openings can never swap between launches.
let mut best: Option<(u8, f32, u8, usize, bool)> = None;
let consider = |round: u8, area: f32, lane: u8, i: usize, is_day: bool, best: &mut Option<(u8, f32, u8, usize, bool)>| {
let consider = |round: u8,
area: f32,
lane: u8,
i: usize,
is_day: bool,
best: &mut Option<(u8, f32, u8, usize, bool)>| {
let key = (round, -area, lane, i);
if best.is_none_or(|b| key < (b.0, b.1, b.2, b.3)) {
*best = Some((key.0, key.1, key.2, key.3, is_day));
@@ -2017,62 +2024,64 @@ pub fn update_bleed_fixtures(
let src: Vec<BleedSource<'_>> = if on && !live.is_empty() {
sources
.iter()
.filter_map(|(pl, gt, reach, lit_rooms, lane, rooms, synthetic, daylight, spell)| {
if lit_rooms.is_some_and(|c| {
!c.rooms.groups.is_empty() && !live.contains(&c.rooms.instance)
}) {
return None;
}
// The packer's own lane verdict, fallback included (a light lives a frame or two
// before the classifier's first pass).
if !lane.map_or_else(|| rooms.is_some(), |l| l.interior) {
return None;
}
let c = pl.color;
let base = pl.intensity / (4.0 * std::f32::consts::PI);
// `fireLightGain` IS mirrored (a dial that darkens the hearth must darken what the
// doorway carries of it); the FLICKER deliberately is not.
//
// MONKEY (spellLightGain): and the spell dial the same way, in the same ORDER the
// packer folds them (`global_light::build_light_data` ~:1328) — spell first, fire
// second, because a spell light carries BOTH markers and the two dials answer
// different questions (a hearth's brightness vs how hard combat may strobe a room).
// Without the mirror a player who turned `spellLightGain` to 0 would still see the
// fireball's light arrive through the doorway while its own row was dark.
let s = base
* if spell {
spell_gain.0.max(0.0)
} else if synthetic {
fire_gain.0.max(0.0)
} else {
.filter_map(
|(pl, gt, reach, lit_rooms, lane, rooms, synthetic, daylight, spell)| {
if lit_rooms.is_some_and(|c| {
!c.rooms.groups.is_empty() && !live.contains(&c.rooms.instance)
}) {
return None;
}
// The packer's own lane verdict, fallback included (a light lives a frame or two
// before the classifier's first pass).
if !lane.map_or_else(|| rooms.is_some(), |l| l.interior) {
return None;
}
let c = pl.color;
let base = pl.intensity / (4.0 * std::f32::consts::PI);
// `fireLightGain` IS mirrored (a dial that darkens the hearth must darken what the
// doorway carries of it); the FLICKER deliberately is not.
//
// MONKEY (spellLightGain): and the spell dial the same way, in the same ORDER the
// packer folds them (`global_light::build_light_data` ~:1328) — spell first, fire
// second, because a spell light carries BOTH markers and the two dials answer
// different questions (a hearth's brightness vs how hard combat may strobe a room).
// Without the mirror a player who turned `spellLightGain` to 0 would still see the
// fireball's light arrive through the doorway while its own row was dark.
let s = base
* if spell {
spell_gain.0.max(0.0)
} else if synthetic {
fire_gain.0.max(0.0)
} else {
1.0
};
// …and `interiorGain`, which the packer applies to every interior fixture EXCEPT a
// `DaylightFixture`. Mirroring that exclusion here is what keeps the doorway from
// being dimmed twice: a bleed fixture IS a `DaylightFixture`, so the packer will not
// scale it, and the gain therefore has to be in the TARGET.
// MONKEY (lava light): the shared caster exclusion does not make magma sunlight.
let g = if daylight.is_some_and(|f| f.how != DaylightHow::Lava) {
1.0
} else {
knobs.interior_gain
};
// …and `interiorGain`, which the packer applies to every interior fixture EXCEPT a
// `DaylightFixture`. Mirroring that exclusion here is what keeps the doorway from
// being dimmed twice: a bleed fixture IS a `DaylightFixture`, so the packer will not
// scale it, and the gain therefore has to be in the TARGET.
// MONKEY (lava light): the shared caster exclusion does not make magma sunlight.
let g = if daylight.is_some_and(|f| f.how != DaylightHow::Lava) {
1.0
} else {
knobs.interior_gain
};
let c_norm = commit_norm([
(c[0] * s * g).max(0.0),
(c[1] * s * g).max(0.0),
(c[2] * s * g).max(0.0),
]);
let r = reach
.map(|r| r.0)
.filter(|r| *r > 0.5)
.unwrap_or_else(|| super::m2_light_reach(base));
Some(BleedSource {
pos: gt.translation(),
c_norm,
r_eff: super::interior_reach(r, knobs.atten_scale),
claims: lit_rooms,
})
})
let c_norm = commit_norm([
(c[0] * s * g).max(0.0),
(c[1] * s * g).max(0.0),
(c[2] * s * g).max(0.0),
]);
let r = reach
.map(|r| r.0)
.filter(|r| *r > 0.5)
.unwrap_or_else(|| super::m2_light_reach(base));
Some(BleedSource {
pos: gt.translation(),
c_norm,
r_eff: super::interior_reach(r, knobs.atten_scale),
claims: lit_rooms,
})
},
)
.collect()
} else {
Vec::new()
@@ -2120,8 +2129,22 @@ pub fn update_bleed_fixtures(
let r_eff = super::interior_reach(reach.map_or(fx.reach, |r| r.0), knobs.atten_scale);
let lit = if on {
brighter(
room_irradiance(placement_src(fx.instance), &[], bl.probe, fx.instance, bl.sides[0], k_fill),
room_irradiance(placement_src(fx.instance), &[], bl.probe, fx.instance, bl.sides[1], k_fill),
room_irradiance(
placement_src(fx.instance),
&[],
bl.probe,
fx.instance,
bl.sides[0],
k_fill,
),
room_irradiance(
placement_src(fx.instance),
&[],
bl.probe,
fx.instance,
bl.sides[1],
k_fill,
),
)
} else {
[0.0; 3]
@@ -2306,27 +2329,59 @@ mod tests {
groups.extend((0..40).map(|_| group(true, lo, hi)));
let vertices = [[0.0f32; 3]; 4];
let infos: Vec<WmoPortalInfo> = (0..3)
.map(|_| WmoPortalInfo { start_vertex: 0, count: 4, plane: [1.0, 0.0, 0.0, 0.0] })
.map(|_| WmoPortalInfo {
start_vertex: 0,
count: 4,
plane: [1.0, 0.0, 0.0, 0.0],
})
.collect();
// p0: g1<->g0 (exterior), p1: g1<->g2, p2: g2<->g3.
let refs = [
WmoPortalRef { portal: 0, group: 0, side: 1 },
WmoPortalRef { portal: 1, group: 2, side: 1 },
WmoPortalRef { portal: 1, group: 1, side: -1 },
WmoPortalRef { portal: 2, group: 3, side: 1 },
WmoPortalRef { portal: 2, group: 2, side: -1 },
WmoPortalRef {
portal: 0,
group: 0,
side: 1,
},
WmoPortalRef {
portal: 1,
group: 2,
side: 1,
},
WmoPortalRef {
portal: 1,
group: 1,
side: -1,
},
WmoPortalRef {
portal: 2,
group: 3,
side: 1,
},
WmoPortalRef {
portal: 2,
group: 2,
side: -1,
},
];
let mut slices = vec![(0u16, 0u16); groups.len()];
slices[1] = (0, 2);
slices[2] = (2, 2);
slices[3] = (4, 1);
let portals = PortalGraph { vertices: &vertices, infos: &infos, refs: &refs, slices: &slices };
let portals = PortalGraph {
vertices: &vertices,
infos: &infos,
refs: &refs,
slices: &slices,
};
let none: [(u16, bool, &[[f32; 3]]); 0] = [];
let sky = district_sky_rooms(&groups, portals, none);
assert!(sky[1] && sky[2], "the doorway room and its neighbour");
assert!(!sky[3], "two hops in stays dark");
assert!(!sky[0], "the shell is not a room");
assert!(district_sky_rooms(&groups[..5], portals, none).is_empty(), "a building is untouched");
assert!(
district_sky_rooms(&groups[..5], portals, none).is_empty(),
"a building is untouched"
);
// A window (EXT-class batch) makes g3 a sky room too.
let window = [[0.0f32; 3], [1.0, 1.0, 1.0]];
let sky = district_sky_rooms(&groups, portals, [(3u16, true, &window[..])]);
@@ -2347,7 +2402,11 @@ mod tests {
}
}
pts.push([0.0, 0.0, 0.0]);
assert_eq!(window_boxes(&pts, false).len(), 1, "a building keeps the whole batch");
assert_eq!(
window_boxes(&pts, false).len(),
1,
"a building keeps the whole batch"
);
let panes = window_boxes(&pts, true);
assert_eq!(panes.len(), 3, "a district splits it into its panes");
assert!(panes.iter().all(|(l, h)| diagonal(*l, *h) < 6.0));
@@ -2360,7 +2419,7 @@ mod tests {
assert_eq!(daylight_reach(2.0), 7.0);
assert!((daylight_reach(2.6) - 7.9).abs() < 1e-5); // a 1.2 x 2.2 yd door
assert_eq!(daylight_reach(100.0), 20.0); // the cap
// Monotone in between, so a wider opening never throws light LESS far.
// Monotone in between, so a wider opening never throws light LESS far.
let mut prev = 0.0;
for i in 0..40 {
let r = daylight_reach(i as f32 * 0.5);
@@ -2388,19 +2447,51 @@ mod tests {
[0.0, -1.0, 2.0],
];
let infos = [
WmoPortalInfo { start_vertex: 0, count: 4, plane: [1.0, 0.0, 0.0, 0.0] },
WmoPortalInfo { start_vertex: 0, count: 4, plane: [1.0, 0.0, 0.0, 0.0] },
WmoPortalInfo { start_vertex: 0, count: 4, plane: [1.0, 0.0, 0.0, 0.0] },
WmoPortalInfo {
start_vertex: 0,
count: 4,
plane: [1.0, 0.0, 0.0, 0.0],
},
WmoPortalInfo {
start_vertex: 0,
count: 4,
plane: [1.0, 0.0, 0.0, 0.0],
},
WmoPortalInfo {
start_vertex: 0,
count: 4,
plane: [1.0, 0.0, 0.0, 0.0],
},
];
// p0: g0 <-> g1 (interior to exterior — a doorway).
// p1: g0 <-> g2 (room to room — not).
// p2: named by g2 alone (a portal to the outside — a doorway).
let refs = [
WmoPortalRef { portal: 0, group: 1, side: 1 },
WmoPortalRef { portal: 1, group: 2, side: 1 },
WmoPortalRef { portal: 0, group: 0, side: -1 },
WmoPortalRef { portal: 1, group: 0, side: -1 },
WmoPortalRef { portal: 2, group: 2, side: 1 },
WmoPortalRef {
portal: 0,
group: 1,
side: 1,
},
WmoPortalRef {
portal: 1,
group: 2,
side: 1,
},
WmoPortalRef {
portal: 0,
group: 0,
side: -1,
},
WmoPortalRef {
portal: 1,
group: 0,
side: -1,
},
WmoPortalRef {
portal: 2,
group: 2,
side: 1,
},
];
// g0 owns refs 0..2, g1 owns 2..3, g2 owns 3..5.
let slices = [(0, 2), (2, 1), (3, 2)];
@@ -2551,8 +2642,15 @@ mod tests {
let kept = daylight_seeds(&groups, PortalGraph::default(), batches());
assert_eq!(kept.len(), MAX_DAYLIGHT_PER_PLACEMENT);
let rooms: HashSet<u16> = kept.iter().map(|s| s.group).collect();
assert_eq!(rooms.len(), MAX_DAYLIGHT_PER_PLACEMENT, "one each: {kept:?}");
assert!(kept.iter().all(|s| s.round == 0), "all first lights: {kept:?}");
assert_eq!(
rooms.len(),
MAX_DAYLIGHT_PER_PLACEMENT,
"one each: {kept:?}"
);
assert!(
kept.iter().all(|s| s.round == 0),
"all first lights: {kept:?}"
);
// …and the eight rooms are the ones with the WIDEST first opening, in that order: within a
// round the old area rank is untouched.
for (n, s) in kept.iter().enumerate() {
@@ -2576,7 +2674,11 @@ mod tests {
// The budget TRUNCATES, at any size, and a budget of zero spends nothing.
assert_eq!(quota_select(&ranked, &[], 5).0.len(), 5);
assert_eq!(quota_select(&ranked, &[], 0).0.len(), 0);
assert_eq!(quota_select(&ranked, &[], 999).0.len(), 24, "runs out of seeds, not slots");
assert_eq!(
quota_select(&ranked, &[], 999).0.len(),
24,
"runs out of seeds, not slots"
);
}
/// MONKEY (daylight quota): the budget itself — a building holds at eight, a district scales
@@ -2591,19 +2693,28 @@ mod tests {
// The whole small-building corpus (1 183 of 1 211 shipped roots) is on this arm, the inn
// (10 interior groups) and the abbey (11) included: nothing moves.
assert_eq!(daylight_budget(&rooms(0, true)), MAX_DAYLIGHT_PER_PLACEMENT);
assert_eq!(daylight_budget(&rooms(10, true)), MAX_DAYLIGHT_PER_PLACEMENT);
assert_eq!(
daylight_budget(&rooms(10, true)),
MAX_DAYLIGHT_PER_PLACEMENT
);
assert_eq!(
daylight_budget(&rooms(DAYLIGHT_DISTRICT_ROOMS, true)),
MAX_DAYLIGHT_PER_PLACEMENT
);
// One room past the line it is a district and pays per room — Ironforge's 103 interior
// groups, Stormwind's 190 against the 128 ceiling.
assert_eq!(daylight_budget(&rooms(DAYLIGHT_DISTRICT_ROOMS + 1, true)), 33);
assert_eq!(
daylight_budget(&rooms(DAYLIGHT_DISTRICT_ROOMS + 1, true)),
33
);
assert_eq!(daylight_budget(&rooms(103, true)), 103);
assert_eq!(daylight_budget(&rooms(190, true)), DAYLIGHT_BUDGET_MAX);
// EXTERIOR groups are not rooms: Stormwind's 116 exterior-class groups buy nothing, which is
// why the count is `g.interior` and not `groups.len()`.
assert_eq!(daylight_budget(&rooms(200, false)), MAX_DAYLIGHT_PER_PLACEMENT);
assert_eq!(
daylight_budget(&rooms(200, false)),
MAX_DAYLIGHT_PER_PLACEMENT
);
}
/// MONKEY (daylight quota): a BLEED serves TWO rooms, so its round is the lower of them — a
@@ -2653,7 +2764,10 @@ mod tests {
let (d4, b4) = quota_select(&day2, &bleed, 3);
assert_eq!(b4.len(), 1);
assert!(d4.iter().all(|s| s.round == 0), "{d4:?}");
assert!(d4.iter().any(|s| s.group == 1), "g1's window is a first light: {d4:?}");
assert!(
d4.iter().any(|s| s.group == 1),
"g1's window is a first light: {d4:?}"
);
}
/// MONKEY (daylight fixtures: boundary): the stitched-threshold seed — it finds the shared
@@ -2666,13 +2780,9 @@ mod tests {
group(false, [-10.0, -10.0, -1.0], [20.0, 20.0, 10.0]),
];
// THE DOORWAY: a 4 yd run of floor vertices on the y = 0 wall, authored into BOTH groups.
let door: Vec<[f32; 3]> = (0..=8)
.map(|i| [2.0 + i as f32 * 0.5, 0.0, 0.0])
.collect();
let door: Vec<[f32; 3]> = (0..=8).map(|i| [2.0 + i as f32 * 0.5, 0.0, 0.0]).collect();
// A PARTY-WALL seam: the same stitch, but running 5 yd UP the x = 10 wall.
let wall: Vec<[f32; 3]> = (0..=10)
.map(|i| [10.0, 8.0, i as f32 * 0.5])
.collect();
let wall: Vec<[f32; 3]> = (0..=10).map(|i| [10.0, 8.0, i as f32 * 0.5]).collect();
// A CORNER stitch: three coincident vertices where two walls meet the floor.
let corner = [[0.0, 10.0, 0.0], [0.0, 10.0, 0.2], [0.2, 10.0, 0.0]];
// …and interior-only floor the shell never sees.
@@ -2701,7 +2811,11 @@ mod tests {
// but its bearing is the centroid's, not the wall normal's. And it stands SEED_MAX_HZ above
// the threshold rather than on it.
let step = ((s.pos[0] - 4.0).powi(2) + s.pos[1].powi(2)).sqrt();
assert!((step - NUDGE_YD).abs() < 1e-3, "pos {:?} step {step}", s.pos);
assert!(
(step - NUDGE_YD).abs() < 1e-3,
"pos {:?} step {step}",
s.pos
);
assert!(s.pos[1] > 0.0, "did not step into the room: {:?}", s.pos);
assert!((s.pos[2] - SEED_MAX_HZ).abs() < 1e-3, "pos {:?}", s.pos);
assert!((s.hz - SEED_MAX_HZ).abs() < 1e-3);
@@ -2720,7 +2834,6 @@ mod tests {
assert_eq!(with_portal[0].how, DaylightHow::Aperture);
}
/// MONKEY (portal bleed): the seed rule — interior<->interior portals only, standing IN the
/// opening, carrying both sides.
#[test]
@@ -2739,19 +2852,51 @@ mod tests {
[0.0, -1.0, 2.0],
];
let infos = [
WmoPortalInfo { start_vertex: 0, count: 4, plane: [1.0, 0.0, 0.0, 0.0] },
WmoPortalInfo { start_vertex: 0, count: 4, plane: [1.0, 0.0, 0.0, 0.0] },
WmoPortalInfo { start_vertex: 0, count: 4, plane: [1.0, 0.0, 0.0, 0.0] },
WmoPortalInfo {
start_vertex: 0,
count: 4,
plane: [1.0, 0.0, 0.0, 0.0],
},
WmoPortalInfo {
start_vertex: 0,
count: 4,
plane: [1.0, 0.0, 0.0, 0.0],
},
WmoPortalInfo {
start_vertex: 0,
count: 4,
plane: [1.0, 0.0, 0.0, 0.0],
},
];
// p0: g0 <-> g1 (a room to the SHELL — the daylight seed's population, not this one).
// p1: g0 <-> g2 (room to room — the one bleed seed).
// p2: named by g2 alone (a portal to the outside — not a doorway between two rooms).
let refs = [
WmoPortalRef { portal: 0, group: 1, side: 1 },
WmoPortalRef { portal: 1, group: 2, side: 1 },
WmoPortalRef { portal: 0, group: 0, side: -1 },
WmoPortalRef { portal: 1, group: 0, side: -1 },
WmoPortalRef { portal: 2, group: 2, side: 1 },
WmoPortalRef {
portal: 0,
group: 1,
side: 1,
},
WmoPortalRef {
portal: 1,
group: 2,
side: 1,
},
WmoPortalRef {
portal: 0,
group: 0,
side: -1,
},
WmoPortalRef {
portal: 1,
group: 0,
side: -1,
},
WmoPortalRef {
portal: 2,
group: 2,
side: 1,
},
];
let slices = [(0, 2), (2, 1), (3, 2)];
let portals = PortalGraph {
@@ -2770,7 +2915,11 @@ mod tests {
// the whole aperture argument — a doorway has no side, so there is no side to step onto).
assert!(seeds[0].pos[0].abs() < 1e-4, "pos {:?}", seeds[0].pos);
// The z clamp is `seed_point`'s: the quad's centre is 1 yd up, under `SEED_MAX_HZ`.
assert!((seeds[0].pos[2] - 1.0).abs() < 1e-4, "pos {:?}", seeds[0].pos);
assert!(
(seeds[0].pos[2] - 1.0).abs() < 1e-4,
"pos {:?}",
seeds[0].pos
);
// Both rooms are MOLR-seeded, so both are claimed HARD — a doorway lights either side.
let claims = seeds[0].claims(&groups, portals);
for g in [0u16, 2] {
@@ -2794,7 +2943,8 @@ mod tests {
let delivered = luminance(hue)
* i
* (core_atten(NUDGE_YD) * interior_window(NUDGE_YD, r_eff, INTERIOR_DIRECT_POW)
+ k_fill * interior_window(NUDGE_YD, INTERIOR_FILL_SPAN * r_eff, INTERIOR_FILL_POW));
+ k_fill
* interior_window(NUDGE_YD, INTERIOR_FILL_SPAN * r_eff, INTERIOR_FILL_POW));
assert!(
(delivered - BLEED_K * luminance(lit)).abs() < 1e-6,
"delivered {delivered} vs {}",
@@ -2844,10 +2994,18 @@ mod tests {
.collect();
let mut refs = Vec::new();
for i in 0..6u16 {
refs.push(WmoPortalRef { portal: i, group: 1, side: 1 });
refs.push(WmoPortalRef {
portal: i,
group: 1,
side: 1,
});
}
for i in 0..6u16 {
refs.push(WmoPortalRef { portal: i, group: 0, side: -1 });
refs.push(WmoPortalRef {
portal: i,
group: 0,
side: -1,
});
}
let slices = [(0, 6), (6, 6)];
let portals = PortalGraph {
@@ -2880,12 +3038,17 @@ mod tests {
);
// The widest doorways (2w x w = 2, 8, 18, 32*, 50*, 72* yd^2 -- starred ones refused as
// SPLIT cuts) outrank the 2 x 2 yd windows, and the narrowest doorway loses to them.
assert!(bleed.iter().all(|b| b.area < 28.0), "a split cut got seeded: {bleed:?}");
assert!(
bleed.iter().all(|b| b.area < 28.0),
"a split cut got seeded: {bleed:?}"
);
assert!(bleed.len() >= 2, "{bleed:?}");
for w in bleed.windows(2) {
assert!(w[0].area >= w[1].area);
}
assert!(bleed.iter().all(|b| b.sides.contains(&0) && b.sides.contains(&1)));
assert!(bleed
.iter()
.all(|b| b.sides.contains(&0) && b.sides.contains(&1)));
}
/// The sun curve: dark at and below the horizon, monotone up to full — and the calibration
@@ -2934,6 +3097,9 @@ mod tests {
// on the reference target, which is the whole claim the seam closure rests on.
let room = hue_lum * bright * (a + knobs.fill * w_fill) + knobs.ambient;
let illum = 1.0 - (-room * knobs.exposure).exp();
assert!((illum - t_day).abs() < 1e-3, "illum {illum} vs target {t_day}");
assert!(
(illum - t_day).abs() < 1e-3,
"illum {illum} vs target {t_day}"
);
}
}
@@ -272,8 +272,8 @@ fn city_daylight_census() {
#[test]
#[ignore = "instrument: needs WOW_DATA; run by hand with --ignored --nocapture"]
fn lamp_terrain_occlusion_scan() {
let spec = std::env::var("WOW_LAMP_SCAN")
.unwrap_or_else(|_| "Azeroth,-9300,150,1,lamppost".into());
let spec =
std::env::var("WOW_LAMP_SCAN").unwrap_or_else(|_| "Azeroth,-9300,150,1,lamppost".into());
let p: Vec<&str> = spec.split(',').collect();
let (map, cx, cy, r, filter) = (
p[0],
@@ -339,7 +339,13 @@ fn lamp_terrain_occlusion_scan() {
}
}
if total > 0 {
rows.push((blocked as f32 / total as f32, l, best, near, h(l[0], l[1]).unwrap_or(f32::NAN)));
rows.push((
blocked as f32 / total as f32,
l,
best,
near,
h(l[0], l[1]).unwrap_or(f32::NAN),
));
}
}
rows.sort_by(|a, b| b.3.cmp(&a.3).then(b.0.total_cmp(&a.0)));
+9 -5
View File
@@ -193,9 +193,7 @@ impl FlameFlicker {
(lo + span * (frac + jitter)).clamp(lo * 0.85, hi)
};
let phase = |k: u32| unit(mix(self.seed.wrapping_add(0x85eb_ca6b).wrapping_mul(k + 3)));
let s = |k: u32, frac: f32| {
(std::f32::consts::TAU * (f(k, frac) * t + phase(k))).sin()
};
let s = |k: u32, frac: f32| (std::f32::consts::TAU * (f(k, frac) * t + phase(k))).sin();
// Three sines plus one smoothed value-noise term. The noise is what stops the sum reading
// as a sum of sines — a pure harmonic stack has an audible-to-the-eye periodicity at the
// beat of its partials, and a fire has none. It runs at the SLOW edge of the band so it
@@ -367,7 +365,10 @@ mod tests {
m - 1.0
);
let rate = (m - prev).abs() / dt;
assert!(rate <= bound, "{kind:?} gain {gain}: {rate}/s over {bound}/s");
assert!(
rate <= bound,
"{kind:?} gain {gain}: {rate}/s over {bound}/s"
);
prev = m;
}
}
@@ -398,7 +399,10 @@ mod tests {
assert_eq!(a.at(t, 1.0).intensity, same.at(t, 1.0).intensity);
apart = apart.max((a.at(t, 1.0).intensity - b.at(t, 1.0).intensity).abs());
}
assert!(apart > 0.05, "two neighbouring candles ran in step: {apart}");
assert!(
apart > 0.05,
"two neighbouring candles ran in step: {apart}"
);
}
/// `fireFlicker 0` is the off switch — a hard identity, not merely a small wobble.
@@ -46,11 +46,13 @@ impl Default for FogModelSetting {
fn env_override() -> Option<FogModel> {
static ENV: std::sync::OnceLock<Option<FogModel>> = std::sync::OnceLock::new();
*ENV.get_or_init(|| {
std::env::var("WOW_FOGMODEL").ok().and_then(|v| match v.trim() {
"0" => Some(FogModel::Classic),
"1" => Some(FogModel::Modern),
_ => None,
})
std::env::var("WOW_FOGMODEL")
.ok()
.and_then(|v| match v.trim() {
"0" => Some(FogModel::Classic),
"1" => Some(FogModel::Modern),
_ => None,
})
})
}
+377 -168
View File
@@ -699,7 +699,12 @@ pub(super) fn register(app: &mut App) {
// fallback for one frame — see that fallback's note in `build_light_data`.
// MONKEY (p0 MonkeyFrame): the programme block is packed right after the table.
// GFX (moonlight): the moon row is written into the frame just before it is packed.
(classify_light_lanes, build_light_data, super::moonlight::update_moonlight, pack_monkey_frame)
(
classify_light_lanes,
build_light_data,
super::moonlight::update_moonlight,
pack_monkey_frame,
)
.chain()
.after(bevy::transform::TransformSystems::Propagate)
.after(super::update_time_lighting),
@@ -863,7 +868,9 @@ impl ShadowHandover {
self.ramp = (self.ramp + dt.max(0.0) / 1.5).min(1.0);
self.weight = match wanted {
Some(ShadowBody::Sun) => pack_shadow_lane(sun * self.ramp, 0.0),
Some(ShadowBody::Moon) => pack_shadow_lane(0.0, moon * strength.clamp(0.0, 1.0) * self.ramp),
Some(ShadowBody::Moon) => {
pack_shadow_lane(0.0, moon * strength.clamp(0.0, 1.0) * self.ramp)
}
None => 0.0,
};
}
@@ -1520,103 +1527,105 @@ fn build_light_data(
&& gt.translation().distance_squared(cam_pos)
< INTERIOR_NEAR_ADMIT * INTERIOR_NEAR_ADMIT)
})
.filter_map(|(pl, gt, rooms, synthetic, reach, lane_of, lit_rooms, flicker, daylight, spell)| {
let p = gt.translation();
let d2 = p.distance_squared(cam_pos);
(d2 < POINT_PACK_RADIUS * POINT_PACK_RADIUS).then(|| {
// MONKEY (merge): `WorldPointLight::color` is already linear RGB.
let c = pl.color;
// The authored intensity, BEFORE the fire gain: the pool's geometry must not move
// when the user dims the invented lights, only its brightness.
let base = pl.intensity / (4.0 * std::f32::consts::PI);
// MONKEY (fire GO lights): the live `fireLightGain` folds in HERE, over the
// recovered colour×intensity, and only for a synthesised source. At spawn it
// would need a world respawn to retune; here the dial moves the frame it changes.
// MONKEY (spellLightGain): …and the spell lane's own gain INSTEAD of it on a
// spell row. Every spell light is also tagged synthetic (it is an invented source
// and the census counts it as one), so the arms must be ordered, not summed: a
// fireball scaled by both dials would darken when the player turned the hearths
// down, which is precisely the coupling the second cvar exists to cut.
let s = base
* if spell {
spell_gain.0.max(0.0)
} else if synthetic {
fire_gain.0.max(0.0)
.filter_map(
|(pl, gt, rooms, synthetic, reach, lane_of, lit_rooms, flicker, daylight, spell)| {
let p = gt.translation();
let d2 = p.distance_squared(cam_pos);
(d2 < POINT_PACK_RADIUS * POINT_PACK_RADIUS).then(|| {
// MONKEY (merge): `WorldPointLight::color` is already linear RGB.
let c = pl.color;
// The authored intensity, BEFORE the fire gain: the pool's geometry must not move
// when the user dims the invented lights, only its brightness.
let base = pl.intensity / (4.0 * std::f32::consts::PI);
// MONKEY (fire GO lights): the live `fireLightGain` folds in HERE, over the
// recovered colour×intensity, and only for a synthesised source. At spawn it
// would need a world respawn to retune; here the dial moves the frame it changes.
// MONKEY (spellLightGain): …and the spell lane's own gain INSTEAD of it on a
// spell row. Every spell light is also tagged synthetic (it is an invented source
// and the census counts it as one), so the arms must be ordered, not summed: a
// fireball scaled by both dials would darken when the player turned the hearths
// down, which is precisely the coupling the second cvar exists to cut.
let s = base
* if spell {
spell_gain.0.max(0.0)
} else if synthetic {
fire_gain.0.max(0.0)
} else {
1.0
};
// MONKEY (flame flicker): the fire wobble, folded in at the very last moment — over
// the committed colour and NOWHERE else. Deliberately downstream of `base`, which
// still feeds the reach/lane below unmodulated: a breathing REACH would move the
// interior window, re-decide the room gate's portal hop and re-rank the torch
// shadow casters every frame, i.e. exactly the frame-rate thrash this feature
// exists to avoid. Only the brightness moves; the pool's geometry is frozen.
//
// `elapsed_secs` (absolute, never a delta) is what makes it frame-rate independent:
// the same instant gives the same brightness whatever the frame took.
let fm = flicker.map_or(FlickerMod::STEADY, |f| {
f.at(now_secs, dynamic_interiors.flicker)
});
let s = s * fm.intensity;
let rgb = commit_raw([c[0] * s, c[1] * s, c[2] * s * fm.blue]);
// MONKEY (light lane by position): a light is INTERIOR iff it PHYSICALLY STANDS in
// an interior-class WMO group — the verdict [`classify_light_lanes`] (static) or
// the carried-light claim (entities) wrote onto it. Claiming a room is no longer
// the test: Stormwind's street torches claim exterior-class groups and must light
// the cobbles, while the Goldshire inn's fixtures claim an exterior-flagged group
// from inside the building and must NOT light the lawn (see [`LightLane`]).
//
// No lane yet ⇒ fall back to the OLD `LightRooms` rule rather than to "exterior".
// A light lives one or two frames before the classifier's first pass (it spawns in
// the stream stage, the classifier runs the frame after), and the conservative
// arm of that gap is the pre-change behaviour: a MOLT fixture starts interior and
// is corrected outward, never the reverse — so the gap can never flash a pool onto
// an inn's lawn.
let interior = lane_of.map_or_else(|| rooms.is_some(), |l| l.interior);
// MONKEY (darkness gains): `interiorGain` dims the room lane's third input — every
// INTERIOR fixture's committed colour — downstream of the fire gain and the
// flicker, so those two keep their own meanings ("how bright is this invented
// source" / "how hard does it breathe") and this one reads purely as "how dark is
// the room". The EXTERIOR half of the table is untouched on purpose: an outdoor
// campfire belongs to the night law, which dims the sky around it and not it.
//
// And so is the DAYLIGHT FIXTURE ([`super::DaylightFixture`]) — an interior-lane
// entry that IS the sun standing in a doorway. A sunlit opening must not dim with
// the room's candles: this dial means "how dark is the CANDLELIGHT". Nor does
// `nightGain` claim it instead — that one dims the night, and a daylight fixture is
// already scaled to nothing by its own day envelope (`daylight_target`'s `sun_w`,
// the same curve) by the time the night dim is at full strength.
// MONKEY (lava light): lava borrows the caster exclusion, not the sun's gain exemption.
let daylight = daylight.is_some_and(|f| f.how != super::DaylightHow::Lava);
let rgb = if interior && !daylight {
rgb.map(|c| c * dynamic_interiors.interior_gain)
} else {
1.0
rgb
};
// MONKEY (flame flicker): the fire wobble, folded in at the very last moment — over
// the committed colour and NOWHERE else. Deliberately downstream of `base`, which
// still feeds the reach/lane below unmodulated: a breathing REACH would move the
// interior window, re-decide the room gate's portal hop and re-rank the torch
// shadow casters every frame, i.e. exactly the frame-rate thrash this feature
// exists to avoid. Only the brightness moves; the pool's geometry is frozen.
//
// `elapsed_secs` (absolute, never a delta) is what makes it frame-rate independent:
// the same instant gives the same brightness whatever the frame took.
let fm = flicker.map_or(FlickerMod::STEADY, |f| {
f.at(now_secs, dynamic_interiors.flicker)
});
let s = s * fm.intensity;
let rgb = commit_raw([c[0] * s, c[1] * s, c[2] * s * fm.blue]);
// MONKEY (light lane by position): a light is INTERIOR iff it PHYSICALLY STANDS in
// an interior-class WMO group — the verdict [`classify_light_lanes`] (static) or
// the carried-light claim (entities) wrote onto it. Claiming a room is no longer
// the test: Stormwind's street torches claim exterior-class groups and must light
// the cobbles, while the Goldshire inn's fixtures claim an exterior-flagged group
// from inside the building and must NOT light the lawn (see [`LightLane`]).
//
// No lane yet ⇒ fall back to the OLD `LightRooms` rule rather than to "exterior".
// A light lives one or two frames before the classifier's first pass (it spawns in
// the stream stage, the classifier runs the frame after), and the conservative
// arm of that gap is the pre-change behaviour: a MOLT fixture starts interior and
// is corrected outward, never the reverse — so the gap can never flash a pool onto
// an inn's lawn.
let interior = lane_of.map_or_else(|| rooms.is_some(), |l| l.interior);
// MONKEY (darkness gains): `interiorGain` dims the room lane's third input — every
// INTERIOR fixture's committed colour — downstream of the fire gain and the
// flicker, so those two keep their own meanings ("how bright is this invented
// source" / "how hard does it breathe") and this one reads purely as "how dark is
// the room". The EXTERIOR half of the table is untouched on purpose: an outdoor
// campfire belongs to the night law, which dims the sky around it and not it.
//
// And so is the DAYLIGHT FIXTURE ([`super::DaylightFixture`]) — an interior-lane
// entry that IS the sun standing in a doorway. A sunlit opening must not dim with
// the room's candles: this dial means "how dark is the CANDLELIGHT". Nor does
// `nightGain` claim it instead — that one dims the night, and a daylight fixture is
// already scaled to nothing by its own day envelope (`daylight_target`'s `sun_w`,
// the same curve) by the time the night dim is at full strength.
// MONKEY (lava light): lava borrows the caster exclusion, not the sun's gain exemption.
let daylight = daylight.is_some_and(|f| f.how != super::DaylightHow::Lava);
let rgb = if interior && !daylight {
rgb.map(|c| c * dynamic_interiors.interior_gain)
} else {
rgb
};
// A fixture's reach is its authored MOLT end where there is one and the M2 bucket
// otherwise, with a fail-open default for a MOLT record whose end is absent or
// degenerate (a few author 0 with `useAtten` clear).
let lane = if interior {
let r = reach
.map(|r| r.0)
.filter(|r| *r > 0.5)
.unwrap_or_else(|| m2_light_reach(base));
interior_reach(r, dynamic_interiors.atten_scale)
} else {
0.0
};
// MONKEY (room gate): the fixture's claim set, resolved HERE because this is the
// one place that already knows both the rooms and the interior verdict. An
// EXTERIOR-lane light is never read by the gate, so it packs the ungated head.
let claim = if interior && dynamic_interiors.room_gate {
RoomClaim::build(rooms, lit_rooms)
} else {
RoomClaim::UNGATED
};
let lit_n = lit_rooms.map_or(0, |l| l.rooms.groups.len());
(d2, p, pl.range, rgb, synthetic, lane, claim, lit_n)
})
})
// A fixture's reach is its authored MOLT end where there is one and the M2 bucket
// otherwise, with a fail-open default for a MOLT record whose end is absent or
// degenerate (a few author 0 with `useAtten` clear).
let lane = if interior {
let r = reach
.map(|r| r.0)
.filter(|r| *r > 0.5)
.unwrap_or_else(|| m2_light_reach(base));
interior_reach(r, dynamic_interiors.atten_scale)
} else {
0.0
};
// MONKEY (room gate): the fixture's claim set, resolved HERE because this is the
// one place that already knows both the rooms and the interior verdict. An
// EXTERIOR-lane light is never read by the gate, so it packs the ungated head.
let claim = if interior && dynamic_interiors.room_gate {
RoomClaim::build(rooms, lit_rooms)
} else {
RoomClaim::UNGATED
};
let lit_n = lit_rooms.map_or(0, |l| l.rooms.groups.len());
(d2, p, pl.range, rgb, synthetic, lane, claim, lit_n)
})
},
)
.collect();
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
// MONKEY (ext light k8): 255, not 256 — index 255 is the shaders' `EXT_SEL_EMPTY` sentinel now
@@ -1829,7 +1838,12 @@ pub fn classify_light_lanes(
streamer: Res<crate::terrain_stream::TerrainStreamer>,
adt_tiles: Res<Assets<benilla_assets::AdtTile>>,
lights: Query<
(Entity, &GlobalTransform, Option<&LightRooms>, Option<&LightLane>),
(
Entity,
&GlobalTransform,
Option<&LightRooms>,
Option<&LightLane>,
),
(
With<WorldPointLight>,
Without<ShadowProxyLight>,
@@ -1860,11 +1874,8 @@ pub fn classify_light_lanes(
// the anchor is not a DOWN-ray hit (the same buried-terrain rule as down_ray_claim).
let probe = gt.translation() + Vec3::Y * crate::wmo_portal::POSITION_PROBE_LIFT;
let terrain_hit = matches!(verdict, crate::wmo_portal::IndoorVerdict::Outdoors)
&& crate::terrain_stream::terrain_height_under(
&streamer,
&adt_tiles,
probe,
).is_some_and(|height| height <= probe.y);
&& crate::terrain_stream::terrain_height_under(&streamer, &adt_tiles, probe)
.is_some_and(|height| height <= probe.y);
let interior = light_verdict_interior(&verdict, terrain_hit, rooms.is_some());
let want = LightLane {
interior,
@@ -1916,13 +1927,33 @@ mod tests {
fn light_lanes_distinguish_resolved_outdoors_from_an_empty_ray() {
use crate::wmo_portal::IndoorVerdict;
for has_rooms in [false, true] {
assert!(!light_verdict_interior(&IndoorVerdict::Outdoors, true, has_rooms));
assert!(!light_verdict_interior(&IndoorVerdict::OutdoorsOnWmo, false, has_rooms));
assert!(light_verdict_interior(&IndoorVerdict::DayNight, true, has_rooms));
assert!(light_verdict_interior(
&IndoorVerdict::Baked { mocv: [0; 3], lobes: Vec::new() }, false, has_rooms,
assert!(!light_verdict_interior(
&IndoorVerdict::Outdoors,
true,
has_rooms
));
assert_eq!(light_verdict_interior(&IndoorVerdict::Outdoors, false, has_rooms), has_rooms);
assert!(!light_verdict_interior(
&IndoorVerdict::OutdoorsOnWmo,
false,
has_rooms
));
assert!(light_verdict_interior(
&IndoorVerdict::DayNight,
true,
has_rooms
));
assert!(light_verdict_interior(
&IndoorVerdict::Baked {
mocv: [0; 3],
lobes: Vec::new()
},
false,
has_rooms,
));
assert_eq!(
light_verdict_interior(&IndoorVerdict::Outdoors, false, has_rooms),
has_rooms
);
}
}
@@ -1941,20 +1972,31 @@ mod tests {
assert_eq!(moon_combine(0.2, 1.0, 0.35, 0.0), 1.0);
assert_eq!(moon_combine(0.2, 0.0, 0.35, 0.0), 0.2 * (1.0 - 0.35));
for (sky, point) in [(0.2f32, 0.0f32), (0.2, 1.0), (1.2, 0.1)] {
assert_eq!(moon_combine(sky, point, 0.0, 0.0).to_bits(),
(sky + point).clamp(0.0, 1.0).to_bits());
assert_eq!(moon_combine(sky, point, 0.35, 1.0).to_bits(),
(sky + point).clamp(0.0, 1.0).to_bits());
assert_eq!(
moon_combine(sky, point, 0.0, 0.0).to_bits(),
(sky + point).clamp(0.0, 1.0).to_bits()
);
assert_eq!(
moon_combine(sky, point, 0.35, 1.0).to_bits(),
(sky + point).clamp(0.0, 1.0).to_bits()
);
}
}
// MONKEY (moon shadows): exercise the REAL state machine, including delayed acknowledgement
// (new rig's deferred spawn), the propagation hold, and frame-rate-independent monotone ramp.
fn assert_shadow_transition(state: &mut ShadowHandover, body: ShadowBody, strength: f32) {
let (sun, moon) = if body == ShadowBody::Sun { (1.0, 0.0) } else { (0.0, 1.0) };
let (sun, moon) = if body == ShadowBody::Sun {
(1.0, 0.0)
} else {
(0.0, 1.0)
};
for _ in 0..3 {
state.request(sun, moon, strength, true, 1.0 / 60.0);
assert_eq!(state.weight, 0.0, "no acknowledgement: wrong/absent basis must not cast");
assert_eq!(
state.weight, 0.0,
"no acknowledgement: wrong/absent basis must not cast"
);
}
state.aim_written(body);
assert_eq!(state.weight, 0.0, "transform-write frame");
@@ -1968,7 +2010,14 @@ mod tests {
assert!((state.weight.abs() - previous) <= 1.0 / 90.0 + 1e-6);
previous = state.weight.abs();
}
assert_eq!(state.weight, if body == ShadowBody::Sun { 1.0 } else { -strength });
assert_eq!(
state.weight,
if body == ShadowBody::Sun {
1.0
} else {
-strength
}
);
}
#[test]
@@ -2160,7 +2209,11 @@ mod tests {
// Entity index 0 is the "terrain cell / no building" sentinel in the shader's identity
// lane, so the packer fails OPEN on it — burn it, then take a real index.
let zeroth = world.spawn_empty().id();
assert_eq!(zeroth.index().index(), 0, "the first index really is the sentinel");
assert_eq!(
zeroth.index().index(),
0,
"the first index really is the sentinel"
);
let inst = world.spawn_empty().id();
let rooms = LightRooms(crate::wmo_portal::WmoGroupVis {
instance: inst,
@@ -2170,7 +2223,7 @@ mod tests {
// more its box holds (g7) — its own order, which the packer must not reshuffle.
let lit = LightLitRooms {
rooms: crate::wmo_portal::WmoGroupVis {
instance: inst,
instance: inst,
groups: std::sync::Arc::from([5u16, 4, 7]),
},
// MONKEY (soft portal claims): no fades — every claim is HARD, which is the packer's
@@ -2179,14 +2232,26 @@ mod tests {
};
let claim = RoomClaim::build(Some(&rooms), Some(&lit));
assert_eq!(claim.instance, inst.index().index(), "keyed to the placement");
assert_eq!(
claim.instance,
inst.index().index(),
"keyed to the placement"
);
assert_eq!(claim.n, 3, "the spawner's list, verbatim: {claim}");
assert_eq!(&claim.groups[..3], &[5, 4, 7], "in the spawner's priority order");
assert_eq!(
&claim.groups[..3],
&[5, 4, 7],
"in the spawner's priority order"
);
let mut gpu = [0u32; ROOM_CLAIM_STRIDE];
claim.write(&mut gpu);
assert_eq!(gpu[1], 3, "the count the shader loops to");
assert_eq!(ROOM_CLAIM_FADE, 2 + ROOM_CLAIM_MAX, "the fades start after the id block");
assert_eq!(
ROOM_CLAIM_FADE,
2 + ROOM_CLAIM_MAX,
"the fades start after the id block"
);
// `group + 1`, so the shader can keep 0 for "empty" / "this fragment names no room" —
// group 0 is a real, common group id and could not be its own sentinel. MONKEY (portal
// claims): plus the positive exterior-lane bit, since none of these carries the deny flag.
@@ -2199,10 +2264,17 @@ mod tests {
],
"claims stored + 1, exterior-lane eligible, at full entry weight"
);
assert_eq!(&gpu[5..ROOM_CLAIM_FADE], &[0, 0, 0], "padding stays empty, not a claim on g0");
assert_eq!(
&gpu[5..ROOM_CLAIM_FADE],
&[0, 0, 0],
"padding stays empty, not a claim on g0"
);
// MONKEY (soft portal claims): no fades supplied ⇒ every record is zero, and `radius == 0`
// is the HARD claim the shader reads as weight 1 everywhere.
assert!(gpu[ROOM_CLAIM_FADE..].iter().all(|w| *w == 0), "hard claims write no fade");
assert!(
gpu[ROOM_CLAIM_FADE..].iter().all(|w| *w == 0),
"hard claims write no fade"
);
// MONKEY (review fixes): containment claims must pack identically with NO MOLR. This
// used to discard the whole list and let one fixture light every room in the building.
@@ -2215,7 +2287,7 @@ mod tests {
// bit — the one thing that keeps a tavern candle off the street outside.
let shell = LightLitRooms {
rooms: crate::wmo_portal::WmoGroupVis {
instance: inst,
instance: inst,
groups: std::sync::Arc::from([5u16, 9 | LIT_ROOM_EXT_DENY]),
},
fades: std::sync::Arc::from([]),
@@ -2261,14 +2333,22 @@ mod tests {
let many: Vec<u16> = (0..ROOM_CLAIM_MAX as u16 + 1).collect();
let big = LightLitRooms {
rooms: crate::wmo_portal::WmoGroupVis {
instance: inst,
instance: inst,
groups: std::sync::Arc::from(&many[..]),
},
fades: std::sync::Arc::from([]),
};
let packed = RoomClaim::build(Some(&rooms), Some(&big));
assert_eq!(usize::from(packed.n), ROOM_CLAIM_MAX, "overflow truncates to the cap");
assert_eq!(&packed.groups[..], &many[..ROOM_CLAIM_MAX], "keeps the head, drops the tail");
assert_eq!(
usize::from(packed.n),
ROOM_CLAIM_MAX,
"overflow truncates to the cap"
);
assert_eq!(
&packed.groups[..],
&many[..ROOM_CLAIM_MAX],
"keeps the head, drops the tail"
);
}
/// GOLDEN — MONKEY (soft portal claims): the fade half of the GPU record, by byte offset.
@@ -2309,11 +2389,27 @@ mod tests {
assert_eq!(f32::from_bits(gpu[f]), -1234.5);
assert_eq!(f32::from_bits(gpu[f + 1]), 60.25);
assert_eq!(f32::from_bits(gpu[f + 2]), 7000.0);
assert_eq!(gpu[f + 3] & 0xffff, (6.25 * CLAIM_FADE_SCALE) as u32, "radius, low half");
assert_eq!(gpu[f + 3] >> 16, (1.5 * CLAIM_FADE_SCALE) as u32, "slack, high half");
assert_eq!(
gpu[f + 3] & 0xffff,
(6.25 * CLAIM_FADE_SCALE) as u32,
"radius, low half"
);
assert_eq!(
gpu[f + 3] >> 16,
(1.5 * CLAIM_FADE_SCALE) as u32,
"slack, high half"
);
// The entry weight rides the ID word's spare bits, not the fade record.
assert_eq!((gpu[3] >> CLAIM_ENTRY_SHIFT) & 0xff, 102, "0.4 x 255, on the soft claim");
assert_eq!((gpu[2] >> CLAIM_ENTRY_SHIFT) & 0xff, 255, "full, on the hard one");
assert_eq!(
(gpu[3] >> CLAIM_ENTRY_SHIFT) & 0xff,
102,
"0.4 x 255, on the soft claim"
);
assert_eq!(
(gpu[2] >> CLAIM_ENTRY_SHIFT) & 0xff,
255,
"full, on the hard one"
);
// Every slot past the count is literally empty — a stale fade would follow a live light.
assert!(gpu[ROOM_CLAIM_FADE + 8..].iter().all(|w| *w == 0));
assert_eq!(gpu.len(), ROOM_CLAIM_STRIDE, "2 head + 6 ids + 6 x 4 fade");
@@ -2367,7 +2463,10 @@ mod tests {
app.update();
let data = app.world().resource::<WowLightData>().0;
assert_eq!(data.points[3], [0.0, 0.0, 0.0, 0.0], "gain 0 = lane off");
assert!((data.points[1][0] - 2.0).abs() < 1e-4, "authored unaffected");
assert!(
(data.points[1][0] - 2.0).abs() < 1e-4,
"authored unaffected"
);
assert_eq!(
app.world().resource::<ResolvedPointLights>().as_slice()[1].color,
Vec3::ZERO,
@@ -2422,7 +2521,11 @@ mod tests {
app.world_mut().insert_resource(SpellLightGain(0.0));
app.update();
let data = app.world().resource::<WowLightData>().0;
assert_eq!(data.points[3], [0.0, 0.0, 0.0, 0.0], "spell gain 0 = spell lights off");
assert_eq!(
data.points[3],
[0.0, 0.0, 0.0, 0.0],
"spell gain 0 = spell lights off"
);
assert!(
(data.points[1][0] - 1.0).abs() < 1e-4,
"the campfire still burns at its own gain"
@@ -2453,7 +2556,10 @@ mod tests {
let mut app = packer_app();
// Same recipe, same lane, same intensity: only the component can separate them.
let recipe = || crate::terrain_stream::point_light([1.0, 0.5, 0.25], 1.5);
let lane = LightLane { interior: true, generation: LightLane::SETTLED };
let lane = LightLane {
interior: true,
generation: LightLane::SETTLED,
};
app.world_mut()
.spawn((recipe(), GlobalTransform::from_translation(Vec3::X), lane));
app.world_mut().spawn((
@@ -2465,20 +2571,36 @@ mod tests {
let (mut moved, mut steady_moved) = (0.0f32, 0.0f32);
let first = at(&mut app, 0);
let (base_steady, base_flame, reach) = (first.points[1][0], first.points[3][0], first.points[3][3]);
assert!(reach > 0.5, "the flame is on the interior lane, so `.w` carries its reach");
let (base_steady, base_flame, reach) =
(first.points[1][0], first.points[3][0], first.points[3][3]);
assert!(
reach > 0.5,
"the flame is on the interior lane, so `.w` carries its reach"
);
for ms in (40..4000).step_by(37) {
let d = at(&mut app, ms);
steady_moved = steady_moved.max((d.points[1][0] - base_steady).abs());
moved = moved.max((d.points[3][0] - base_flame).abs() / base_flame.max(1e-6));
assert_eq!(d.points[3][3], reach, "the packed reach never moves with the flicker");
assert_eq!(d.points[2], first.points[2], "nor does the position/range row");
assert_eq!(
d.points[3][3], reach,
"the packed reach never moves with the flicker"
);
assert_eq!(
d.points[2], first.points[2],
"nor does the position/range row"
);
}
assert_eq!(steady_moved, 0.0, "a light with no FlameFlicker is a constant, as before");
assert_eq!(
steady_moved, 0.0,
"a light with no FlameFlicker is a constant, as before"
);
assert!(moved > 0.02, "the flame barely moved at all: {moved}");
// Both endpoints of the excursion are inside the torch rung's authored +-10%, doubled by
// the two-sided base sample (the reference frame is t=0, itself off the mean).
assert!(moved < 2.0 * FlameKind::Torch.amplitude(), "over amplitude: {moved}");
assert!(
moved < 2.0 * FlameKind::Torch.amplitude(),
"over amplitude: {moved}"
);
// `fireFlicker 0` is the off switch: the flame commits the same bytes on every frame.
app.world_mut().insert_resource(DynamicInteriors {
@@ -2487,7 +2609,11 @@ mod tests {
});
let off = at(&mut app, 5000).points[3];
for ms in [5100u64, 5250, 5600] {
assert_eq!(at(&mut app, ms).points[3], off, "gain 0 = the pre-feature constant");
assert_eq!(
at(&mut app, ms).points[3],
off,
"gain 0 = the pre-feature constant"
);
}
}
@@ -2732,12 +2858,19 @@ mod tests {
/// `LightStd430` is mirrored by three shaders plus the portrait booth's frozen studio blob.
#[test]
fn the_live_point_cap_leaves_the_sentinel_index_free() {
assert_eq!(MAX_LIVE_POINT_LIGHTS, 255, "255 is EXT_SEL_EMPTY in the three shaders");
assert_eq!(
MAX_LIVE_POINT_LIGHTS, 255,
"255 is EXT_SEL_EMPTY in the three shaders"
);
assert_eq!(MAX_LIVE_POINT_LIGHTS, MAX_POINT_LIGHTS - 1);
// 21 header rows + 2 x 256 point rows, 16 B each.
// MONKEY (p0 MonkeyFrame): the fixed rows plus the 256-byte programme block.
// GFX (moonlight): + the moonlight row (17 programme rows, 272 B).
assert_eq!(per_frame_blob_bytes(), 8800, "the mirrored blob must not change size");
assert_eq!(
per_frame_blob_bytes(),
8800,
"the mirrored blob must not change size"
);
}
/// MONKEY (reviewfix): the shader mirrors use literal storage-array lengths and water reads
@@ -2792,7 +2925,10 @@ mod tests {
for debug in 0..=4u32 {
for daylight in [0.0f32, 0.01, 0.12, 0.5, 0.999, 1.0] {
let w = 1.0 + debug as f32 + daylight * DAYLIGHT_LANE_SCALE;
assert!(interiors_on(w), "lane off at debug {debug} daylight {daylight}");
assert!(
interiors_on(w),
"lane off at debug {debug} daylight {daylight}"
);
assert_eq!(idbg(w), debug, "debug decode moved (w {w})");
assert!(
(daylight_of(w) - daylight).abs() < 1e-4,
@@ -2813,7 +2949,11 @@ mod tests {
}
};
assert_eq!(pack(false, 3, 0.5), 0.0);
assert_eq!(idbg(pack(true, 3, 5.0)), 3, "an out-of-range cvar must still clamp under 0.5");
assert_eq!(
idbg(pack(true, 3, 5.0)),
3,
"an out-of-range cvar must still clamp under 0.5"
);
assert!((daylight_of(pack(true, 3, 5.0)) - 1.0).abs() < 1e-4);
assert!((daylight_of(pack(true, 0, -1.0))).abs() < 1e-4);
}
@@ -2890,7 +3030,8 @@ mod tests {
});
// MONKEY (moon shadows): this gain test models an already-settled sun rig; the
// packer now consumes its publication instead of manufacturing a clock-only weight.
app.world_mut().resource_mut::<ShadowHandover>().weight = sun_shadow_strength(celestial_y);
app.world_mut().resource_mut::<ShadowHandover>().weight =
sun_shadow_strength(celestial_y);
// An EXTERIOR fire, to prove the dial stops at the sky law.
app.world_mut().spawn((
crate::terrain_stream::point_light([1.0, 0.5, 0.25], 2.0),
@@ -2902,25 +3043,59 @@ mod tests {
let day = pack(1.0);
assert_eq!(day.rows[5][2], 1.0, "the sun is up: night_w is 0");
assert_eq!([day.rows[0][0], day.rows[0][1], day.rows[0][2]], base.ambient);
assert_eq!([day.rows[1][0], day.rows[1][1], day.rows[1][2]], base.diffuse);
assert_eq!(
[day.rows[0][0], day.rows[0][1], day.rows[0][2]],
base.ambient
);
assert_eq!(
[day.rows[1][0], day.rows[1][1], day.rows[1][2]],
base.diffuse
);
assert_eq!([day.rows[3][0], day.rows[3][1], day.rows[3][2]], base.spec);
// The SH block is derived from the same triple — its DC lanes carry ambient verbatim.
assert_eq!(day.rows[6][3], base.ambient[0], "the SH DC is undimmed by day too");
assert_eq!(
day.rows[6][3], base.ambient[0],
"the SH DC is undimmed by day too"
);
let night = pack(0.0);
assert_eq!(night.rows[5][2], 0.0, "below the horizon: night_w is 1");
let g = DynamicInteriors::default().night_gain;
assert_eq!(g, 0.45, "the shipped default is the director's pick (2026-09-11: 0.45)");
assert_eq!(
g, 0.45,
"the shipped default is the director's pick (2026-09-11: 0.45)"
);
for c in 0..3 {
assert_eq!(night.rows[0][c], base.ambient[c] * g, "ambient takes the gain");
assert_eq!(night.rows[1][c], base.diffuse[c] * g, "diffuse takes the gain");
assert_eq!(night.rows[3][c], base.spec[c] * g, "the sun halo follows its sun");
assert_eq!(
night.rows[0][c],
base.ambient[c] * g,
"ambient takes the gain"
);
assert_eq!(
night.rows[1][c],
base.diffuse[c] * g,
"diffuse takes the gain"
);
assert_eq!(
night.rows[3][c],
base.spec[c] * g,
"the sun halo follows its sun"
);
}
assert_eq!(night.rows[6][3], base.ambient[0] * g, "the SH DC follows the triple");
assert_eq!(
night.rows[6][3],
base.ambient[0] * g,
"the SH DC follows the triple"
);
// The fire is the same brightness on both frames — which is the point of the feature.
assert_eq!(day.points[1], night.points[1], "a point light never takes the night dim");
assert!((night.points[1][0] - 2.0).abs() < 1e-4, "…at its authored value");
assert_eq!(
day.points[1], night.points[1],
"a point light never takes the night dim"
);
assert!(
(night.points[1][0] - 2.0).abs() < 1e-4,
"…at its authored value"
);
}
/// GOLDEN — MONKEY (moon shadows): THE HAND-OVER LAW, swept over the whole game day against
@@ -2990,8 +3165,14 @@ mod tests {
prev_sun = sun;
prev_moon = moon;
}
assert!(sun_minutes > 600, "the sun should cast most of the day: {sun_minutes} min");
assert!(moon_minutes > 200, "the moon should cast most of the night: {moon_minutes} min");
assert!(
sun_minutes > 600,
"the sun should cast most of the day: {sun_minutes} min"
);
assert!(
moon_minutes > 200,
"the moon should cast most of the night: {moon_minutes} min"
);
assert!(
dark_minutes > 60,
"the sun sets ~1h45m before the moon rises — the oval blob owns that window, and a \
@@ -3065,7 +3246,10 @@ mod tests {
// Midnight: the sun is 10 degrees under, the white moon is overhead (+55 degrees).
let sun_h = super::super::daynight::celestial_sun_direction(0.0).y;
let moon_h = super::super::daynight::moon_direction(0.0).y;
assert!(sun_h < 0.0 && moon_h > 0.3, "midnight: sun down, moon high ({sun_h}, {moon_h})");
assert!(
sun_h < 0.0 && moon_h > 0.3,
"midnight: sun down, moon high ({sun_h}, {moon_h})"
);
let pack = |strength: f32| {
let mut app = packer_app();
app.world_mut().insert_resource(WowLighting {
@@ -3073,10 +3257,15 @@ mod tests {
moon_dir_white: Vec3::new(0.0, moon_h, 0.0),
..default()
});
app.world_mut().insert_resource(MoonShadowStrength(strength));
app.world_mut()
.insert_resource(MoonShadowStrength(strength));
let mut handover = app.world_mut().resource_mut::<ShadowHandover>();
handover.request(0.0, 1.0, strength, true, 0.0);
handover.aim_written(if strength > 0.0 { ShadowBody::Moon } else { ShadowBody::Sun });
handover.aim_written(if strength > 0.0 {
ShadowBody::Moon
} else {
ShadowBody::Sun
});
handover.request(0.0, 1.0, strength, true, 0.0);
handover.request(0.0, 1.0, strength, true, 1.5);
app.update();
@@ -3128,9 +3317,15 @@ mod tests {
let mut app = packer_app();
// One recipe, one lane component apart: nothing else can separate the two entries.
let recipe = || crate::terrain_stream::point_light([1.0, 0.5, 0.25], 2.0);
let lane = |interior| LightLane { interior, generation: LightLane::SETTLED };
app.world_mut()
.spawn((recipe(), GlobalTransform::from_translation(Vec3::X), lane(true)));
let lane = |interior| LightLane {
interior,
generation: LightLane::SETTLED,
};
app.world_mut().spawn((
recipe(),
GlobalTransform::from_translation(Vec3::X),
lane(true),
));
app.world_mut().spawn((
recipe(),
GlobalTransform::from_translation(Vec3::new(2.0, 0.0, 0.0)),
@@ -3165,12 +3360,23 @@ mod tests {
let data = app.world().resource::<WowLightData>().0;
assert_eq!(data.rows[20][0], 3.0, "all three packed");
assert_eq!(data.rows[20][1], 0.2 * g, "the base ambient floor takes the gain");
assert_eq!(data.rows[20][2], 0.5 * g, "the per-fixture fill takes the gain");
assert_eq!(
data.rows[20][1],
0.2 * g,
"the base ambient floor takes the gain"
);
assert_eq!(
data.rows[20][2],
0.5 * g,
"the per-fixture fill takes the gain"
);
assert_eq!(data.rows[20][3], 3.0, "exposure stays the user's own dial");
// Nearest-first: the interior fixture at x=1 is entry 0, the exterior one at x=2 entry 1.
let (int, ext) = (data.points[1], data.points[3]);
assert!(int[3] > 0.5 && ext[3] == 0.0, "the lanes packed as expected: {int:?} {ext:?}");
assert!(
int[3] > 0.5 && ext[3] == 0.0,
"the lanes packed as expected: {int:?} {ext:?}"
);
assert!(
(int[0] - 2.0 * g).abs() < 1e-4,
"the interior fixture's colour takes the gain: {int:?}"
@@ -3180,7 +3386,10 @@ mod tests {
"the exterior light is untouched by it: {ext:?}"
);
let sun = data.points[5];
assert!(sun[3] > 0.5, "the daylight fixture packed on the interior lane: {sun:?}");
assert!(
sun[3] > 0.5,
"the daylight fixture packed on the interior lane: {sun:?}"
);
assert!(
(sun[0] - 2.0).abs() < 1e-4,
"…and a sunlit doorway does not dim with the candles: {sun:?}"
@@ -319,7 +319,11 @@ fn lane_census(
let Some(mode) = DUMP.get_or_init(|| std::env::var_os("WOW_POINTS_DUMP")) else {
return;
};
let every = if mode.as_os_str() == "frame" { 0.0 } else { 1.0 };
let every = if mode.as_os_str() == "frame" {
0.0
} else {
1.0
};
let now = time.elapsed_secs_f64();
if now - *last < every {
return;
@@ -442,7 +446,10 @@ mod tests {
assert!(!points.is_empty());
for point in points {
let p = benilla_assets::coords::bevy_to_wow(point);
assert_eq!(info.surface_z_at(p[0], p[1]).map(|z| z + LIFT_YD), Some(p[2]));
assert_eq!(
info.surface_z_at(p[0], p[1]).map(|z| z + LIFT_YD),
Some(p[2])
);
let local = rot.inverse() * Vec3::from_array(p);
assert!(!(local.x > 12.0 && local.x < 24.0 && local.y > 12.0 && local.y < 24.0));
}
@@ -603,11 +610,10 @@ mod tests {
assert_eq!(marker.surface, magma);
assert!(app.world().get::<DaylightFixture>(e).is_some());
assert!(app.world().get::<super::super::FlameFlicker>(e).is_none());
assert!(
app.world()
.get::<super::super::SyntheticFireLight>(e)
.is_none()
);
assert!(app
.world()
.get::<super::super::SyntheticFireLight>(e)
.is_none());
assert!(app.world().get::<ChildOf>(e).is_none());
}
app.world_mut().despawn(magma);
+21 -9
View File
@@ -17,8 +17,8 @@ pub mod fog_model; // MONKEY (fog): the Modern fog model's CPU half (MonkeyFrame
pub use fog_model::FogModelSetting;
mod lava_light; // MONKEY (lava light): magma surface fixtures and their independent gain
mod moonlight; // GFX (moonlight): the moon as an additive night light (MonkeyFrame row 16)
pub use moonlight::{moon_light_intensity, MoonLight, MOON_LIGHT_BASE};
pub use lava_light::{LavaLight, LavaLightGain};
pub use moonlight::{moon_light_intensity, MoonLight, MOON_LIGHT_BASE};
mod prop_probes; // the per-instance interior-prop SH probe table (slot ↔ MeshTag payload)
mod resolve; // the per-frame time-of-day sample into WowLighting + the WMO interior-fog crossfade
mod sh; // the model SH light-probe coefficient math
@@ -31,22 +31,34 @@ pub use blob::LightBlob;
// two types; the fixture itself still wears `DaylightFixture`.
pub use daylight::set_window_split; // MONKEY (fix-daylight)
pub use daylight::{
daylight_claims, daylight_intensity, daylight_lane, daylight_point_light, daylight_reach,
daylight_rooms, daylight_seeds, daylight_target, bleed_seeds, placement_openings, BleedFixture,
bleed_seeds,
daylight_claims,
daylight_intensity,
daylight_lane,
daylight_point_light,
daylight_reach,
daylight_rooms,
daylight_seeds,
daylight_target,
// MONKEY (daylight: district sky rooms)
district_sky_rooms,
placement_openings,
BleedFixture,
BleedSeed,
DaylightFixture, DaylightHow, DaylightSeed, BLEED_K, MAX_DAYLIGHT_PER_PLACEMENT,
DaylightFixture,
DaylightHow,
DaylightSeed,
BLEED_K,
MAX_DAYLIGHT_PER_PLACEMENT,
};
// MONKEY (flame flicker): the component + the one route rule, so every spawn lane files a flame
// the same way and the packer has a single function to evaluate.
pub use flicker::{flame_kind_for, flicker_seed, FlameFlicker, FlameKind, FlickerMod};
pub use global_light::{
interior_reach, m2_light_reach, new_shared_light_buffer, DynamicInteriors, FireLightGain,
ClaimFade, LightLane, LightLitRooms, LightReach, LightRooms, RoomClaimTable,
ResolvedPointLight, ResolvedPointLights, SharedLightBuffer,
ShadowDistance, ShadowFilterGaussian, ShadowProxyLight, SyntheticFireLight,
WorldShadowActive,
interior_reach, m2_light_reach, new_shared_light_buffer, ClaimFade, DynamicInteriors,
FireLightGain, LightLane, LightLitRooms, LightReach, LightRooms, ResolvedPointLight,
ResolvedPointLights, RoomClaimTable, ShadowDistance, ShadowFilterGaussian, ShadowProxyLight,
SharedLightBuffer, SyntheticFireLight, WorldShadowActive,
};
// MONKEY (moon shadows): the night directional-shadow strength dial (`moonShadowStrength`), the
// one resource the settings registry writes for this feature (`0` = the pre-feature night render),
@@ -127,9 +127,24 @@ impl MonkeyFrame {
rows[1] = [s[0], s[1], s[2], self.sun_fog_strength];
let e = self.end_fog_rgb;
rows[2] = [e[0], e[1], e[2], self.end_fog_distance];
rows[3] = [model, self.sun_fog_angle, self.sun_fog_dir[0], self.sun_fog_dir[1]];
rows[4] = [self.wind_dir[0], self.wind_dir[1], self.wind_base_heading, self.wind_gust];
rows[5] = [self.wind_travel, self.sway_strength, self.grass_strength, self.tree_strength];
rows[3] = [
model,
self.sun_fog_angle,
self.sun_fog_dir[0],
self.sun_fog_dir[1],
];
rows[4] = [
self.wind_dir[0],
self.wind_dir[1],
self.wind_base_heading,
self.wind_gust,
];
rows[5] = [
self.wind_travel,
self.sway_strength,
self.grass_strength,
self.tree_strength,
];
rows[6] = [self.rain_rate, self.wetness, self.ripple_time_s, self.snow];
let n = (self.bender_count as usize).min(MAX_BENDERS);
rows[7] = [n as f32, time_of_day, night, self.moon_shadow_confidence];
@@ -205,7 +220,11 @@ mod tests {
let body = src.split_once("struct MonkeyFrame {").unwrap().1;
let body = body.split_once("\n}").unwrap().0;
let mut rows = 0;
for line in body.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with("//")) {
for line in body
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with("//"))
{
if line.contains("array<vec4<f32>, 8>") {
rows += 8;
} else if line.contains("vec4<f32>") {
+20 -4
View File
@@ -73,7 +73,11 @@ pub(super) fn update_moonlight(
} else {
([0.0; 3], 0.0)
};
let confidence = if intensity > 0.0 { handover.moon_ramp() } else { 0.0 };
let confidence = if intensity > 0.0 {
handover.moon_ramp()
} else {
0.0
};
// Write through `ResMut` only on a change, so the packer's own change check stays quiet.
if frame.moon_light_dir != dir
|| frame.moon_light != intensity
@@ -92,9 +96,21 @@ mod tests {
#[test]
fn off_day_and_a_set_moon_are_exactly_zero() {
// Midnight: sun far below, moon high.
assert_eq!(moon_light_intensity(0.0, -0.9, 0.8, 0.0), 0.0, "moonLight 0 is the null");
assert_eq!(moon_light_intensity(1.0, 0.7, 0.8, 0.0), 0.0, "no moonlight by day");
assert_eq!(moon_light_intensity(1.0, -0.9, -0.1, 0.0), 0.0, "a set moon lights nothing");
assert_eq!(
moon_light_intensity(0.0, -0.9, 0.8, 0.0),
0.0,
"moonLight 0 is the null"
);
assert_eq!(
moon_light_intensity(1.0, 0.7, 0.8, 0.0),
0.0,
"no moonlight by day"
);
assert_eq!(
moon_light_intensity(1.0, -0.9, -0.1, 0.0),
0.0,
"a set moon lights nothing"
);
let full = moon_light_intensity(1.0, -0.9, 0.8, 0.0);
assert!((full - MOON_LIGHT_BASE).abs() < 1e-6);
}
+238 -94
View File
@@ -4,12 +4,12 @@
//!
//! MONKEY (enhanced water: refraction): the same node also copies the opaque COLOUR, the frame
//! as it stood before any water drew, into `WaterColourImage` for the module to look through.
use bevy::prelude::*;
use benilla_assets::{materials::LiquidMaterial, WaterColourImage, WaterDepthImage, WaterQuality};
use bevy::camera::visibility::VisibleEntities;
use bevy::core_pipeline::core_3d::graph::{Core3d, Node3d};
use bevy::ecs::query::QueryItem;
use bevy::image::ToExtents;
use bevy::render::{RenderApp, RenderStartup};
use bevy::prelude::*;
use bevy::render::extract_component::{ExtractComponent, ExtractComponentPlugin};
use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin};
use bevy::render::render_asset::RenderAssets;
@@ -18,7 +18,7 @@ use bevy::render::render_resource::*;
use bevy::render::renderer::{RenderContext, RenderDevice};
use bevy::render::texture::GpuImage;
use bevy::render::view::{ViewDepthTexture, ViewTarget};
use benilla_assets::{WaterColourImage, WaterDepthImage, WaterQuality, materials::LiquidMaterial};
use bevy::render::{RenderApp, RenderStartup};
#[derive(Component, Clone, ExtractComponent)]
struct WaterView;
@@ -38,34 +38,62 @@ struct WaterOverride(Option<u8>);
impl Plugin for WaterDepthPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<WaterQuality>().init_resource::<WaterDepthImage>()
app.init_resource::<WaterQuality>()
.init_resource::<WaterDepthImage>()
.init_resource::<WaterColourImage>();
let value = std::env::var("WOW_WATER").ok()
.and_then(|v| v.parse::<u8>().ok()).filter(|v| *v <= 2);
if let Some(value) = value { app.insert_resource(WaterQuality(value)); }
let value = std::env::var("WOW_WATER")
.ok()
.and_then(|v| v.parse::<u8>().ok())
.filter(|v| *v <= 2);
if let Some(value) = value {
app.insert_resource(WaterQuality(value));
}
let image = app.world().resource::<WaterDepthImage>().0.clone();
let colour = app.world().resource::<WaterColourImage>().0.clone();
app.insert_resource(WaterOverride(value))
.insert_resource(DepthSource { image, colour, active: false })
.add_plugins((ExtractComponentPlugin::<WaterView>::default(),
ExtractResourcePlugin::<DepthSource>::default()))
.insert_resource(DepthSource {
image,
colour,
active: false,
})
.add_plugins((
ExtractComponentPlugin::<WaterView>::default(),
ExtractResourcePlugin::<DepthSource>::default(),
))
// Camera sizes and the per-view visible list are final by Last, before extraction.
.add_systems(Last, update_water_depth);
let shader = app.world_mut().resource_mut::<Assets<Shader>>()
.add(Shader::from_wgsl(RESOLVE_SHADER, "water_depth_resolve.wgsl"));
let Some(render_app) = app.get_sub_app_mut(RenderApp) else { return };
render_app.insert_resource(DepthShader(shader)).add_systems(RenderStartup, init_pipeline)
let shader = app
.world_mut()
.resource_mut::<Assets<Shader>>()
.add(Shader::from_wgsl(
RESOLVE_SHADER,
"water_depth_resolve.wgsl",
));
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
return;
};
render_app
.insert_resource(DepthShader(shader))
.add_systems(RenderStartup, init_pipeline)
.add_render_graph_node::<ViewNodeRunner<DepthNode>>(Core3d, WaterDepthLabel)
// static_gx precedes MainOpaquePass; this sees BOTH solid-geometry paths.
.add_render_graph_edges(Core3d, (
Node3d::MainOpaquePass, WaterDepthLabel, Node3d::MainTransparentPass,
));
.add_render_graph_edges(
Core3d,
(
Node3d::MainOpaquePass,
WaterDepthLabel,
Node3d::MainTransparentPass,
),
);
}
}
fn update_water_depth(
mut commands: Commands,
mut cameras: Query<(Entity, &Camera, &mut Camera3d, &VisibleEntities), With<crate::view::WorldCamera>>,
mut cameras: Query<
(Entity, &Camera, &mut Camera3d, &VisibleEntities),
With<crate::view::WorldCamera>,
>,
liquids: Query<&MeshMaterial3d<LiquidMaterial>>,
mut materials: ResMut<Assets<LiquidMaterial>>,
mut images: ResMut<Assets<Image>>,
@@ -75,12 +103,18 @@ fn update_water_depth(
light: Res<crate::lighting::WowLighting>,
) {
if let Some(value) = override_value.0 {
if quality.0 != value { quality.0 = value; }
if quality.0 != value {
quality.0 = value;
}
}
// The dome's resolved, byte-quantized sky rows, converted to linear RGB.
let linear = |row: [f32; 3]| {
let c = benilla_assets::quant255(row).map(|v| {
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
});
Vec4::new(c[0], c[1], c[2], 0.0)
};
@@ -88,16 +122,24 @@ fn update_water_depth(
let horizon = linear(light.sky[4]);
let celestial = if light.celestial_dir.y > 0.0 {
light.celestial_dir.extend(0.0)
} else { light.moon_dir_white.extend(1.0) };
} else {
light.moon_dir_white.extend(1.0)
};
// Evaluated after lighting resolves each frame; mutate only on actual changes.
let tier = quality.0.min(2) as f32;
let changed: Vec<_> = materials.iter().filter(|(_, m)| {
let water = &m.extension.water;
water.lane.z < 0.5
&& (water.mode.x != tier
|| (quality.0 > 0 && (water.sky_zenith != zenith
|| water.sky_horizon != horizon || water.celestial != celestial)))
}).map(|(id, _)| id).collect();
let changed: Vec<_> = materials
.iter()
.filter(|(_, m)| {
let water = &m.extension.water;
water.lane.z < 0.5
&& (water.mode.x != tier
|| (quality.0 > 0
&& (water.sky_zenith != zenith
|| water.sky_horizon != horizon
|| water.celestial != celestial)))
})
.map(|(id, _)| id)
.collect();
for id in changed {
let water = &mut materials.get_mut(id).unwrap().extension.water;
water.mode.x = tier;
@@ -112,25 +154,38 @@ fn update_water_depth(
if !usage.contains(TextureUsages::TEXTURE_BINDING) {
camera3d.depth_texture_usages = (usage | TextureUsages::TEXTURE_BINDING).into();
}
if quality.0 == 0 || !camera.is_active { continue; }
source.active = visible.get(std::any::TypeId::of::<Mesh3d>()).iter().any(|entity| {
liquids.get(*entity).ok().and_then(|m| materials.get(&m.0))
.is_some_and(|m| m.extension.water.lane.z < 0.5)
});
if quality.0 == 0 || !camera.is_active {
continue;
}
source.active = visible
.get(std::any::TypeId::of::<Mesh3d>())
.iter()
.any(|entity| {
liquids
.get(*entity)
.ok()
.and_then(|m| materials.get(&m.0))
.is_some_and(|m| m.extension.water.lane.z < 0.5)
});
if let Some(size) = camera.physical_target_size() {
if size.x > 0 && size.y > 0 {
let stale = |handle: &Handle<Image>| images.get(handle)
.is_some_and(|image| image.size() != size);
let stale = |handle: &Handle<Image>| {
images.get(handle).is_some_and(|image| image.size() != size)
};
if stale(&source.image) || stale(&source.colour) {
for handle in [source.image.clone(), source.colour.clone()] {
if let Some(image) = images.get_mut(&handle) {
if image.size() != size { image.resize(size.to_extents()); }
if image.size() != size {
image.resize(size.to_extents());
}
}
}
// A resize replaces the GPU view behind this stable handle. Rebuild all
// material bindings, including opaque liquids sharing the fallback binding.
let ids: Vec<_> = materials.ids().collect();
for id in ids { let _ = materials.get_mut(id); }
for id in ids {
let _ = materials.get_mut(id);
}
}
}
}
@@ -150,41 +205,86 @@ struct DepthShader(Handle<Shader>);
fn init_pipeline(mut commands: Commands, shader: Res<DepthShader>, cache: Res<PipelineCache>) {
let shader = &shader.0;
let layouts = [false, true].map(|multisampled| BindGroupLayoutDescriptor::new(
"water depth source", &[BindGroupLayoutEntry {
binding: 0, visibility: ShaderStages::FRAGMENT,
ty: BindingType::Texture { sample_type: TextureSampleType::Depth,
view_dimension: TextureViewDimension::D2, multisampled }, count: None,
}],
));
let pipelines = [0, 1].map(|i| cache.queue_render_pipeline(RenderPipelineDescriptor {
label: Some("water depth resolve".into()), layout: vec![layouts[i].clone()],
vertex: VertexState { shader: shader.clone(), entry_point: Some("vertex".into()), ..default() },
fragment: Some(FragmentState {
shader: shader.clone(), entry_point: Some("fragment".into()),
shader_defs: if i == 1 { vec!["MULTISAMPLED".into()] } else { vec![] },
targets: vec![Some(ColorTargetState { format: TextureFormat::R32Float,
blend: None, write_mask: ColorWrites::ALL })],
}), ..default()
}));
let layouts = [false, true].map(|multisampled| {
BindGroupLayoutDescriptor::new(
"water depth source",
&[BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Texture {
sample_type: TextureSampleType::Depth,
view_dimension: TextureViewDimension::D2,
multisampled,
},
count: None,
}],
)
});
let pipelines = [0, 1].map(|i| {
cache.queue_render_pipeline(RenderPipelineDescriptor {
label: Some("water depth resolve".into()),
layout: vec![layouts[i].clone()],
vertex: VertexState {
shader: shader.clone(),
entry_point: Some("vertex".into()),
..default()
},
fragment: Some(FragmentState {
shader: shader.clone(),
entry_point: Some("fragment".into()),
shader_defs: if i == 1 {
vec!["MULTISAMPLED".into()]
} else {
vec![]
},
targets: vec![Some(ColorTargetState {
format: TextureFormat::R32Float,
blend: None,
write_mask: ColorWrites::ALL,
})],
}),
..default()
})
});
let colour_layout = BindGroupLayoutDescriptor::new(
"water colour source", &[BindGroupLayoutEntry {
binding: 0, visibility: ShaderStages::FRAGMENT,
ty: BindingType::Texture { sample_type: TextureSampleType::Float { filterable: false },
view_dimension: TextureViewDimension::D2, multisampled: false }, count: None,
"water colour source",
&[BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Texture {
sample_type: TextureSampleType::Float { filterable: false },
view_dimension: TextureViewDimension::D2,
multisampled: false,
},
count: None,
}],
);
let colour_pipeline = cache.queue_render_pipeline(RenderPipelineDescriptor {
label: Some("water colour copy".into()), layout: vec![colour_layout.clone()],
vertex: VertexState { shader: shader.clone(), entry_point: Some("vertex".into()), ..default() },
label: Some("water colour copy".into()),
layout: vec![colour_layout.clone()],
vertex: VertexState {
shader: shader.clone(),
entry_point: Some("vertex".into()),
..default()
},
fragment: Some(FragmentState {
shader: shader.clone(), entry_point: Some("colour".into()),
shader: shader.clone(),
entry_point: Some("colour".into()),
shader_defs: vec!["COLOUR".into()],
targets: vec![Some(ColorTargetState { format: TextureFormat::Rgba16Float,
blend: None, write_mask: ColorWrites::ALL })],
}), ..default()
targets: vec![Some(ColorTargetState {
format: TextureFormat::Rgba16Float,
blend: None,
write_mask: ColorWrites::ALL,
})],
}),
..default()
});
commands.insert_resource(DepthPipelines {
layouts,
pipelines,
colour_layout,
colour_pipeline,
});
commands.insert_resource(DepthPipelines { layouts, pipelines, colour_layout, colour_pipeline });
}
// MONKEY (ao): public so the AO pass can order itself before the opaque copies.
@@ -194,37 +294,68 @@ pub struct WaterDepthLabel;
struct DepthNode;
impl ViewNode for DepthNode {
type ViewQuery = (&'static WaterView, &'static ViewDepthTexture, &'static ViewTarget);
fn run<'w>(&self, _: &mut RenderGraphContext, context: &mut RenderContext<'w>,
(_, depth, target): QueryItem<'w, '_, Self::ViewQuery>, world: &'w World) -> Result<(), NodeRunError> {
type ViewQuery = (
&'static WaterView,
&'static ViewDepthTexture,
&'static ViewTarget,
);
fn run<'w>(
&self,
_: &mut RenderGraphContext,
context: &mut RenderContext<'w>,
(_, depth, target): QueryItem<'w, '_, Self::ViewQuery>,
world: &'w World,
) -> Result<(), NodeRunError> {
let source = world.resource::<DepthSource>();
if !source.active { return Ok(()); }
if !source.active {
return Ok(());
}
let images = world.resource::<RenderAssets<GpuImage>>();
let Some(image) = images.get(&source.image) else { return Ok(()) };
if image.texture.size() != depth.texture.size() { return Ok(()); }
let Some(image) = images.get(&source.image) else {
return Ok(());
};
if image.texture.size() != depth.texture.size() {
return Ok(());
}
let pipelines = world.resource::<DepthPipelines>();
let cache = world.resource::<PipelineCache>();
let index = usize::from(depth.texture.sample_count() > 1);
let Some(pipeline) = cache.get_render_pipeline(pipelines.pipelines[index]) else { return Ok(()) };
let Some(pipeline) = cache.get_render_pipeline(pipelines.pipelines[index]) else {
return Ok(());
};
// Both copies or neither: a depth copy without its colour would show the module a real bed
// through a black frame (shallows flash black while the colour pipeline compiles). With
// neither, the cleared depth reads as sky, the water is opaque, and nothing shows.
let Some(colour_pipeline) = cache.get_render_pipeline(pipelines.colour_pipeline) else {
return Ok(());
};
let Some(colour) = images.get(&source.colour) else { return Ok(()) };
if colour.texture.size() != depth.texture.size() { return Ok(()); }
let Some(colour) = images.get(&source.colour) else {
return Ok(());
};
if colour.texture.size() != depth.texture.size() {
return Ok(());
}
let device = world.resource::<RenderDevice>();
let bind = device.create_bind_group("water depth source",
let bind = device.create_bind_group(
"water depth source",
&cache.get_bind_group_layout(&pipelines.layouts[index]),
&BindGroupEntries::single(depth.view()));
let mut pass = context.command_encoder().begin_render_pass(&RenderPassDescriptor {
label: Some("water opaque depth copy"),
color_attachments: &[Some(RenderPassColorAttachment {
view: &image.texture_view, resolve_target: None, depth_slice: None,
ops: Operations { load: LoadOp::Clear(Default::default()), store: StoreOp::Store },
})], ..default()
});
&BindGroupEntries::single(depth.view()),
);
let mut pass = context
.command_encoder()
.begin_render_pass(&RenderPassDescriptor {
label: Some("water opaque depth copy"),
color_attachments: &[Some(RenderPassColorAttachment {
view: &image.texture_view,
resolve_target: None,
depth_slice: None,
ops: Operations {
load: LoadOp::Clear(Default::default()),
store: StoreOp::Store,
},
})],
..default()
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &bind, &[]);
pass.draw(0..3, 0..1);
@@ -232,16 +363,26 @@ impl ViewNode for DepthNode {
// The opaque colour, for the refraction. `main_texture_view` is the single-sample texture
// the opaque pass resolved into, so no MSAA variant is needed here.
let bind = device.create_bind_group("water colour source",
let bind = device.create_bind_group(
"water colour source",
&cache.get_bind_group_layout(&pipelines.colour_layout),
&BindGroupEntries::single(target.main_texture_view()));
let mut pass = context.command_encoder().begin_render_pass(&RenderPassDescriptor {
label: Some("water opaque colour copy"),
color_attachments: &[Some(RenderPassColorAttachment {
view: &colour.texture_view, resolve_target: None, depth_slice: None,
ops: Operations { load: LoadOp::Clear(Default::default()), store: StoreOp::Store },
})], ..default()
});
&BindGroupEntries::single(target.main_texture_view()),
);
let mut pass = context
.command_encoder()
.begin_render_pass(&RenderPassDescriptor {
label: Some("water opaque colour copy"),
color_attachments: &[Some(RenderPassColorAttachment {
view: &colour.texture_view,
resolve_target: None,
depth_slice: None,
ops: Operations {
load: LoadOp::Clear(Default::default()),
store: StoreOp::Store,
},
})],
..default()
});
pass.set_pipeline(colour_pipeline);
pass.set_bind_group(0, &bind, &[]);
pass.draw(0..3, 0..1);
@@ -291,9 +432,12 @@ mod tests {
let ndc = clip / clip.w;
let viewport = Vec4::new(32.0, 16.0, 1600.0, 900.0);
let pixel = (ndc.truncate().truncate() * Vec2::new(0.5, -0.5) + Vec2::splat(0.5))
* Vec2::new(viewport.z, viewport.w) + Vec2::new(viewport.x, viewport.y);
* Vec2::new(viewport.z, viewport.w)
+ Vec2::new(viewport.x, viewport.y);
let xy = ((pixel - Vec2::new(viewport.x, viewport.y))
/ Vec2::new(viewport.z, viewport.w)) * Vec2::new(2.0, -2.0) + Vec2::new(-1.0, 1.0);
/ Vec2::new(viewport.z, viewport.w))
* Vec2::new(2.0, -2.0)
+ Vec2::new(-1.0, 1.0);
let scene_h = clip_from_world.inverse() * Vec4::new(xy.x, xy.y, ndc.z, 1.0);
let vertical_depth = 10.0 - scene_h.y / scene_h.w;
assert!((vertical_depth - 2.0).abs() < 0.001);
+8 -3
View File
@@ -378,9 +378,14 @@ pub(super) fn setup_liquid(
// the time anything draws — the same path terrain and the models take.
// Read once at setup; only frozen water captures consume this override.
let capture_time = if std::env::var_os("WOW_CAPTURE").is_some() {
std::env::var("WOW_CAPTURE_WATER_T").ok().and_then(|v| v.parse::<f32>().ok())
.filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0)
} else { 0.0 };
std::env::var("WOW_CAPTURE_WATER_T")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.filter(|v| v.is_finite() && *v >= 0.0)
.unwrap_or(0.0)
} else {
0.0
};
let deterministic = crate::dev_state::deterministic_run();
let mut assets = LiquidAssets::default();
for &(kind, dir, stem, count) in FRAME_SETS {
+13 -3
View File
@@ -418,14 +418,20 @@ mod tests {
assert_eq!(lane_of(t), 6);
assert_eq!(rig_of(t), 9);
assert_eq!(t & ALPHA_MASK, alpha_bits(0.5));
assert_eq!(with_shade(t, 255, ext()) & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
assert_eq!(
with_shade(t, 255, ext()) & MATTE_INDOOR_BIT,
MATTE_INDOOR_BIT
);
assert_eq!(lane_of(with_shade(t, 255, ext())), 6);
assert_eq!(shade_of(with_shade(t, 255, ext()), ext()), 255);
assert_eq!(with_alpha(t, 0.25) & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
assert_eq!(lane_of(with_alpha(t, 0.25)), 6);
assert_eq!(with_rig(t, 3) & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
assert_eq!(lane_of(with_rig(t, 3)), 6);
assert_eq!(with_interior_fog(t, true) & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
assert_eq!(
with_interior_fog(t, true) & MATTE_INDOOR_BIT,
MATTE_INDOOR_BIT
);
assert_eq!(lane_of(with_interior_fog(t, true)), 6);
assert_eq!(with_exterior_reset(t) & MATTE_INDOOR_BIT, 0);
assert_eq!(lane_of(with_exterior_reset(t)), 0);
@@ -440,7 +446,11 @@ mod tests {
// An overshooting caller SATURATES rather than wrapping into the probe/rig neighbourhood.
let over = with_matte_indoor(alpha_bits(1.0), 200);
assert_eq!(lane_of(over), LANE_MAX);
assert_eq!(over & (RIG_MASK | SHADE_MASK), 0, "no spill into its neighbours");
assert_eq!(
over & (RIG_MASK | SHADE_MASK),
0,
"no spill into its neighbours"
);
// Lane 0 under the flag is legal (the first tick of a ramp) and is NOT the exterior reset:
// the bit is what routes the shader into the mix, the field is only its weight.
let zero = with_matte_indoor(alpha_bits(1.0), 0);
+12 -5
View File
@@ -90,7 +90,9 @@ pub(crate) fn glow_day_fade(sun_y: f32) -> f32 {
/// The glow strength: the day fade, dimmed by the cloud cover over the sun (the same coverage the
/// glare's occlusion reads) and by storm weather.
pub(crate) fn glow_strength(sun_y: f32, cover_at_sun: f32, bcc: f32) -> f32 {
GLOW_GAIN * glow_day_fade(sun_y) * (1.0 - 0.8 * cover_at_sun.clamp(0.0, 1.0))
GLOW_GAIN
* glow_day_fade(sun_y)
* (1.0 - 0.8 * cover_at_sun.clamp(0.0, 1.0))
* (1.0 - 0.85 * bcc.clamp(0.0, 1.0))
}
@@ -100,8 +102,7 @@ impl Plugin for SkyFxPlugin {
fn build(&self, app: &mut App) {
// The library `sky.wgsl` and `cloud.wgsl` import; its embedded path is served by
// `shaders::plugin`, this keeps it loaded so the import resolves.
let lib: Handle<Shader> =
bevy::asset::load_embedded_asset!(app, "shaders/sky_fx.wgsl");
let lib: Handle<Shader> = bevy::asset::load_embedded_asset!(app, "shaders/sky_fx.wgsl");
std::mem::forget(lib);
let frozen = std::env::var_os("WOW_CAPTURE").map(|_| {
std::env::var("WOW_CAPTURE_SKY_T")
@@ -117,7 +118,10 @@ impl Plugin for SkyFxPlugin {
let quality = SkyQuality(SkyQuality::env_override().unwrap_or(0));
app.insert_resource(quality)
// MONKEY (integration): the clock ticks before its readers (sky colours, cloud FX).
.add_systems(Update, tick_sky_clock.before(crate::lighting::LightingConsumeSet))
.add_systems(
Update,
tick_sky_clock.before(crate::lighting::LightingConsumeSet),
)
// Beside the shared light buffer, ahead of the sky and cloud materials built after it.
.add_systems(
Startup,
@@ -205,7 +209,10 @@ mod tests {
for (n, rate) in tiles.iter().zip([0.011, 0.0043]) {
assert_eq!(n.fract(), 0.0, "whole tiles per wrap");
let actual = n * f64::from(tile) / SKY_CLOCK_WRAP_S;
assert!((actual / rate - 1.0).abs() < 0.05, "drift {actual} vs {rate}");
assert!(
(actual / rate - 1.0).abs() < 0.05,
"drift {actual} vs {rate}"
);
}
}
+34 -23
View File
@@ -83,13 +83,13 @@ pub struct CameraSkybox(pub Vec<SkyboxLayer>);
impl CameraSkybox {
/// The heaviest main layer, the one a readout names.
pub fn primary(&self) -> Option<&SkyboxLayer> {
self.0
.iter()
.filter(|l| !l.celestial)
.fold(None, |best: Option<&SkyboxLayer>, l| match best {
self.0.iter().filter(|l| !l.celestial).fold(
None,
|best: Option<&SkyboxLayer>, l| match best {
Some(b) if b.weight >= l.weight => Some(b),
_ => Some(l),
})
},
)
}
/// MONKEY (leftovers): the heaviest main layer's weight among those carrying `flag`, 0 if none.
@@ -483,12 +483,7 @@ fn build_skybox(
.lock_recover()
.read_file(&norm(path))
.ok();
let rig = Arc::new(
bytes
.as_deref()
.map(SkyRig::from_bytes)
.unwrap_or_default(),
);
let rig = Arc::new(bytes.as_deref().map(SkyRig::from_bytes).unwrap_or_default());
let subs = benilla_formats::load_m2_mesh(&mut world_assets.chain.lock_recover(), path);
let subs = match subs {
Ok(subs) if !subs.is_empty() => subs,
@@ -539,7 +534,11 @@ fn build_skybox(
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, sub.vertex_colors.clone());
}
// MONKEY (skybox): stage 1 reads UV set B.
if let Some(st) = sub.stage1.as_ref().filter(|s| s.uvs.len() == sub.positions.len()) {
if let Some(st) = sub
.stage1
.as_ref()
.filter(|s| s.uvs.len() == sub.positions.len())
{
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_1, st.uvs.clone());
}
mesh.insert_indices(Indices::U32(sub.indices.clone()));
@@ -560,7 +559,11 @@ fn build_skybox(
};
// MONKEY (skybox): a two-texture batch takes its own copies with stage 1 bound, so
// the deduped one-texture materials stay as they were.
if let Some(st) = sub.stage1.as_ref().filter(|s| s.uvs.len() == sub.positions.len()) {
if let Some(st) = sub
.stage1
.as_ref()
.filter(|s| s.uvs.len() == sub.positions.len())
{
let tex1 = st
.texture
.as_deref()
@@ -639,9 +642,10 @@ fn build_skybox(
}
}
rigs.0.insert(path.to_string(), rig);
built
.0
.insert(path.to_string(), u16::try_from(subs.len()).unwrap_or(u16::MAX));
built.0.insert(
path.to_string(),
u16::try_from(subs.len()).unwrap_or(u16::MAX),
);
}
}
@@ -981,10 +985,7 @@ fn apply_skybox_visibility(
#[allow(clippy::type_complexity)]
fn follow_camera(
cam: Query<&GlobalTransform, With<WorldCamera>>,
mut parts: Query<
(&mut Transform, &mut GlobalTransform, &SkyboxLocal),
Without<WorldCamera>,
>,
mut parts: Query<(&mut Transform, &mut GlobalTransform, &SkyboxLocal), Without<WorldCamera>>,
) {
let Some(cam_gt) = cam.iter().next() else {
return;
@@ -1068,7 +1069,10 @@ mod tests {
assert_eq!(skybox_batch_order(true, 0, 23), 24);
assert_eq!(skybox_batch_order(false, 24, 0), 25);
assert_eq!(skybox_batch_order(false, 0, 31), 32);
assert_eq!(skybox_batch_order(false, 40, usize::MAX), FOG_CONE_ORDER - 1);
assert_eq!(
skybox_batch_order(false, 40, usize::MAX),
FOG_CONE_ORDER - 1
);
assert_eq!(FOG_CONE_ORDER, 58);
}
@@ -1102,12 +1106,19 @@ mod tests {
flags,
..layer(path, w)
};
let mut sky = CameraSkybox(vec![with("a", 0.3, 0x8), with("b", 0.7, 0x1), with("c", 0.5, 0x18)]);
let mut sky = CameraSkybox(vec![
with("a", 0.3, 0x8),
with("b", 0.7, 0x1),
with("c", 0.5, 0x18),
]);
sky.0.push(SkyboxLayer {
celestial: true,
..with("d", 1.0, 0x18)
});
assert_eq!(sky.flag_weight(benilla_formats::SKYBOX_FORCE_SUN_SHAFTS), 0.5);
assert_eq!(
sky.flag_weight(benilla_formats::SKYBOX_FORCE_SUN_SHAFTS),
0.5
);
assert_eq!(sky.flag_weight(benilla_formats::SKYBOX_NO_SUN_FOG), 0.5);
assert_eq!(sky.flag_weight(benilla_formats::SKYBOX_FOG_BLEND), 0.0);
assert_eq!(CameraSkybox::default().flag_weight(0x8), 0.0);
+21 -29
View File
@@ -116,7 +116,11 @@ impl SkyRig {
let n = skeleton.bones.len();
let mut rig = SkyRig {
parents: skeleton.bones.iter().map(|b| b.parent).collect(),
pivots: skeleton.bones.iter().map(|b| wow_to_bevy(b.pivot)).collect(),
pivots: skeleton
.bones
.iter()
.map(|b| wow_to_bevy(b.pivot))
.collect(),
tracks: vec![BoneTracks::default(); n],
duration: 0.0,
seq_slot: None,
@@ -309,7 +313,8 @@ impl SkyRig {
_ => c.sample(band_t, blend),
}
}
let tr = at(&t.g_translation, &t.translation, band_t, gseq_now, lerp3).unwrap_or(Vec3::ZERO);
let tr =
at(&t.g_translation, &t.translation, band_t, gseq_now, lerp3).unwrap_or(Vec3::ZERO);
let rot = at(&t.g_rotation, &t.rotation, band_t, gseq_now, slerp)
.unwrap_or(Quat::IDENTITY)
.normalize();
@@ -388,16 +393,8 @@ impl SkyMatLane {
lane.uv = Some((slot, seed, uv));
}
}
let rot = sub
.uv_rot_seq
.as_ref()
.and_then(|s| s.seq(None))
.cloned();
let scale = sub
.uv_scale_seq
.as_ref()
.and_then(|s| s.seq(None))
.cloned();
let rot = sub.uv_rot_seq.as_ref().and_then(|s| s.seq(None)).cloned();
let scale = sub.uv_scale_seq.as_ref().and_then(|s| s.seq(None)).cloned();
if rot.is_some() || scale.is_some() {
if let Some(slot) = table.alloc() {
write(materials, &|m| m.extension.anim_slots.z = f32::from(slot));
@@ -470,10 +467,9 @@ impl SkyMatLane {
);
}
if let Some((slot, loops)) = &self.affine {
let q = loops
.rot
.as_ref()
.map_or([0.0, 0.0, 0.0, 1.0], |l| l.sample(l.clock(band_t, gseq_now)));
let q = loops.rot.as_ref().map_or([0.0, 0.0, 0.0, 1.0], |l| {
l.sample(l.clock(band_t, gseq_now))
});
let s = loops
.scale
.as_ref()
@@ -493,10 +489,9 @@ impl SkyMatLane {
);
}
if let Some((slot, loops)) = &self.stage1_affine {
let q = loops
.rot
.as_ref()
.map_or([0.0, 0.0, 0.0, 1.0], |l| l.sample(l.clock(band_t, gseq_now)));
let q = loops.rot.as_ref().map_or([0.0, 0.0, 0.0, 1.0], |l| {
l.sample(l.clock(band_t, gseq_now))
});
let s = loops
.scale
.as_ref()
@@ -505,10 +500,7 @@ impl SkyMatLane {
}
if let Some((slot, seed, a)) = &self.tint {
let c = benilla_assets::quant255(a.sample(a.clock(band_t, gseq_now)));
table.set(
*slot,
[c[0] - seed[0], c[1] - seed[1], c[2] - seed[2], 0.0],
);
table.set(*slot, [c[0] - seed[0], c[1] - seed[1], c[2] - seed[2], 0.0]);
}
}
}
@@ -527,7 +519,10 @@ mod tests {
rotation: Channel {
keys: vec![
(0.0, Quat::IDENTITY),
(4.0, Quat::from_rotation_y(std::f32::consts::FRAC_PI_2 * 1.5)),
(
4.0,
Quat::from_rotation_y(std::f32::consts::FRAC_PI_2 * 1.5),
),
],
linear: true,
},
@@ -577,10 +572,7 @@ mod tests {
..Default::default()
};
assert!(!rig.animates());
assert!(rig
.pose(1.0, 1.0)
.iter()
.all(|m| *m == Affine3A::IDENTITY));
assert!(rig.pose(1.0, 1.0).iter().all(|m| *m == Affine3A::IDENTITY));
}
/// The Caverns of Time belts: the general rig reproduces the rigid spin the old lane drew.
+4 -6
View File
@@ -26,17 +26,15 @@ mod shadow; // MONKEY (world shadows): CPU triangle collection for the static-wo
mod torch_depth; // MONKEY (torch shadows Phase 1): the per-fixture depth-map render + its targets
pub use shadow::CutoutBucket; // MONKEY (world shadows): per-leaf-texture alpha-cutout caster group
pub use torch_depth::TorchShadowViews; // MONKEY (torch shadows Phase 1): the app→render publication
// MONKEY (torch owner exclusion): the light→caster ownership key, written at the light spawn
// sites (`terrain_stream::spawn`, `benilla_app::entities::carried_light`) and read by both
// torch caster gathers, so a fixture never casts its own body's shadow into its own map.
// MONKEY (torch owner exclusion): the light→caster ownership key, written at the light spawn
// sites (`terrain_stream::spawn`, `benilla_app::entities::carried_light`) and read by both
// torch caster gathers, so a fixture never casts its own body's shadow into its own map.
pub use torch_depth::{torch_flame_inside_bounds, LightOwner};
// MONKEY (torch shadows Phase 3A): the shared depth image + table buffer every model material binds,
// their startup constructor, the one-param main-world accessor, and the always-on wiring — used by
// the asset foundation (`crate::assets`) and every material-building site; NOT gated on `enabled()`.
pub use torch_depth::{
new_torch_shared, SharedTorchBuffer, TorchDepthImage, TorchShared,
};
pub(crate) use torch_depth::register_shared as register_torch_shared;
pub use torch_depth::{new_torch_shared, SharedTorchBuffer, TorchDepthImage, TorchShared};
/// The doodad spatial cell, a quarter ADT tile (133⅓ yd), as `terrain_stream::merge::CELL`.
const CELL: f32 = 533.333_3 / 4.0;
+29 -15
View File
@@ -332,12 +332,14 @@ fn init_pipelines(
// MONKEY (room gate): the claim table's persistent GPU buffer, written every frame by
// `prepare_room_claims`. Created here (RenderStartup) rather than beside the shared light
// buffer so the whole binding — layout, buffer, upload — lives with its one reader.
commands.insert_resource(GxRoomClaims(render_device.create_buffer(&BufferDescriptor {
label: Some("static_gx_room_claims"),
size: crate::lighting::room_claim_bytes(),
usage: BufferUsages::STORAGE | BufferUsages::COPY_DST,
mapped_at_creation: false,
})));
commands.insert_resource(GxRoomClaims(render_device.create_buffer(
&BufferDescriptor {
label: Some("static_gx_room_claims"),
size: crate::lighting::room_claim_bytes(),
usage: BufferUsages::STORAGE | BufferUsages::COPY_DST,
mapped_at_creation: false,
},
)));
commands.insert_resource(GxPipelines {
view_layout,
cell_layout,
@@ -848,7 +850,10 @@ fn prepare_room_claims(
// copy it saves, and the comparison is against what we WROTE, so it cannot disagree with the
// buffer's real contents.
let id = buffer.0.id();
if last.as_ref().is_some_and(|(b, w)| *b == id && w[..] == claims.0[..]) {
if last
.as_ref()
.is_some_and(|(b, w)| *b == id && w[..] == claims.0[..])
{
return;
}
queue.write_buffer(&buffer.0, 0, bytemuck::cast_slice(&claims.0[..]));
@@ -878,10 +883,7 @@ fn prepare_view_bind(
commands.insert_resource(GxLightBind(render_device.create_bind_group(
"static_gx_light",
&layout,
&BindGroupEntries::sequential((
light.0.as_entire_binding(),
claims.0.as_entire_binding(),
)),
&BindGroupEntries::sequential((light.0.as_entire_binding(), claims.0.as_entire_binding())),
)));
}
@@ -939,16 +941,28 @@ fn prepare_torch_bind(
}
// Rebuild in place — one `Res` write instead of a deferred `insert_resource` per frame.
*cached = GxTorchBind {
group: build_torch_bind(&pipes, &pipeline_cache, &render_device, gpu_image,
targets.sampler(), &table),
group: build_torch_bind(
&pipes,
&pipeline_cache,
&render_device,
gpu_image,
targets.sampler(),
&table,
),
table,
texture,
};
return;
}
commands.insert_resource(GxTorchBind {
group: build_torch_bind(&pipes, &pipeline_cache, &render_device, gpu_image,
targets.sampler(), &table),
group: build_torch_bind(
&pipes,
&pipeline_cache,
&render_device,
gpu_image,
targets.sampler(),
&table,
),
table,
texture,
});
+2 -1
View File
@@ -113,7 +113,8 @@ impl StaticGx {
if !item.cutout {
continue;
}
let (Some(texture_id), Some(texture)) = (item.texture, item.texture_handle.as_ref())
let (Some(texture_id), Some(texture)) =
(item.texture, item.texture_handle.as_ref())
else {
continue;
};
+405 -110
View File
@@ -54,8 +54,8 @@ use super::render::StaticGxView;
// subsystem tag that keeps a building's own batches apart from the props standing in it.
use crate::interact::WorldObject;
use crate::model_render::ModelKind;
use bevy::math::Affine3A;
use benilla_assets::materials::TorchBinds;
use bevy::math::Affine3A;
/// MONKEY (live bank rank): each 512-square Depth32 face is 1 MiB. Sixteen static cubes plus
/// eight compact live overlays cost 144 MiB; 384-square faces would cost 81 MiB.
@@ -341,8 +341,10 @@ pub fn new_torch_shared(
format: TextureFormat::Depth32Float,
// MONKEY (static torch cache): wgpu 27 transfer.rs permits partial ARRAY layers
// for depth, provided x/y cover the entire mip. Banks are disjoint subresources.
usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING
| TextureUsages::COPY_SRC | TextureUsages::COPY_DST,
usage: TextureUsages::RENDER_ATTACHMENT
| TextureUsages::TEXTURE_BINDING
| TextureUsages::COPY_SRC
| TextureUsages::COPY_DST,
view_formats: &[],
},
// The receivers' `GreaterEqual` comparison sampler (reverse-Z: lit iff the fragment's own
@@ -370,7 +372,10 @@ pub fn new_torch_shared(
usage: BufferUsages::STORAGE | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
(TorchDepthImage(images.add(image)), SharedTorchBuffer(buffer))
(
TorchDepthImage(images.add(image)),
SharedTorchBuffer(buffer),
)
}
/// MONKEY (static torch cache): byte-identical std140/std430 table in BOTH receiver shaders.
@@ -423,15 +428,17 @@ impl TorchTableUniform {
// cvar clamp and belt-and-braces here, and `strength` cannot exceed 100. A strength of 0 is
// MEANINGFUL (shadows off) and must survive the pack, which is why it gets no `max(1)`.
let soft_pct = ((soft * 100.0).round() as i64).clamp(1, 0xffff) as u32;
let strength_pct = ((views.strength.clamp(0.0, 1.0) * 100.0).round() as i64)
.clamp(0, 100) as u32;
let strength_pct =
((views.strength.clamp(0.0, 1.0) * 100.0).round() as i64).clamp(0, 100) as u32;
table.soft_strength = (strength_pct << 16) | soft_pct;
// MONKEY (outdoor torch shadows): the exterior receiver lane's live gate. Packed even when
// `count` is 0 costs nothing and is simpler to reason about than a conditional flag.
table.flags = if views.exterior { TORCH_EXT_LANE } else { 0 };
for i in 0..count {
table.positions[i] = views.positions[i].to_array();
if views.ready_mask & (1 << i) == 0 { table.positions[i][3] = 0.0; }
if views.ready_mask & (1 << i) == 0 {
table.positions[i][3] = 0.0;
}
// Six cube faces per fixture: layer = fixture * 6 + face (the depth node renders and
// the shaders' `torch_face` pick by the same index).
for f in 0..CUBE_FACES {
@@ -469,9 +476,9 @@ fn upload_torch_table(
// nothing to say. A `memcmp` of 6 KB is far cheaper than the copy it replaces, and the compare
// is against what we WROTE, so it can never disagree with the buffer's real contents.
let id = buffer.0.id();
let current = last.as_ref().is_some_and(|(b, t)| {
*b == id && bytemuck::bytes_of(t) == bytemuck::bytes_of(&table)
});
let current = last
.as_ref()
.is_some_and(|(b, t)| *b == id && bytemuck::bytes_of(t) == bytemuck::bytes_of(&table));
if !current {
queue.write_buffer(&buffer.0, 0, bytemuck::bytes_of(&table));
*last = Some((id, table));
@@ -492,7 +499,11 @@ struct TorchUploadTrace {
impl Default for TorchUploadTrace {
fn default() -> Self {
Self { uploads: 0, frames: 0, since: std::time::Instant::now() }
Self {
uploads: 0,
frames: 0,
since: std::time::Instant::now(),
}
}
}
@@ -502,7 +513,10 @@ impl TorchUploadTrace {
return;
}
if std::env::var_os("WOW_TORCH_TRACE").is_some() {
info!("torch-perf: table uploads {}/{} frames", self.uploads, self.frames);
info!(
"torch-perf: table uploads {}/{} frames",
self.uploads, self.frames
);
}
*self = Self::default();
}
@@ -575,7 +589,10 @@ fn init_torch_depth(
"torch_depth_view_layout",
&BindGroupLayoutEntries::sequential(
ShaderStages::VERTEX,
(uniform_buffer_sized(false, Some(std::num::NonZero::new(64).unwrap())),),
(uniform_buffer_sized(
false,
Some(std::num::NonZero::new(64).unwrap()),
),),
),
);
let shader: Handle<Shader> =
@@ -643,7 +660,9 @@ fn prepare_torch_depth(
draw.count = 0;
draw.rebuild_mask = 0;
// MONKEY (static torch cache): fail closed until every prerequisite for this frame exists.
if let Some(views) = views.as_mut() { views.ready_mask = 0; }
if let Some(views) = views.as_mut() {
views.ready_mask = 0;
}
// MONKEY (static torch cache): attachment views are allocated once per texture identity.
let gpu_image = image.as_ref().and_then(|h| images.get(&h.0));
match gpu_image {
@@ -683,17 +702,28 @@ fn prepare_torch_depth(
return;
}
let mut views = views.expect("count > 0 implies views present");
if layers.texture.is_none() || pipeline_cache.get_render_pipeline(targets.pipeline).is_none() {
if layers.texture.is_none()
|| pipeline_cache
.get_render_pipeline(targets.pipeline)
.is_none()
{
return;
}
let cached = cache.0.lock().unwrap();
let (ready_mask, rebuild_mask, dynamic_mask) = torch_cache_plan(
&*cached, &views.caster_meshes[..count], views.dynamic_mask, views.moving_mask,
|id| torch_mesh_ready(id, &meshes, &allocator));
&*cached,
&views.caster_meshes[..count],
views.dynamic_mask,
views.moving_mask,
|id| torch_mesh_ready(id, &meshes, &allocator),
);
views.ready_mask = ready_mask;
views.dynamic_mask = dynamic_mask;
// If entity upload is late, bind the static bank for this frame, never last frame's overlay.
if !views.entity_mesh.is_some_and(|id| torch_mesh_ready(id, &meshes, &allocator)) {
if !views
.entity_mesh
.is_some_and(|id| torch_mesh_ready(id, &meshes, &allocator))
{
views.dynamic_mask = 0;
}
let layout = pipeline_cache.get_bind_group_layout(&targets.view_layout);
@@ -702,7 +732,9 @@ fn prepare_torch_depth(
for i in 0..layer_count {
// MONKEY (static torch cache): fixture projections do not move with the camera.
// Keep their buffers/bind groups too: zero resource allocation on a steady frame.
if draw.view_projs.get(i) == Some(&views.view_projs[i]) { continue; }
if draw.view_projs.get(i) == Some(&views.view_projs[i]) {
continue;
}
let vp = views.view_projs[i].to_cols_array();
let buffer = render_device.create_buffer_with_data(&BufferInitDescriptor {
label: Some("torch_depth_view"),
@@ -775,16 +807,22 @@ impl ViewNode for TorchDepthNode {
let allocator = world.resource::<MeshAllocator>();
let image = world.resource::<TorchDepthImage>();
let images = world.resource::<RenderAssets<GpuImage>>();
let Some(gpu) = images.get(&image.0) else { return Ok(()) };
let Some(gpu) = images.get(&image.0) else {
return Ok(());
};
let cache = world.resource::<TorchStaticCache>();
let mut cached = cache.0.lock().unwrap();
let trace = world.resource::<TorchCacheTrace>();
let trace_on = std::env::var_os("WOW_TORCH_TRACE").is_some();
let mut last_trace = trace.0.lock().unwrap();
let trace_due = trace_on && last_trace.is_none_or(|t| t.elapsed().as_secs_f32() >= 1.0);
if trace_due { *last_trace = Some(std::time::Instant::now()); }
if trace_due {
*last_trace = Some(std::time::Instant::now());
}
for slot in 0..count / CUBE_FACES {
if views.ready_mask & (1 << slot) == 0 { continue; }
if views.ready_mask & (1 << slot) == 0 {
continue;
}
// MONKEY (moving fixture): same reasoning as `torch_cache_plan` — a live slot's mesh
// id is stable by design, so the id comparison would veto every rebuild the plan just
// granted it and freeze the map at the fixture's first position.
@@ -803,7 +841,10 @@ impl ViewNode for TorchDepthNode {
color_attachments: &[],
depth_stencil_attachment: Some(RenderPassDepthStencilAttachment {
view: &layers.views[layer],
depth_ops: Some(Operations { load: LoadOp::Clear(0.0), store: StoreOp::Store }),
depth_ops: Some(Operations {
load: LoadOp::Clear(0.0),
store: StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
@@ -819,23 +860,40 @@ impl ViewNode for TorchDepthNode {
let live = MAX_TORCH_LAYERS + rank * CUBE_FACES + face;
render_context.command_encoder().copy_texture_to_texture(
TexelCopyTextureInfo {
texture: &gpu.texture, mip_level: 0,
origin: Origin3d { x: 0, y: 0, z: layer as u32 },
texture: &gpu.texture,
mip_level: 0,
origin: Origin3d {
x: 0,
y: 0,
z: layer as u32,
},
aspect: TextureAspect::DepthOnly,
},
TexelCopyTextureInfo {
texture: &gpu.texture, mip_level: 0,
origin: Origin3d { x: 0, y: 0, z: live as u32 },
texture: &gpu.texture,
mip_level: 0,
origin: Origin3d {
x: 0,
y: 0,
z: live as u32,
},
aspect: TextureAspect::DepthOnly,
},
Extent3d { width: TORCH_MAP_EDGE, height: TORCH_MAP_EDGE, depth_or_array_layers: 1 },
Extent3d {
width: TORCH_MAP_EDGE,
height: TORCH_MAP_EDGE,
depth_or_array_layers: 1,
},
);
let mut pass = render_context.begin_tracked_render_pass(RenderPassDescriptor {
label: Some("torch_dynamic_overlay"),
color_attachments: &[],
depth_stencil_attachment: Some(RenderPassDepthStencilAttachment {
view: &layers.views[live],
depth_ops: Some(Operations { load: LoadOp::Load, store: StoreOp::Store }),
depth_ops: Some(Operations {
load: LoadOp::Load,
store: StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
@@ -846,12 +904,17 @@ impl ViewNode for TorchDepthNode {
draw_torch_mesh(&mut pass, views.entity_mesh, meshes, allocator);
}
}
if rebuild { cached[slot] = views.caster_meshes[slot]; }
if rebuild {
cached[slot] = views.caster_meshes[slot];
}
if trace_on && (trace_due || rebuild) {
info!("torch-cache: slot {slot} static {} dynamic {} live_rank {:?} live_base {:?}",
info!(
"torch-cache: slot {slot} static {} dynamic {} live_rank {:?} live_base {:?}",
if rebuild { "rebuilt" } else { "cached" },
if dynamic { "yes" } else { "no" }, live_rank,
live_rank.map(|rank| MAX_TORCH_LAYERS + rank * CUBE_FACES));
if dynamic { "yes" } else { "no" },
live_rank,
live_rank.map(|rank| MAX_TORCH_LAYERS + rank * CUBE_FACES)
);
}
}
Ok(())
@@ -893,7 +956,9 @@ pub(super) fn build(app: &mut App) {
Render,
// MONKEY (static torch cache): ready/dynamic masks must be final before BOTH
// the shared storage upload and static_gx's PrepareBindGroups uniform pack.
prepare_torch_depth.in_set(RenderSystems::PrepareResources).before(upload_torch_table),
prepare_torch_depth
.in_set(RenderSystems::PrepareResources)
.before(upload_torch_table),
)
.add_render_graph_node::<ViewNodeRunner<TorchDepthNode>>(Core3d, TorchDepthLabel)
.add_render_graph_edges(
@@ -918,8 +983,13 @@ pub(super) fn build(app: &mut App) {
// static map at whatever it last held; and a deferred moving rebuild is not a stale map but a
// wrong one — the table republishes its six projections from the live fixture position each frame
// while the depth still holds the old one, i.e. a shadow drawn from where the light no longer is.
fn torch_cache_plan<T: Copy + Eq>(cached: &[Option<T>], requested: &[Option<T>], dynamic_mask: u32,
moving_mask: u32, mut mesh_ready: impl FnMut(T) -> bool) -> (u32, u32, u32) {
fn torch_cache_plan<T: Copy + Eq>(
cached: &[Option<T>],
requested: &[Option<T>],
dynamic_mask: u32,
moving_mask: u32,
mut mesh_ready: impl FnMut(T) -> bool,
) -> (u32, u32, u32) {
let (mut ready, mut rebuild) = (0u32, 0u32);
let mut budget = 2usize;
let mut moving_budget = TORCH_MOVING_MAX;
@@ -935,7 +1005,11 @@ fn torch_cache_plan<T: Copy + Eq>(cached: &[Option<T>], requested: &[Option<T>],
ready |= 1 << i;
continue;
}
let lane = if moving { &mut moving_budget } else { &mut budget };
let lane = if moving {
&mut moving_budget
} else {
&mut budget
};
if *lane > 0 && mesh_ready(id) {
*lane -= 1;
rebuild |= 1 << i;
@@ -946,36 +1020,68 @@ fn torch_cache_plan<T: Copy + Eq>(cached: &[Option<T>], requested: &[Option<T>],
// does. Bound even malformed resource masks to eight cubes so no copy can escape the bank.
let eligible = dynamic_mask & ready;
let dynamic = (0..MAX_TORCH_MAPS).fold(0, |mask, slot| {
if eligible & (1 << slot) != 0 && TorchShadowViews::live_rank(eligible, slot) < MAX_TORCH_DYNAMIC {
if eligible & (1 << slot) != 0
&& TorchShadowViews::live_rank(eligible, slot) < MAX_TORCH_DYNAMIC
{
mask | (1 << slot)
} else { mask }
} else {
mask
}
});
(ready, rebuild, dynamic)
}
// MONKEY (static torch cache): an empty mesh is a valid all-clear cube. A nonempty mesh without
// allocator slices is still uploading, and MUST NOT become a permanently cached empty shadow.
fn torch_mesh_ready(id: AssetId<Mesh>, meshes: &RenderAssets<RenderMesh>, allocator: &MeshAllocator) -> bool {
let Some(mesh) = meshes.get(id) else { return false };
fn torch_mesh_ready(
id: AssetId<Mesh>,
meshes: &RenderAssets<RenderMesh>,
allocator: &MeshAllocator,
) -> bool {
let Some(mesh) = meshes.get(id) else {
return false;
};
match mesh.buffer_info {
RenderMeshBufferInfo::Indexed { count, .. } => count == 0
|| (allocator.mesh_vertex_slice(&id).is_some() && allocator.mesh_index_slice(&id).is_some()),
RenderMeshBufferInfo::Indexed { count, .. } => {
count == 0
|| (allocator.mesh_vertex_slice(&id).is_some()
&& allocator.mesh_index_slice(&id).is_some())
}
_ => false,
}
}
fn draw_torch_mesh<'a>(
pass: &mut bevy::render::render_phase::TrackedRenderPass<'a>, id: Option<AssetId<Mesh>>,
meshes: &'a RenderAssets<RenderMesh>, allocator: &'a MeshAllocator,
pass: &mut bevy::render::render_phase::TrackedRenderPass<'a>,
id: Option<AssetId<Mesh>>,
meshes: &'a RenderAssets<RenderMesh>,
allocator: &'a MeshAllocator,
) {
let Some(id) = id else { return };
let Some(mesh) = meshes.get(id) else { return };
let RenderMeshBufferInfo::Indexed { index_format, count } = mesh.buffer_info else { return };
if count == 0 { return; }
let (Some(v), Some(i)) = (allocator.mesh_vertex_slice(&id), allocator.mesh_index_slice(&id)) else { return };
let RenderMeshBufferInfo::Indexed {
index_format,
count,
} = mesh.buffer_info
else {
return;
};
if count == 0 {
return;
}
let (Some(v), Some(i)) = (
allocator.mesh_vertex_slice(&id),
allocator.mesh_index_slice(&id),
) else {
return;
};
pass.set_vertex_buffer(0, v.buffer.slice(..));
pass.set_index_buffer(i.buffer.slice(..), index_format);
pass.draw_indexed(i.range.clone(), i32::try_from(v.range.start).unwrap_or(0), 0..1);
pass.draw_indexed(
i.range.clone(),
i32::try_from(v.range.start).unwrap_or(0),
0..1,
);
}
impl super::StaticGx {
@@ -986,19 +1092,40 @@ impl super::StaticGx {
/// MONKEY (torch owner exclusion): returns how many in-range items were dropped as the
/// FIXTURE'S OWN BODY — the `WOW_TORCH_TRACE` counter, and the one number that says whether
/// this fix is doing anything for a given slot.
pub fn append_torch_triangles(&self, center: Vec3, reach: f32, owner: Option<LightOwner>,
positions: &mut Vec<[f32; 3]>, indices: &mut Vec<u32>) -> u32 {
pub fn append_torch_triangles(
&self,
center: Vec3,
reach: f32,
owner: Option<LightOwner>,
positions: &mut Vec<[f32; 3]>,
indices: &mut Vec<u32>,
) -> u32 {
let mut excluded = 0;
for cell in self.cells.values().chain(self.wmos.values()).chain(self.props.values()) {
for cell in self
.cells
.values()
.chain(self.wmos.values())
.chain(self.props.values())
{
for item in &cell.items {
if !torch_item_in_range(item, center, reach) { continue; }
if torch_item_is_emitter(item, center, owner) { excluded += 1; continue; }
if !torch_item_in_range(item, center, reach) {
continue;
}
if torch_item_is_emitter(item, center, owner) {
excluded += 1;
continue;
}
let base = positions.len() as u32;
positions.extend(item.geometry.positions.iter().map(|p|
item.transform.transform_point(benilla_assets::coords::wow_to_bevy(*p)).to_array()));
positions.extend(item.geometry.positions.iter().map(|p| {
item.transform
.transform_point(benilla_assets::coords::wow_to_bevy(*p))
.to_array()
}));
let added = positions.len() as u32 - base;
for tri in item.geometry.indices.chunks_exact(3) {
if tri.iter().all(|i| *i < added) { indices.extend(tri.iter().map(|i| base + *i)); }
if tri.iter().all(|i| *i < added) {
indices.extend(tri.iter().map(|i| base + *i));
}
}
}
}
@@ -1021,7 +1148,12 @@ impl super::StaticGx {
/// either of them moves.
pub fn torch_residency_generation(&self) -> u64 {
let mut sum = 0u64;
for cell in self.cells.values().chain(self.wmos.values()).chain(self.props.values()) {
for cell in self
.cells
.values()
.chain(self.wmos.values())
.chain(self.props.values())
{
let mut hash = std::collections::hash_map::DefaultHasher::new();
cell.last_change.hash(&mut hash);
cell.items.len().hash(&mut hash);
@@ -1036,16 +1168,29 @@ impl super::StaticGx {
/// THIS fixture's sphere invalidate it even when neither camera nor fixture moves.
pub fn torch_geometry_key(&self, center: Vec3, reach: f32, owner: Option<LightOwner>) -> u64 {
let mut sum = 0u64;
for cell in self.cells.values().chain(self.wmos.values()).chain(self.props.values()) {
for cell in self
.cells
.values()
.chain(self.wmos.values())
.chain(self.props.values())
{
for item in &cell.items {
if !torch_item_in_range(item, center, reach) { continue; }
if torch_item_is_emitter(item, center, owner) { continue; }
if !torch_item_in_range(item, center, reach) {
continue;
}
if torch_item_is_emitter(item, center, owner) {
continue;
}
let mut hash = std::collections::hash_map::DefaultHasher::new();
// MONKEY (static torch cache): source residency stamp prevents allocator address
// reuse after unload/reload from impersonating the previous geometry Arc.
cell.last_change.hash(&mut hash);
(std::sync::Arc::as_ptr(&item.geometry) as usize).hash(&mut hash);
item.transform.to_matrix().to_cols_array().map(f32::to_bits).hash(&mut hash);
item.transform
.to_matrix()
.to_cols_array()
.map(f32::to_bits)
.hash(&mut hash);
sum = sum.wrapping_add(hash.finish());
}
}
@@ -1093,8 +1238,12 @@ fn torch_item_is_emitter(item: &super::GxItem, center: Vec3, owner: Option<Light
/// `pub` and taking an `Affine3A` because BOTH caster lanes need the identical rule and they hold
/// their pose in different shapes: the retained lane has a `Transform`, the entity lane a
/// `GlobalTransform`. Two copies of a heuristic that decides "does this cast" would drift.
pub fn torch_flame_inside_bounds(world_from_local: Affine3A, local_center: Vec3,
half_extents: Vec3, light: Vec3) -> bool {
pub fn torch_flame_inside_bounds(
world_from_local: Affine3A,
local_center: Vec3,
half_extents: Vec3,
light: Vec3,
) -> bool {
let m = world_from_local.matrix3;
// The basis columns' lengths ARE the scale, whatever rotation is folded in with them.
let scale = Vec3::new(m.x_axis.length(), m.y_axis.length(), m.z_axis.length());
@@ -1109,16 +1258,30 @@ pub fn torch_flame_inside_bounds(world_from_local: Affine3A, local_center: Vec3,
}
/// The retained lane's shape of [`torch_flame_inside_bounds`].
fn torch_bound_contains(transform: &Transform, local_center: Vec3, half_extents: Vec3,
light: Vec3) -> bool {
torch_flame_inside_bounds(transform.compute_affine(), local_center, half_extents, light)
fn torch_bound_contains(
transform: &Transform,
local_center: Vec3,
half_extents: Vec3,
light: Vec3,
) -> bool {
torch_flame_inside_bounds(
transform.compute_affine(),
local_center,
half_extents,
light,
)
}
/// The proximity half of [`torch_item_is_emitter`], split out exactly as `torch_bound_in_range`
/// is: the transformed bounding sphere lies WHOLLY inside `radius` of the fixture. `radius <= 0`
/// contains nothing.
fn torch_bound_contained(transform: &Transform, local_center: Vec3, half_extents: Vec3,
center: Vec3, radius: f32) -> bool {
fn torch_bound_contained(
transform: &Transform,
local_center: Vec3,
half_extents: Vec3,
center: Vec3,
radius: f32,
) -> bool {
if radius <= 0.0 {
return false;
}
@@ -1130,12 +1293,25 @@ fn torch_bound_contained(transform: &Transform, local_center: Vec3, half_extents
// MONKEY (static torch cache): a conservative transformed bounding sphere, independent of
// portal visibility. Missing bounds admit the source rather than silently losing a wall.
fn torch_item_in_range(item: &super::GxItem, center: Vec3, reach: f32) -> bool {
!item.cutout && item.local_aabb.is_none_or(|aabb| torch_bound_in_range(
&item.transform, aabb.center.into(), aabb.half_extents.into(), center, reach))
!item.cutout
&& item.local_aabb.is_none_or(|aabb| {
torch_bound_in_range(
&item.transform,
aabb.center.into(),
aabb.half_extents.into(),
center,
reach,
)
})
}
fn torch_bound_in_range(transform: &Transform, local_center: Vec3, half_extents: Vec3,
center: Vec3, reach: f32) -> bool {
fn torch_bound_in_range(
transform: &Transform,
local_center: Vec3,
half_extents: Vec3,
center: Vec3,
reach: f32,
) -> bool {
let origin = transform.transform_point(local_center);
let radius = (half_extents * transform.scale.abs()).length();
origin.distance_squared(center) <= (reach + radius) * (reach + radius)
@@ -1150,12 +1326,27 @@ mod tests {
#[test]
fn cache_plan_reuses_and_limits_rebuilds() {
let cached = [Some(10), Some(11), None, Some(13)];
assert_eq!(torch_cache_plan(&cached, &cached, 0xf, 0, |_| false), (0b1011, 0, 0b1011));
assert_eq!(
torch_cache_plan(&cached, &cached, 0xf, 0, |_| false),
(0b1011, 0, 0b1011)
);
let requested = [Some(20), Some(11), Some(12), Some(23)];
assert_eq!(torch_cache_plan(&cached, &requested, 0xf, 0, |_| true), (0b0111, 0b0101, 0b0111));
assert_eq!(torch_cache_plan(&cached, &requested, 0xf, 0, |id| id == 23), (0b1010, 0b1000, 0b1010));
assert_eq!(torch_cache_plan(&cached, &[None, Some(11)], 0xf, 0, |_| true), (0b10, 0, 0b10));
assert_eq!(torch_cache_plan(&[None; 4], &requested, 0, 0, |_| true), (0b11, 0b11, 0));
assert_eq!(
torch_cache_plan(&cached, &requested, 0xf, 0, |_| true),
(0b0111, 0b0101, 0b0111)
);
assert_eq!(
torch_cache_plan(&cached, &requested, 0xf, 0, |id| id == 23),
(0b1010, 0b1000, 0b1010)
);
assert_eq!(
torch_cache_plan(&cached, &[None, Some(11)], 0xf, 0, |_| true),
(0b10, 0, 0b10)
);
assert_eq!(
torch_cache_plan(&[None; 4], &requested, 0, 0, |_| true),
(0b11, 0b11, 0)
);
}
// MONKEY (moving fixture): a moving slot rebuilds out of its OWN budget, so two moving
@@ -1166,11 +1357,20 @@ mod tests {
let cached = [None, None, None, None];
let requested = [Some(1), Some(2), Some(3), Some(4)];
// No moving slots: the historical two-rebuild ceiling, untouched.
assert_eq!(torch_cache_plan(&cached, &requested, 0, 0, |_| true).1, 0b0011);
assert_eq!(
torch_cache_plan(&cached, &requested, 0, 0, |_| true).1,
0b0011
);
// Slots 0 and 1 moving: they take the moving budget and slots 2/3 still get the static one.
assert_eq!(torch_cache_plan(&cached, &requested, 0, 0b0011, |_| true).1, 0b1111);
assert_eq!(
torch_cache_plan(&cached, &requested, 0, 0b0011, |_| true).1,
0b1111
);
// Three moving: the third waits (it fast-fades app-side rather than showing a wrong map).
assert_eq!(torch_cache_plan(&cached, &requested, 0, 0b0111, |_| true).1, 0b1011);
assert_eq!(
torch_cache_plan(&cached, &requested, 0, 0b0111, |_| true).1,
0b1011
);
}
// MONKEY (shadow floor): the two dials share one word; neither may corrupt the other, and a
@@ -1185,7 +1385,12 @@ mod tests {
(0.0, 2.0, 100, 100),
(0.5, -1.0, 50, 0),
] {
let views = TorchShadowViews { count: 1, soft, strength, ..Default::default() };
let views = TorchShadowViews {
count: 1,
soft,
strength,
..Default::default()
};
let word = TorchTableUniform::pack(Some(&views)).soft_strength;
assert_eq!(word & 0xffff, soft_pct, "soft {soft}");
assert_eq!(word >> 16, strength_pct, "strength {strength}");
@@ -1212,7 +1417,12 @@ mod tests {
let mut requested = cached;
requested[0] = None;
let (ready, _, dynamic) = torch_cache_plan(&cached, &requested, 0x8001, 0, |_| false);
let views = TorchShadowViews { count: 16, ready_mask: ready, dynamic_mask: dynamic, ..Default::default() };
let views = TorchShadowViews {
count: 16,
ready_mask: ready,
dynamic_mask: dynamic,
..Default::default()
};
let table = TorchTableUniform::pack(Some(&views));
assert_eq!(dynamic, 0x8000);
assert_eq!(table.dynamic_mask, dynamic);
@@ -1222,10 +1432,20 @@ mod tests {
#[test]
fn torch_bounds_include_remote_building_batches() {
let transform = Transform::from_xyz(200.0, 0.0, 0.0).with_scale(Vec3::splat(2.0));
assert!(torch_bound_in_range(&transform, Vec3::new(-100.0, 0.0, 0.0),
Vec3::splat(10.0), Vec3::ZERO, 48.0));
assert!(!torch_bound_in_range(&transform, Vec3::ZERO,
Vec3::splat(10.0), Vec3::ZERO, 48.0));
assert!(torch_bound_in_range(
&transform,
Vec3::new(-100.0, 0.0, 0.0),
Vec3::splat(10.0),
Vec3::ZERO,
48.0
));
assert!(!torch_bound_in_range(
&transform,
Vec3::ZERO,
Vec3::splat(10.0),
Vec3::ZERO,
48.0
));
}
// MONKEY (static torch cache): protect uniform/storage byte agreement and sparse-slot bank
@@ -1235,8 +1455,12 @@ mod tests {
assert_eq!(std::mem::size_of::<TorchTableUniform>(), 6416);
assert_eq!(std::mem::offset_of!(TorchTableUniform, positions), 16);
assert_eq!(std::mem::offset_of!(TorchTableUniform, view_projs), 272);
let mut views = TorchShadowViews { count: 16, dynamic_mask: (1 << 15) | 1,
ready_mask: 1 << 15, ..Default::default() };
let mut views = TorchShadowViews {
count: 16,
dynamic_mask: (1 << 15) | 1,
ready_mask: 1 << 15,
..Default::default()
};
views.positions[0] = Vec4::ONE;
views.positions[15] = Vec4::ONE;
let table = TorchTableUniform::pack(Some(&views));
@@ -1253,7 +1477,11 @@ mod tests {
assert_eq!(table.flags, 0, "not exterior by default");
views.exterior = true;
assert_eq!(TorchTableUniform::pack(Some(&views)).flags, TORCH_EXT_LANE);
assert_eq!(TorchTableUniform::pack(None).flags, 0, "no resource = no lane");
assert_eq!(
TorchTableUniform::pack(None).flags,
0,
"no resource = no lane"
);
}
// MONKEY (outdoor torch shadows): EMITTER-OWNER EXCLUSION. A campfire's own logs must leave
@@ -1263,8 +1491,15 @@ mod tests {
// a fence that merely passes the fire casting normally.
#[test]
fn only_the_fire_s_own_body_leaves_its_caster_gather() {
let excluded = |x: f32, half: f32, radius: f32| torch_bound_contained(
&Transform::from_xyz(x, 0.0, 0.0), Vec3::ZERO, Vec3::splat(half), Vec3::ZERO, radius);
let excluded = |x: f32, half: f32, radius: f32| {
torch_bound_contained(
&Transform::from_xyz(x, 0.0, 0.0),
Vec3::ZERO,
Vec3::splat(half),
Vec3::ZERO,
radius,
)
};
// The campfire prop itself: centred on the flame, ~1 yd across.
assert!(excluded(0.2, 0.5, 2.5));
// A crate two yards off: its centre is inside 2.5, its sphere is not.
@@ -1275,7 +1510,13 @@ mod tests {
assert!(!excluded(0.0, 0.1, 0.0));
// Scale is applied to the extents, exactly as `torch_bound_in_range` applies it.
let scaled = Transform::from_xyz(0.0, 0.0, 0.0).with_scale(Vec3::splat(4.0));
assert!(!torch_bound_contained(&scaled, Vec3::ZERO, Vec3::splat(0.5), Vec3::ZERO, 2.5));
assert!(!torch_bound_contained(
&scaled,
Vec3::ZERO,
Vec3::splat(0.5),
Vec3::ZERO,
2.5
));
}
// MONKEY (torch owner exclusion): the IDENTITY key. Both caster lanes have to agree on it while
@@ -1295,13 +1536,22 @@ mod tests {
// The entity lane's CLONE of the same identity still matches (a pointer key would not).
assert!(owner.owns(&lantern.clone()));
// Same building, different prop model — a WMO prop shares its building's uniqueId.
let barrel = WorldObject { label: "world/generic/barrel01.m2".into(), ..lantern.clone() };
let barrel = WorldObject {
label: "world/generic/barrel01.m2".into(),
..lantern.clone()
};
assert!(!owner.owns(&barrel));
// Same model, different placement: the lantern down the street still casts.
let other = WorldObject { id: 4243, ..lantern.clone() };
let other = WorldObject {
id: 4243,
..lantern.clone()
};
assert!(!owner.owns(&other));
// The BUILDING itself is a different subsystem, so a prop light can never delete its shell.
let building = WorldObject { kind: ModelKind::Wmo, ..lantern.clone() };
let building = WorldObject {
kind: ModelKind::Wmo,
..lantern.clone()
};
assert!(!owner.owns(&building));
// An entity-hosted light names a frame, and never claims a placement by accident.
assert!(!LightOwner::Instance(Entity::from_raw_u32(7).unwrap()).owns(&lantern));
@@ -1322,31 +1572,76 @@ mod tests {
let post_half = Vec3::new(0.4, 1.6, 0.4);
let flame = Vec3::new(0.0, 3.0, 0.0);
assert!(torch_bound_contains(&at(0.0, 0.0), post, post_half, flame));
assert!(!torch_bound_contained(&at(0.0, 0.0), post, post_half, flame, TORCH_SELF_TINY),
"the retired proximity rule never saw it");
assert!(
!torch_bound_contained(&at(0.0, 0.0), post, post_half, flame, TORCH_SELF_TINY),
"the retired proximity rule never saw it"
);
// The crate 1.5 yd away keeps casting — that shadow is the whole point of the feature.
assert!(!torch_bound_contains(&at(1.5, 0.3), Vec3::ZERO, Vec3::splat(0.5), flame));
assert!(!torch_bound_contains(
&at(1.5, 0.3),
Vec3::ZERO,
Vec3::splat(0.5),
flame
));
// A room-sized wall batch holds the candle but is NOT fixture-sized: excluding it would
// delete the room's shadows and leak the candle through its own walls.
assert!(!torch_bound_contains(&at(0.0, 0.0), Vec3::ZERO, Vec3::splat(20.0), flame));
assert!(!torch_bound_contains(&at(0.0, 0.0), Vec3::ZERO,
Vec3::splat(TORCH_SELF_MAX_EXTENT), flame), "at the cap, still a building");
assert!(!torch_bound_contains(
&at(0.0, 0.0),
Vec3::ZERO,
Vec3::splat(20.0),
flame
));
assert!(
!torch_bound_contains(
&at(0.0, 0.0),
Vec3::ZERO,
Vec3::splat(TORCH_SELF_MAX_EXTENT),
flame
),
"at the cap, still a building"
);
// The pad reaches a flame sitting just proud of its housing, and stops well short of the
// next prop along.
let head = Vec3::new(0.0, 0.0, 0.0);
let head_half = Vec3::splat(0.25);
assert!(torch_bound_contains(&at(0.0, 0.0), head, head_half, Vec3::new(0.0, 0.5, 0.0)));
assert!(!torch_bound_contains(&at(0.0, 0.0), head, head_half, Vec3::new(0.0, 0.9, 0.0)));
assert!(torch_bound_contains(
&at(0.0, 0.0),
head,
head_half,
Vec3::new(0.0, 0.5, 0.0)
));
assert!(!torch_bound_contains(
&at(0.0, 0.0),
head,
head_half,
Vec3::new(0.0, 0.9, 0.0)
));
// Scale applies to the box AND to the pad (a world-space pad in model units).
let big = Transform::from_xyz(0.0, 0.0, 0.0).with_scale(Vec3::splat(4.0));
assert!(torch_bound_contains(&big, Vec3::ZERO, Vec3::splat(0.3), Vec3::new(0.0, 1.2, 0.0)));
assert!(!torch_bound_contains(&big, Vec3::ZERO, Vec3::splat(2.0), Vec3::ZERO),
"scaled past the fixture cap");
assert!(torch_bound_contains(
&big,
Vec3::ZERO,
Vec3::splat(0.3),
Vec3::new(0.0, 1.2, 0.0)
));
assert!(
!torch_bound_contains(&big, Vec3::ZERO, Vec3::splat(2.0), Vec3::ZERO),
"scaled past the fixture cap"
);
// A rotated post is tested against its own box, not its circumsphere.
let spun = Transform::from_xyz(0.0, 0.0, 0.0)
.with_rotation(Quat::from_rotation_y(std::f32::consts::FRAC_PI_2));
assert!(torch_bound_contains(&spun, Vec3::new(0.0, 1.5, 2.0), post_half,
Vec3::new(2.0, 3.0, 0.0)));
assert!(!torch_bound_contains(&spun, Vec3::new(0.0, 1.5, 2.0), post_half, flame));
assert!(torch_bound_contains(
&spun,
Vec3::new(0.0, 1.5, 2.0),
post_half,
Vec3::new(2.0, 3.0, 0.0)
));
assert!(!torch_bound_contains(
&spun,
Vec3::new(0.0, 1.5, 2.0),
post_half,
flame
));
}
}
@@ -366,8 +366,7 @@ pub fn spawn_model_entities(
groups,
bounds,
sky,
} if is_wmo && class.merges() =>
{
} if is_wmo && class.merges() => {
groups.get(batch_idx).map(|&g| {
(
(0, 0), // WMO items release by instance death, never by tile
@@ -379,16 +378,17 @@ pub fn spawn_model_entities(
// scale — the same eligibility the exterior lane's strict
// claim term uses, so a group that may be lit by a room's
// fixtures is exactly the group that stops reading as sky.
ext_night: bounds
.get(usize::from(g))
.is_some_and(benilla_formats::room_claim::ext_building_scale),
ext_night: bounds.get(usize::from(g)).is_some_and(
benilla_formats::room_claim::ext_building_scale,
),
// MONKEY (enclosed day floor): this batch's group is a ROOM
// INSIDE A BUILDING -- an interior-class group whose centre
// sits in a building-scale exterior shell of the same root.
// Same place, same table, same argument as `ext_night` above:
// this is the last point at which the model's group table is
// in hand, and the answer rides to the shader as a record bit.
enclosed: benilla_formats::room_claim::enclosed_by_building_shell(
enclosed:
benilla_formats::room_claim::enclosed_by_building_shell(
bounds, g,
)
// MONKEY (daylight: district sky rooms): …or a city room
@@ -376,7 +376,14 @@ pub(super) fn portal_slices(model: &WmoModel) -> Vec<(u16, u16)> {
fn pack_claims(claims: &[room_claim::Claim]) -> std::sync::Arc<[u16]> {
claims
.iter()
.map(|c| c.group | if c.ext_ok { 0 } else { crate::lighting::LIT_ROOM_EXT_DENY })
.map(|c| {
c.group
| if c.ext_ok {
0
} else {
crate::lighting::LIT_ROOM_EXT_DENY
}
})
.collect()
}
@@ -735,9 +742,9 @@ pub(super) fn spawn_ribbons_for(
#[cfg(test)]
mod tests {
use super::{carried_light_claims, pack_claims};
use benilla_assets::WmoModel;
use benilla_formats::room_claim::{room_claims, PortalGraph};
use benilla_formats::WmoGroupInfo;
use benilla_assets::WmoModel;
use bevy::prelude::*;
fn g(interior: bool, min: [f32; 3], max: [f32; 3]) -> WmoGroupInfo {
@@ -763,9 +770,9 @@ mod tests {
#[test]
fn a_fixture_claims_the_rooms_whose_authored_box_holds_it() {
let groups = [
g(true, [14.11, -4.86, 0.40], [20.47, -0.38, 4.37]), // g0, far corner room
g(true, [-17.07, -11.08, -0.21], [14.39, 15.54, 16.32]), // g1 (inn "kitchen")
g(true, [-36.75, -8.67, -6.76], [-17.01, 12.68, 0.45]), // g2 (inn "room03", basement)
g(true, [14.11, -4.86, 0.40], [20.47, -0.38, 4.37]), // g0, far corner room
g(true, [-17.07, -11.08, -0.21], [14.39, 15.54, 16.32]), // g1 (inn "kitchen")
g(true, [-36.75, -8.67, -6.76], [-17.01, 12.68, 0.45]), // g2 (inn "room03", basement)
g(false, [-37.77, -13.53, -0.18], [19.91, 18.68, 23.28]), // g3 (inn "upstairs" SHELL)
];
let claim = |p: [f32; 3]| room_claims(&groups, PortalGraph::default(), p, 11.2, &[]);
@@ -790,7 +797,11 @@ mod tests {
/// one tavern candle.
#[test]
fn a_district_shell_is_claimed_but_barred_from_the_exterior_lane() {
let groups = [g(false, [-510.81, -82.83, -5.78], [-315.12, 101.35, 126.18])];
let groups = [g(
false,
[-510.81, -82.83, -5.78],
[-315.12, 101.35, 126.18],
)];
let packed = pack_claims(&room_claims(
&groups,
PortalGraph::default(),
@@ -826,9 +837,8 @@ mod tests {
let placement = Transform::from_xyz(100.0, 0.0, 0.0);
let world_from_local = placement.compute_affine();
// Model-space (-9.13, -1.13, 4.48) — the inn's real ground-floor fixture L0.
let at = placement.transform_point(benilla_assets::coords::wow_to_bevy([
-9.13, -1.13, 4.48,
]));
let at =
placement.transform_point(benilla_assets::coords::wow_to_bevy([-9.13, -1.13, 4.48]));
let inst = Entity::from_raw_u32(12).unwrap();
let set = carried_light_claims(&model, inst, world_from_local, at, 11.2)
@@ -855,10 +865,13 @@ mod tests {
// claim would pack it UNGATED, which fails open — but it is barred from the exterior lane,
// so `all_ext_ok` is false and the caller leaves the light on the EXTERIOR lane rather than
// taking its pool off the very cobbles it stands on.
model.group_bounds = vec![g(false, [-510.81, -82.83, -5.78], [-315.12, 101.35, 126.18])];
let canyon = placement.transform_point(benilla_assets::coords::wow_to_bevy([
-400.0, 0.0, 10.0,
]));
model.group_bounds = vec![g(
false,
[-510.81, -82.83, -5.78],
[-315.12, 101.35, 126.18],
)];
let canyon =
placement.transform_point(benilla_assets::coords::wow_to_bevy([-400.0, 0.0, 10.0]));
let set = carried_light_claims(&model, inst, world_from_local, canyon, 11.2)
.expect("the district shell contains it");
assert!(!set.any_interior);
@@ -134,7 +134,10 @@ pub(super) fn spawn_loaded_placements(
// `alliancebrasscannon_flat.m2`, 2026-09-08). Retire it as an empty
// spawn so the accounting completes; the reference draws nothing there
// either.
if matches!(asset_server.load_state(h), bevy::asset::LoadState::Failed(_)) {
if matches!(
asset_server.load_state(h),
bevy::asset::LoadState::Failed(_)
) {
warn!("placement {unique_id}: its model failed to load — spawning nothing");
p.spawned = true;
*pending_spawns -= 1;
@@ -305,8 +308,13 @@ pub(super) fn spawn_loaded_placements(
let Some(m) = wmos.get(h) else {
// MONKEY (missing model): see the M2 arm — a WMO the client lacks
// retires as an empty spawn rather than pinning the loading screen.
if matches!(asset_server.load_state(h), bevy::asset::LoadState::Failed(_)) {
warn!("placement {unique_id}: its WMO failed to load — spawning nothing");
if matches!(
asset_server.load_state(h),
bevy::asset::LoadState::Failed(_)
) {
warn!(
"placement {unique_id}: its WMO failed to load — spawning nothing"
);
p.spawned = true;
*pending_spawns -= 1;
activity.placements_spawned += 1;
@@ -340,13 +348,19 @@ pub(super) fn spawn_loaded_placements(
refs: &m.portal_refs,
slices: &sky_slices,
},
m.submeshes.iter().zip(m.submesh_group.iter()).map(|(s, g)| {
(
*g,
matches!(s.wmo_batch, Some(benilla_formats::WmoBatchClass::Ext)),
&s.geometry.positions[..],
)
}),
m.submeshes
.iter()
.zip(m.submesh_group.iter())
.map(|(s, g)| {
(
*g,
matches!(
s.wmo_batch,
Some(benilla_formats::WmoBatchClass::Ext)
),
&s.geometry.positions[..],
)
}),
);
let instance = (has_portals || m.wmo_id != 0).then(|| {
commands
@@ -641,7 +655,8 @@ pub(super) fn spawn_loaded_placements(
.unwrap_or_default();
let slices = wmo.map(fx::portal_slices).unwrap_or_default();
// MONKEY (review fixes): the claim list owns its placement even when no MODR names it.
let claims = wmo.zip(portal_instance)
let claims = wmo
.zip(portal_instance)
.map(|(w, instance)| fx::PropClaims::new(w, &slices, placement, instance));
for d in &mut p.doodads {
if d.spawned {
@@ -651,7 +666,10 @@ pub(super) fn spawn_loaded_placements(
// MONKEY (missing model): a prop whose M2 the client doesn't ship would hold its
// BUILDING un-"up" (`placements_pending`) for the loading screen's whole life —
// the 2371 s Stormwind hang on `alliancebrasscannon_flat.m2`. Retire the prop.
if matches!(asset_server.load_state(&d.handle), bevy::asset::LoadState::Failed(_)) {
if matches!(
asset_server.load_state(&d.handle),
bevy::asset::LoadState::Failed(_)
) {
warn!("placement {unique_id}: a WMO prop's model failed to load — skipping it");
d.spawned = true;
*pending_spawns -= 1;
+2 -2
View File
@@ -190,8 +190,8 @@ fn apply_swim_bob(
// ramp is already zero there, so a swimmer never fades out mid-stroke.
let s = waves::swell(pos.xz(), t, waves::OCEAN_WAVE_ENERGY, ramp);
// The lean is for a body at rest; a stroking swimmer already has a swim pitch.
let rest = 1.0
- ((speed - REST_SPEED.0) / (REST_SPEED.1 - REST_SPEED.0)).clamp(0.0, 1.0);
let rest =
1.0 - ((speed - REST_SPEED.0) / (REST_SPEED.1 - REST_SPEED.0)).clamp(0.0, 1.0);
(s.height * BOB_GAIN * ramp, s.grad * rest * ramp)
}
// Out of the water (or Classic, or over a flat liquid): the target is FLAT, and the
+10 -2
View File
@@ -35,8 +35,8 @@ use crate::view::WorldCamera;
use crate::world_unit::{ViewerUnit, WorldUnit};
use benilla_assets::{AssetSet, WorldAssets};
use params::{foam_params, foam_uv, rand01, record_alpha, record_size, WadeState};
use params::{foam_gain, swim_ramp, tread_params, FoamLook, TREAD_INTERVAL};
use params::{foam_params, foam_uv, rand01, record_alpha, record_size, WadeState};
use params::{wake_cooldown, RING_INTERVAL};
/// The ring and wake stencils, loaded raw: the alpha is the shape and the near-black RGB the
@@ -366,7 +366,15 @@ fn drive_unit(
if !oneshot && now < foam_state.ready {
return;
}
let Some(p) = foam_params(state, oneshot, scale, gate, depth, look, &mut foam_state.rng) else {
let Some(p) = foam_params(
state,
oneshot,
scale,
gate,
depth,
look,
&mut foam_state.rng,
) else {
return;
};
let heading = match (p.ring, state) {
+69 -8
View File
@@ -292,14 +292,32 @@ mod tests {
speed: 7.0,
heading: 0.0,
};
let p = foam_params(translating, false, 1.0, 1.0, 0.3, FoamLook::CLASSIC, &mut rng).unwrap();
let p = foam_params(
translating,
false,
1.0,
1.0,
0.3,
FoamLook::CLASSIC,
&mut rng,
)
.unwrap();
assert!(!p.ring);
assert!((0.3..=0.37).contains(&p.size0), "wake size0 {}", p.size0);
assert!((2.8..=4.2).contains(&p.growth), "wake growth {}", p.growth);
assert!((0.6..0.7).contains(&p.lifetime));
assert!((p.peak - 1.0).abs() < 1e-6);
let p = foam_params(WadeState::Standing, false, 1.0, 1.0, 0.3, FoamLook::CLASSIC, &mut rng).unwrap();
let p = foam_params(
WadeState::Standing,
false,
1.0,
1.0,
0.3,
FoamLook::CLASSIC,
&mut rng,
)
.unwrap();
assert!(p.ring);
assert!((0.16..=0.23).contains(&p.size0), "ring size0 {}", p.size0);
assert!(
@@ -310,7 +328,16 @@ mod tests {
assert!((0.6..0.7).contains(&p.lifetime), "ring life {}", p.lifetime);
assert!((p.peak - 0.8).abs() < 1e-6);
let p = foam_params(translating, true, 1.0, 1.0, 0.3, FoamLook::CLASSIC, &mut rng).unwrap();
let p = foam_params(
translating,
true,
1.0,
1.0,
0.3,
FoamLook::CLASSIC,
&mut rng,
)
.unwrap();
assert!(p.ring, "a one-shot is ring-category");
assert!(
(0.3..=0.377).contains(&p.size0),
@@ -341,10 +368,37 @@ mod tests {
#[test]
fn depth_gate_and_attenuation() {
let mut rng = 7u32;
assert!(foam_params(WadeState::Standing, false, 1.0, 1.0, 1.05, FoamLook::CLASSIC, &mut rng).is_none());
assert!(foam_params(WadeState::Standing, false, 1.0, 1.0, -0.1, FoamLook::CLASSIC, &mut rng).is_none());
assert!(foam_params(
WadeState::Standing,
false,
1.0,
1.0,
1.05,
FoamLook::CLASSIC,
&mut rng
)
.is_none());
assert!(foam_params(
WadeState::Standing,
false,
1.0,
1.0,
-0.1,
FoamLook::CLASSIC,
&mut rng
)
.is_none());
// Near the gate depth, k → 0.5: a standing ring's peak → 6 × (0.8/6 × ~0.5) ≈ 0.4.
let deep = foam_params(WadeState::Standing, false, 1.0, 1.0, 0.99, FoamLook::CLASSIC, &mut rng).unwrap();
let deep = foam_params(
WadeState::Standing,
false,
1.0,
1.0,
0.99,
FoamLook::CLASSIC,
&mut rng,
)
.unwrap();
assert!((deep.peak - 0.8 * 0.505).abs() < 0.02, "peak {}", deep.peak);
}
@@ -407,7 +461,11 @@ mod tests {
assert!((splash_e.size0 / splash_c.size0 - 1.8).abs() < 1e-5);
assert!((splash_e.growth / splash_c.growth - 1.3).abs() < 1e-5);
}
assert_eq!(foam_gain(false), 1.0, "Classic draws at the authored colour");
assert_eq!(
foam_gain(false),
1.0,
"Classic draws at the authored colour"
);
assert!((foam_gain(true) - 3.5).abs() < 1e-6);
}
@@ -460,7 +518,10 @@ mod tests {
assert_eq!(swim_ramp(-1.0, h), 0.0, "dry");
assert_eq!(swim_ramp(0.0, h), 0.0);
assert_eq!(swim_ramp(0.5 * h, h), 0.0, "knee-deep is not swimming");
assert!(swim_ramp(0.75 * h, h) > 0.7, "the verified latch is mostly in");
assert!(
swim_ramp(0.75 * h, h) > 0.7,
"the verified latch is mostly in"
);
assert_eq!(swim_ramp(0.9 * h, h), 1.0, "past the band, saturated");
assert_eq!(swim_ramp(10.0, h), 1.0);
let mut prev = 0.0;
+5 -1
View File
@@ -153,7 +153,11 @@ mod tests {
for _ in 0..(120 * 10) {
w.step(0.0, 0.1);
}
assert!((w.wetness - 0.5).abs() < 0.01, "half dry at 2 min: {}", w.wetness);
assert!(
(w.wetness - 0.5).abs() < 0.01,
"half dry at 2 min: {}",
w.wetness
);
for _ in 0..(121 * 10) {
w.step(0.0, 0.1);
}
+12 -3
View File
@@ -412,11 +412,17 @@ mod tests {
// The player's strength scales the sway only; a storm swings it harder still.
world.insert_resource(FoliageWindStrength::from_cvar(2.0));
world.run_system_once(update_wind).unwrap();
assert_eq!(world.resource::<MonkeyFrame>().pack(0.5, 0.0)[5][1..], [2.0, 1.0, 1.0]);
assert_eq!(
world.resource::<MonkeyFrame>().pack(0.5, 0.0)[5][1..],
[2.0, 1.0, 1.0]
);
world.resource_mut::<WeatherState>().sky_density = 1.0;
world.run_system_once(update_wind).unwrap();
let sway = world.resource::<MonkeyFrame>().sway_strength;
assert!((sway - 2.0 * (1.0 + STORM_SWAY_GAIN)).abs() < 1.0e-6, "{sway}");
assert!(
(sway - 2.0 * (1.0 + STORM_SWAY_GAIN)).abs() < 1.0e-6,
"{sway}"
);
world.insert_resource(FoliageWind(0));
world.run_system_once(update_wind).unwrap();
let rows = world.resource::<MonkeyFrame>().pack(0.5, 0.0);
@@ -437,7 +443,10 @@ mod tests {
let p = WindProfile::default();
for t0 in [10.0, 600.0, 36_000.0] {
let d = integrate_travel(p, 0.0, t0, t0 + 1.0);
assert!(d > 0.0 && d < 2.2 * f64::from(p.base_speed), "t0={t0} d={d}");
assert!(
d > 0.0 && d < 2.2 * f64::from(p.base_speed),
"t0={t0} d={d}"
);
}
}
}