diff --git a/crates/benilla-app/assets/ui/CharacterFrameAdapters.xml b/crates/benilla-app/assets/ui/CharacterFrameAdapters.xml new file mode 100644 index 00000000..7c4e2419 --- /dev/null +++ b/crates/benilla-app/assets/ui/CharacterFrameAdapters.xml @@ -0,0 +1,16 @@ + + + + diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc index 9170c73a..c17398e6 100644 --- a/crates/benilla-app/assets/ui/benilla.toc +++ b/crates/benilla-app/assets/ui/benilla.toc @@ -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. diff --git a/crates/benilla-app/src/addon_harness/render_tests.rs b/crates/benilla-app/src/addon_harness/render_tests.rs index d083d5ea..c4d1308d 100644 --- a/crates/benilla-app/src/addon_harness/render_tests.rs +++ b/crates/benilla-app/src/addon_harness/render_tests.rs @@ -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)?; diff --git a/crates/benilla-app/src/blob_shadow.rs b/crates/benilla-app/src/blob_shadow.rs index 40252fb5..c04bdf16 100644 --- a/crates/benilla-app/src/blob_shadow.rs +++ b/crates/benilla-app/src/blob_shadow.rs @@ -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); diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index 4ee9c272..0cd39407 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -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, ) { - let Some(n) = std::env::var("WOW_PERF_CROWD").ok().and_then(|v| v.parse::().ok()) else { + let Some(n) = std::env::var("WOW_PERF_CROWD") + .ok() + .and_then(|v| v.parse::().ok()) + else { return; }; let Some(scenario) = ctx.scenario else { diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index 1d6ab8ab..9ddef204 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -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=: 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. diff --git a/crates/benilla-app/src/character_shadow.rs b/crates/benilla-app/src/character_shadow.rs index cc4cf4dd..cdb5452f 100644 --- a/crates/benilla-app/src/character_shadow.rs +++ b/crates/benilla-app/src/character_shadow.rs @@ -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() { diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index e02f739b..c003d01e 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -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::() + app.world_mut() + .non_send_resource_mut::() .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::() - .run("SetCVar('lightingQuality', 'Low')").unwrap(); + app.world_mut() + .non_send_resource_mut::() + .run("SetCVar('lightingQuality', 'Low')") + .unwrap(); if flush_preset_first { app.world_mut().run_schedule(Update); assert_eq!(res::(&app).shadow_map_size, 1024); } - app.world_mut().non_send_resource_mut::() - .run("SetCVar('shadowMapSize', '4096')").unwrap(); + app.world_mut() + .non_send_resource_mut::() + .run("SetCVar('shadowMapSize', '4096')") + .unwrap(); for _ in 0..3 { app.world_mut().run_schedule(Update); let cvars = res::(&app); @@ -3176,7 +3203,10 @@ mod tests { assert_eq!(res::(&app).shadow_map_size, 4096); let script = app.world().non_send_resource::(); 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::(&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!( diff --git a/crates/benilla-app/src/debug_panel/lighting_controls.rs b/crates/benilla-app/src/debug_panel/lighting_controls.rs index 12190dc3..44ac98a3 100644 --- a/crates/benilla-app/src/debug_panel/lighting_controls.rs +++ b/crates/benilla-app/src/debug_panel/lighting_controls.rs @@ -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); } diff --git a/crates/benilla-app/src/dynamic_interior.rs b/crates/benilla-app/src/dynamic_interior.rs index 5b0a1e8b..08d3b8d4 100644 --- a/crates/benilla-app/src/dynamic_interior.rs +++ b/crates/benilla-app/src/dynamic_interior.rs @@ -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::() .init_resource::() .add_plugins(DynamicInteriorPlugin); - assert_eq!(app.world().resource::().0, VideoConfig::default().water_quality); - assert_eq!(app.world().resource::().0, VideoConfig::default().lava_light_gain); + assert_eq!( + app.world().resource::().0, + VideoConfig::default().water_quality + ); + assert_eq!( + app.world().resource::().0, + VideoConfig::default().lava_light_gain + ); app.update(); for (water, lava) in [(0, 0.0), (2, 3.25), (1, 1.0)] { { diff --git a/crates/benilla-app/src/entities/attach/char_skin.rs b/crates/benilla-app/src/entities/attach/char_skin.rs index 70aa8a49..9d3439b1 100644 --- a/crates/benilla-app/src/entities/attach/char_skin.rs +++ b/crates/benilla-app/src/entities/attach/char_skin.rs @@ -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" + ); + } +} diff --git a/crates/benilla-app/src/entities/attach/mod.rs b/crates/benilla-app/src/entities/attach/mod.rs index 0055355e..b6c4d7e0 100644 --- a/crates/benilla-app/src/entities/attach/mod.rs +++ b/crates/benilla-app/src/entities/attach/mod.rs @@ -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() { diff --git a/crates/benilla-app/src/entities/carried_light.rs b/crates/benilla-app/src/entities/carried_light.rs index e3dc2f47..3f45db68 100644 --- a/crates/benilla-app/src/entities/carried_light.rs +++ b/crates/benilla-app/src/entities/carried_light.rs @@ -458,7 +458,11 @@ pub(crate) fn track_carried_light_motion( time: Res