Fix TurtleWoW baked NPC atlases (append .blp) plus in-progress GFX work: moonlight, torch depth, water/scene-depth, skybox
This commit is contained in:
@@ -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>
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>") {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user