From 277deac06be0a775ad8656e020de05cf14fc6d3d Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 18 Sep 2026 14:31:51 +0100
Subject: [PATCH] =?UTF-8?q?MONKEY:=20lighting=20round=206=20=E2=80=94=20Ad?=
=?UTF-8?q?vanced=20Graphics=20options=20page,=20moon=20shadows,=20ground-?=
=?UTF-8?q?effect=20spell=20lights?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Advanced Graphics page: Lighting Quality preset (Off/Low/Medium/High/Custom, one const table,
Off = the original client's look), shadow rows moved from Graphics, Shadow Resolution, Moon
Shadows, interior/torch/fire/spell/night rows; EN+RU strings; works in player builds.
- Moon shadows: the single shadow rig re-aims at the moon at night (MoonShadowStrength 0.35);
attenuates only the night sky term, never point lights; covers terrain, WMO exteriors, models
and retained doodads; stateful hand-over for clock jumps; blob gated on a built silhouette;
zero cost at strength 0. Signed fog_params.z carries +sun / -moon*strength.
- Area spell lights: SpellLightMode::Area for Flamestrike, Rain of Fire, Consecration, Flare,
Explosive Trap (school-based where the model says nothing); Blizzard/Hurricane/Volley/frost
traps stay dark; repeating impacts deduped; benilla-extract spellvis prints the area block.
1121 world+formats tests; 8/8 WGSL probes; UI and world smoke scenes clean.
Co-Authored-By: Claude Fable 5.1
---
crates/benilla-app/assets/ui/OptionsFrame.xml | 725 ++++++++++++++++--
crates/benilla-app/src/blob_shadow.rs | 97 ++-
crates/benilla-app/src/character_shadow.rs | 26 +
crates/benilla-app/src/cvars.rs | 428 ++++++++++-
.../src/debug_panel/lighting_controls.rs | 28 +-
crates/benilla-app/src/dynamic_interior.rs | 14 +-
crates/benilla-app/src/entities.rs | 3 +
.../benilla-app/src/entities/carried_light.rs | 139 +++-
crates/benilla-app/src/entities/dest_fx.rs | 132 +++-
.../src/entities/spell_fx/lifecycle.rs | 207 ++++-
.../benilla-app/src/entities/spell_fx/mod.rs | 6 +-
crates/benilla-app/src/shadow_core.rs | 137 +++-
.../src/ui_script/options_tests.rs | 400 +++++++++-
crates/benilla-app/src/video.rs | 21 +-
crates/benilla-app/src/world_shadow.rs | 8 +
.../src/shaders/shadow_hook.wgsl | 82 +-
.../benilla-assets/src/shaders/terrain.wgsl | 36 +-
crates/benilla-assets/src/shaders/wdl.wgsl | 2 +-
.../benilla-assets/src/shaders/wow_model.wgsl | 33 +-
.../src/bin/benilla-extract/spellvis.rs | 27 +
crates/benilla-formats/src/fire_light.rs | 277 ++++++-
crates/benilla-formats/src/lib.rs | 8 +
.../src/lighting/global_light.rs | 475 +++++++++++-
crates/benilla-world/src/lighting/mod.rs | 24 +
.../benilla-world/src/shaders/static_gx.wgsl | 28 +-
.../benilla-world/src/shaders/wow_effect.wgsl | 5 +-
26 files changed, 3180 insertions(+), 188 deletions(-)
diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index d9d432eb..9e9cbae9 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -329,9 +329,12 @@
-- The static category tree (era Game tab, Classic tocs), in list order. Keys are the row
-- name suffixes; the visible label doubles as the page title (the era does the same —
-- Settings.RegisterCategory names both from one string).
+ -- MONKEY (advanced graphics): "AdvancedGraphics" joins the System group under Graphics —
+ -- the seat the era's own tree gives a page that refines the one above it, and the seat
+ -- that keeps the three shadow rows it inherits one row away from where they used to be.
OPTIONS_CATEGORY_KEYS = {
"Controls", "Interface", "ActionBars", "Combat", "Chat", "Keybindings", "Nameplates",
- "Graphics", "Audio",
+ "Graphics", "AdvancedGraphics", "Audio",
}
-- The era-window scale (shared, director-owned — the 0219 WORLDMAP_WINDOWED_SCALE
@@ -804,19 +807,265 @@
.. "which it could only do in exclusive fullscreen. benilla applies it to the "
.. "finished image instead, so it works in both display modes."
- BENILLA_TOOLTIP_CHARACTER_SHADOWS =
- "Renders real dynamic silhouettes for players, NPCs, creatures, and mounts, replacing "
- .. "the legacy oval blob. Matches Wrath's character-only shadow tier. This uses "
- .. "additional GPU time and memory."
+ -- ══ MONKEY (advanced graphics): the Advanced Graphics page's own strings, EN + RU ═════
+ --
+ -- The page that gives a PLAYER the dynamic light and shadow system. Until it existed the
+ -- lanes were reachable only from `/console` and the native debug panel — and the panel
+ -- does not ship in a player build, so a player had no way in at all. Three rows move here
+ -- off the Graphics page (Real Character Shadows, World Shadows, Shadow Distance) with
+ -- their settings and their descriptions unchanged; the other twelve are new seats on
+ -- CVars this client has read for some time.
+ --
+ -- **Every string on it is authored in both languages** (the project's own standing rule),
+ -- picked by `GetLocale()`, and there is no other localisation pattern in this window to
+ -- follow: every page above is 1.12 GlobalStrings, whose own file IS the localisation. So
+ -- the table is here, next to the page, rather than in a scheme of its own.
+ --
+ -- TWO CAVEATS, both true of the client and not of this table: benilla's `GetLocale()`
+ -- answers `enUS` unconditionally today (it ships and reads enUS data only), so the ruRU
+ -- column is dormant until that changes; and 1.12's own `FRIZQT__.TTF` carries no Cyrillic
+ -- glyphs, so a Russian build needs a font that does before these read as words. Neither is
+ -- a reason to ship the page half-authored — both are one line away from being true, and a
+ -- string written later is a string written to a different standard.
+ --
+ -- The VALUES the rows write are never localised: a dropdown entry carries the engine's own
+ -- string in `value` and the player's language in `text`, so `config.toml` and `/console`
+ -- read the same words on every install.
+ BENILLA_ADVGFX_STRINGS = {
+ enUS = {
+ page = "Advanced Graphics",
+ lightingQuality = "Lighting Quality",
+ qOff = "Off",
+ qLow = "Low",
+ qMedium = "Medium",
+ qHigh = "High",
+ qCustom = "Custom",
+ characterShadows = "Real Character Shadows",
+ worldShadows = "World Shadows",
+ shadowDistance = "Shadow Distance",
+ shadowResolution = "Shadow Resolution",
+ moonShadows = "Moon Shadows",
+ interiorLight = "Dynamic Interior Lighting",
+ interiorShadows = "Indoor Torch Shadows",
+ exteriorShadows = "Outdoor Torch Shadows",
+ torchSoft = "Torch Shadow Softness",
+ fireLight = "Fire Light Brightness",
+ fireFlicker = "Fire Flicker",
+ spellLights = "Spell Lights",
+ nightDarkness = "Night Darkness",
+ interiorDarkness = "Interior Darkness",
+ yards = "%d yd",
+ tips = {
+ LIGHTING_QUALITY =
+ "Sets character, world, indoor torch and outdoor torch shadows; dynamic "
+ .. "interior lighting; moon shadows; fire and spell brightness; fire flicker; "
+ .. "and night and interior darkness. Low, Medium and High also set shadow "
+ .. "resolution; Medium and High also set resident and moving torch shadow counts.\n\n"
+ .. "Off restores the original client's look: no synthesised fire lights, "
+ .. "night gain 1.0 and interior gain 1.0. All other settings, including "
+ .. "Shadow Distance and Torch Shadow Softness, stay as you set them. "
+ .. "Changing a preset member shows Custom when no preset matches.",
+ CHARACTER_SHADOWS =
+ "Renders real dynamic silhouettes for players, NPCs, creatures, and mounts, replacing "
+ .. "the legacy oval blob. Matches Wrath's character-only shadow tier. This uses "
+ .. "additional GPU time and memory.",
+ WORLD_SHADOWS =
+ "Casts real dynamic shadows from the static world (trees, buildings, and foliage) "
+ .. "following the sun, and switches off the baked terrain shadows they replace. "
+ .. "Independent of Real Character Shadows. This uses additional GPU time and memory.",
+ SHADOW_DISTANCE =
+ "How far realtime shadows are drawn, in yards. Higher covers more ground but spreads "
+ .. "the shadow map thinner (softer edges) and costs more. Applies on Apply.",
+ SHADOW_RESOLUTION =
+ "The edge of the sun's shadow map, in texels. Larger is sharper "
+ .. "everywhere the shadow distance reaches.\n\n"
+ .. "The cost is quadratic: 4096 draws four times the shadow pass of 2048 "
+ .. "and holds four times the depth texture.",
+ MOON_SHADOWS =
+ "How dark a shadow thrown by moonlight is allowed to get. A little keeps "
+ .. "the night from reading flat; a lot is stylised moonlight the original "
+ .. "client never had.\n\n"
+ .. "At 0 the moon casts nothing at all and the night renders exactly as "
+ .. "it did before this existed. It costs nothing by day.",
+ INTERIOR_LIGHT =
+ "Lights the inside of buildings from the lamps, candles and hearths that "
+ .. "actually stand in the room, instead of the lighting baked into the "
+ .. "walls.\n\n"
+ .. "Off restores the original client's baked interiors. On, it costs a "
+ .. "little GPU time per lit surface. Indoor Torch Shadows require it; "
+ .. "Outdoor Torch Shadows work independently.",
+ INTERIOR_SHADOWS =
+ "Lets the nearest indoor fires cast real shadows — a candle throws the "
+ .. "table's shadow across the floor.\n\n"
+ .. "Each promoted fire costs a shadow map of its own, which makes this "
+ .. "the most expensive row on the page. Needs Dynamic Interior Lighting.",
+ EXTERIOR_SHADOWS =
+ "The same for fires out in the open: campfires, braziers and lampposts "
+ .. "cast real shadows after dark.\n\n"
+ .. "It does nothing by day and does not require Dynamic Interior Lighting.",
+ TORCH_SOFT =
+ "How soft the edge of a torch shadow is where the object meets the "
+ .. "ground. Low is hard and crisp; high spreads the edge the way a real "
+ .. "flame does.\n\n"
+ .. "It costs no extra shadow maps — only a wider filter over the ones "
+ .. "already drawn.",
+ FIRE_LIGHT =
+ "How brightly campfires, torches, braziers and forges light the world "
+ .. "around them. These are lights benilla invents from the flame itself; "
+ .. "the original client's art carries none.\n\n"
+ .. "At 0 the invented fire lights are off and only the lights the artists "
+ .. "placed remain. Moving it costs nothing.",
+ FIRE_FLICKER =
+ "How strongly every flame's light breathes — candles quick and shallow, "
+ .. "bonfires slow and steady.\n\n"
+ .. "At 0 fires burn at a constant brightness, which is what they did "
+ .. "before this existed. It is computed on the processor and costs the "
+ .. "graphics card nothing.",
+ SPELL_LIGHTS =
+ "How brightly spells, missiles and impacts light the room around them — a "
+ .. "fireball's flash, an aura's glow.\n\n"
+ .. "Separate from Fire Light Brightness on purpose: calming a busy fight "
+ .. "should not dim the world's hearths. At 0 no spell throws light.",
+ NIGHT_DARKNESS =
+ "How dark the world gets outdoors after sunset. "
+ .. "1.0 = the original client night; lower = darker.\n\n"
+ .. "It never touches the fires — the point is a darker night around the "
+ .. "flame, not a dimmer flame. Inert while the sun is up, and free at any "
+ .. "setting.",
+ INTERIOR_DARKNESS =
+ "Scales interior fixture light and room ambient, including the light from "
+ .. "lamps and candles. Lower values make both dimmer.\n\n"
+ .. "1.0 leaves these inputs unchanged. Free at any setting.",
+ },
+ },
+ ruRU = {
+ page = "Расширенная графика",
+ lightingQuality = "Качество освещения",
+ qOff = "Выкл.",
+ qLow = "Низкое",
+ qMedium = "Среднее",
+ qHigh = "Высокое",
+ qCustom = "Своё",
+ characterShadows = "Настоящие тени персонажей",
+ worldShadows = "Тени мира",
+ shadowDistance = "Дальность теней",
+ shadowResolution = "Разрешение теней",
+ moonShadows = "Лунные тени",
+ interiorLight = "Динамическое освещение помещений",
+ interiorShadows = "Тени факелов в помещениях",
+ exteriorShadows = "Тени огней под открытым небом",
+ torchSoft = "Мягкость теней от огня",
+ fireLight = "Яркость огня",
+ fireFlicker = "Мерцание огня",
+ spellLights = "Свет от заклинаний",
+ nightDarkness = "Темнота ночи",
+ interiorDarkness = "Темнота помещений",
+ yards = "%d ярд.",
+ tips = {
+ LIGHTING_QUALITY =
+ "Задаёт тени персонажей, мира и огней внутри и снаружи помещений; "
+ .. "динамическое освещение помещений; лунные тени; яркость огня и заклинаний; "
+ .. "мерцание огня; темноту ночи и помещений. «Низкое», «Среднее» и «Высокое» "
+ .. "также задают разрешение теней; «Среднее» и «Высокое» — число карт теней "
+ .. "от огней и число огней с тенями движущихся объектов.\n\n"
+ .. "«Выкл.» возвращает вид оригинального клиента: без созданных из пламени "
+ .. "источников света, с коэффициентами яркости ночи и помещений 1.0. "
+ .. "Все остальные настройки, включая дальность теней и мягкость теней от огня, "
+ .. "сохраняют ваши значения. Изменение настройки набора показывает «Своё», "
+ .. "если ни один набор больше не совпадает.",
+ CHARACTER_SHADOWS =
+ "Рисует настоящие динамические силуэты игроков, НИП, существ и ездовых "
+ .. "животных вместо старого овального пятна. Соответствует уровню теней "
+ .. "персонажей из Wrath. Требует дополнительного времени и памяти "
+ .. "видеокарты.",
+ WORLD_SHADOWS =
+ "Заставляет статичный мир — деревья, здания и растительность — "
+ .. "отбрасывать настоящие динамические тени вслед за солнцем и отключает "
+ .. "запечённые тени ландшафта, которые они заменяют. Не зависит от "
+ .. "настоящих теней персонажей. Требует дополнительного времени и памяти "
+ .. "видеокарты.",
+ SHADOW_DISTANCE =
+ "Как далеко рисуются тени в реальном времени, в ярдах. Большее значение "
+ .. "покрывает больше земли, но растягивает карту теней тоньше (края "
+ .. "становятся мягче) и стоит дороже. Применяется по кнопке «Применить».",
+ SHADOW_RESOLUTION =
+ "Размер карты теней солнца в текселях. Чем больше, тем чётче тени на "
+ .. "всей дальности их прорисовки.\n\n"
+ .. "Стоимость растёт квадратично: 4096 — это вчетверо больше работы в "
+ .. "проходе теней и вчетверо больше видеопамяти под карту глубины, чем "
+ .. "2048.",
+ MOON_SHADOWS =
+ "Насколько тёмной может стать тень, отбрасываемая лунным светом. "
+ .. "Немного — и ночь перестаёт выглядеть плоской; много — это "
+ .. "стилизованная луна, какой в оригинальном клиенте не было.\n\n"
+ .. "При 0 луна не отбрасывает теней вовсе, и ночь выглядит так же, как "
+ .. "до появления этой возможности. Днём не стоит ничего.",
+ INTERIOR_LIGHT =
+ "Освещает помещения от ламп, свечей и очагов, которые действительно "
+ .. "стоят в комнате, а не от освещения, запечённого в стенах.\n\n"
+ .. "«Выкл.» возвращает запечённые интерьеры оригинального клиента. "
+ .. "Включённый режим стоит немного времени видеокарты на каждую "
+ .. "освещённую поверхность. Тени от огней внутри требуют этого режима; "
+ .. "тени от огней снаружи работают независимо.",
+ INTERIOR_SHADOWS =
+ "Позволяет ближайшим огням в помещении отбрасывать настоящие тени: "
+ .. "свеча кладёт тень стола на пол.\n\n"
+ .. "Каждый такой огонь стоит собственной карты теней, поэтому это самая "
+ .. "дорогая строка на странице. Требует динамического освещения "
+ .. "помещений.",
+ EXTERIOR_SHADOWS =
+ "То же для огней под открытым небом: костры, жаровни и фонари "
+ .. "отбрасывают настоящие тени после наступления темноты.\n\n"
+ .. "Днём не действует и не требует динамического освещения помещений.",
+ TORCH_SOFT =
+ "Насколько мягок край тени от огня там, где предмет касается пола. "
+ .. "Низкое значение — резкий край, высокое — размытый, как от "
+ .. "настоящего пламени.\n\n"
+ .. "Дополнительных карт теней не требует: это лишь более широкий фильтр "
+ .. "по уже нарисованным.",
+ FIRE_LIGHT =
+ "Насколько ярко костры, факелы, жаровни и горны освещают всё вокруг. "
+ .. "Эти источники benilla создаёт из самого пламени — в оригинальной "
+ .. "графике их нет.\n\n"
+ .. "При 0 придуманные огни выключены и остаются лишь источники, "
+ .. "расставленные художниками. Изменение значения ничего не стоит.",
+ FIRE_FLICKER =
+ "Насколько сильно «дышит» свет каждого пламени: свечи — часто и слабо, "
+ .. "костры — медленно и ровно.\n\n"
+ .. "При 0 огни горят с постоянной яркостью, как было до появления этой "
+ .. "возможности. Считается на процессоре и ничего не стоит видеокарте.",
+ SPELL_LIGHTS =
+ "Насколько ярко заклинания, снаряды и попадания освещают всё вокруг: "
+ .. "вспышка огненного шара, свечение ауры.\n\n"
+ .. "Отделено от яркости огня намеренно: успокоить шумный бой не должно "
+ .. "значить пригасить очаги во всём мире. При 0 заклинания не дают "
+ .. "света.",
+ NIGHT_DARKNESS =
+ "Насколько темно становится под открытым небом после заката. "
+ .. "1.0 — ночь оригинального клиента; ниже — темнее.\n\n"
+ .. "Огней не касается: смысл в том, чтобы ночь вокруг пламени была "
+ .. "темнее, а не само пламя тусклее. Пока солнце в небе, не действует, и "
+ .. "ничего не стоит при любом значении.",
+ INTERIOR_DARKNESS =
+ "Масштабирует свет светильников и фоновое освещение помещений, включая "
+ .. "свет ламп и свечей. Меньшие значения ослабляют и то и другое.\n\n"
+ .. "1.0 оставляет эти составляющие без изменений. Ничего не стоит при любом значении.",
+ },
+ },
+ }
- BENILLA_TOOLTIP_WORLD_SHADOWS =
- "Casts real dynamic shadows from the static world (trees, buildings, and foliage) "
- .. "following the sun, and switches off the baked terrain shadows they replace. "
- .. "Independent of Real Character Shadows. This uses additional GPU time and memory."
+ -- The selected column. `or` the English one so an unbuilt locale is a fallback rather
+ -- than fifteen rows of `nil` labels — 1.12's own GlobalStrings do the same thing by
+ -- shipping one file per locale with every key present.
+ BENILLA_ADVGFX = BENILLA_ADVGFX_STRINGS[GetLocale()] or BENILLA_ADVGFX_STRINGS.enUS
- BENILLA_TOOLTIP_SHADOW_DISTANCE =
- "How far realtime shadows are drawn, in yards. Higher covers more ground but spreads "
- .. "the shadow map thinner (softer edges) and costs more. Applies on Okay/Apply."
+ -- A row's description resolves by GLOBAL NAME at hover time (`OptionsRow_ShowTooltip`), so
+ -- the selected column's copy is published under the key the row carries. Same `BENILLA_`
+ -- prefix the four rows above use, and for the same reason: the reference's
+ -- `OPTION_TOOLTIP_` namespace stays the reference's.
+ for key, text in pairs(BENILLA_ADVGFX.tips) do
+ setglobal("BENILLA_TOOLTIP_" .. key, text)
+ end
OPTIONS_PAGE_ROWS = {
-- Seven rows became sixteen at 2180, and the nine that arrived were not new
@@ -851,9 +1100,25 @@
"RowUiScale", "RowFarclip", "RowWorldDetail", "RowBrightness",
"RowWeatherDensity",
"RowDisplayMode", "RowVerticalSync",
- "RowCharacterShadows", "RowWorldShadows", "RowShadowDistance",
"RowRenderScale",
},
+ -- MONKEY (advanced graphics): the dynamic light + shadow system's own page. Order is
+ -- the preset first (it writes the rest), then the SUN lanes, then the ROOM lane and
+ -- its torches, then the invented lights, then the two darkness dials — coarse to
+ -- fine, and each group's own master above the rows it gates.
+ --
+ -- Real Character Shadows, World Shadows and Shadow Distance MOVED here from Graphics,
+ -- carrying their CVars and their descriptions unchanged: they belong with the rest of
+ -- the rig they share, and the Graphics page had become the place where a player found
+ -- three of eighteen shadow settings and no hint that the other fifteen existed.
+ AdvancedGraphics = {
+ "RowLightingQuality",
+ "RowCharacterShadows", "RowWorldShadows", "RowShadowDistance",
+ "RowShadowResolution", "RowMoonShadows",
+ "RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows", "RowTorchSoft",
+ "RowFireLight", "RowFireFlicker", "RowSpellLights",
+ "RowNightDarkness", "RowInteriorDarkness",
+ },
-- Player Guild Names (2180) rides as a CHILD of Player Names, which is the
-- reference's own relationship twice over: its CheckButton22 hangs off 21 with a +26
-- x indent, and UIOptionsFrame_UpdateDependencies l.702-708 greys 22 (and 23) from
@@ -1030,7 +1295,7 @@
-- The write. A global's consumers are Lua and do not poll, so a uvar row also runs its
-- `applyFunc` — the reference's own per-uvar side effects (SIMPLE_CHAT → FCF_Set_*,
-- HIDE_PARTY_INTERFACE → RaidOptionsFrame_UpdatePartyFrames, the combat-text family →
- -- CombatText_UpdateDisplayedMessages), moved from its batched Okay to our live write
+ -- CombatText_UpdateDisplayedMessages), moved from its batched confirmation to our live write
-- because this window commits on the click (0961's grammar), not on a Save button.
-- `cvarEvent` is a CVar row's counterpart to a global row's `applyFunc`, and it is the
-- reference's own third SetCVar argument (decision 1140): the token the engine hands back
@@ -1368,11 +1633,33 @@
return string.format("%d%%",
math.floor((value - row.minv) / (row.maxv - row.minv) * 100 + 0.5))
end
+ -- MONKEY (advanced graphics): travel, READ BACKWARDS — the same share of the groove,
+ -- counted from the right-hand end. The two darkness dials are the case: their stored
+ -- unit is a GAIN (`nightGain`, `interiorGain`), so the darkest setting is the SMALLEST
+ -- number and sits at the left of the groove, while the row is named for the darkness
+ -- rather than the gain. Without this the readout would count UP as the world got
+ -- LIGHTER under a label that says "Darkness", which is exactly backwards; with it the
+ -- label, the thumb and the number all agree that further left is darker. The store
+ -- keeps the gain, unchanged — this is a readout, not a unit.
+ if row.fmt == "darkness" and row.minv and row.maxv and row.maxv > row.minv then
+ return string.format("%d%%",
+ math.floor((row.maxv - value) / (row.maxv - row.minv) * 100 + 0.5))
+ end
-- A row whose stored value is a FACTOR but whose meaning is a distance: show the
-- distance (decision 1140 — cameraDistanceMaxFactor over the 15 yd base). The number
-- the player is choosing is "how far out the wheel stops", not "times 1.4".
if row.fmt == "yards" then
- return string.format("%d yd", math.floor(value * 15 + 0.5))
+ return string.format(BENILLA_ADVGFX.yards, math.floor(value * 15 + 0.5))
+ end
+ -- MONKEY (advanced graphics): a row whose stored value ALREADY IS a distance in yards
+ -- — the realtime-shadow range. Its own format, because the one above it is a
+ -- CONVERSION and not a unit: `shadowDistance` rode `"yards"` from the day it was
+ -- seated, so an 80-yard cascade printed "1200 yd" and nothing caught it (no test read
+ -- that readout, and 1200 is not an absurd-looking number for a view distance). The
+ -- two formats differ by exactly the factor's 15 yd base, which is why they cannot be
+ -- one.
+ if row.fmt == "yd" then
+ return string.format(BENILLA_ADVGFX.yards, math.floor(value + 0.5))
end
return string.format("%d%%", math.floor(value * 100 + 0.5))
end
@@ -1533,6 +1820,47 @@
OptionsApply_Update()
end
+ -- ── MONKEY (advanced graphics): the page that has to WATCH its settings ─────────────
+ -- Every other page in this window owns its rows outright: nothing but a click on the page
+ -- moves the CVars behind it while it is open, so reading them once on select is the whole
+ -- contract. The lighting rows are not like that, and deliberately so — they are the live
+ -- A/B knobs the whole system was tuned with, and there are three other ways to move one
+ -- with this page on screen: `/console set nightGain 1` in chat, the native lighting panel
+ -- in a dev build, and — the one that matters to a player — the Lighting Quality row above,
+ -- which does not write its members here at all. It writes ONE CVar; the host's preset
+ -- table answers by writing the other ten, and those land a frame or two later.
+ --
+ -- So the page polls. A cheap signature of its own rows' values, four times a second, and a
+ -- refresh only when that string actually moves — which is never, on a page nobody is
+ -- touching. The alternative (an event) does not exist: a host-side CVar write reaches the
+ -- interface as a mirror update, not as `CVAR_UPDATE`, which the reference only ever fires
+ -- for a script's own `SetCVar` with a token.
+ --
+ -- Safe under a drag, and not by luck: the value the refresh would write back is the value
+ -- the drag just wrote, `SetValue` fires nothing when it does not change the value, and the
+ -- `refreshing` guard stops a write even if it did. A staged (deferred) edit is safe too —
+ -- `OptionsPage_Refresh` shows `row.pending` ahead of the stored value.
+ BENILLA_ADVGFX_POLL = 0.25
+
+ function AdvGfx_Signature()
+ local out = ""
+ for _, rkey in ipairs(OPTIONS_PAGE_ROWS.AdvancedGraphics) do
+ local row = getglobal("BenillaOptionsFrameContainerBodyAdvancedGraphics" .. rkey)
+ out = out .. "|" .. (OptionsRow_Get(row) or "")
+ end
+ return out
+ end
+
+ function AdvGfx_OnUpdate(page, elapsed)
+ page.since = (page.since or 0) + (elapsed or 0)
+ if page.since < BENILLA_ADVGFX_POLL then return end
+ page.since = 0
+ local sig = AdvGfx_Signature()
+ if sig == page.sig then return end
+ page.sig = sig
+ OptionsPage_Refresh("AdvancedGraphics")
+ end
+
-- Per-page dependency rules. Audio's is the 1.12 rule, verbatim: Enable All Sound off
-- greys Enable Ambience — and only it, of our rows
-- (SoundOptionsFrame_UpdateDependencies disables buttons 2 and 4, Ambience and Error
@@ -1575,6 +1903,27 @@
OptionsRow_SetEnabled(BenillaOptionsFrameContainerBodyCombatRowPetDamage, dmg)
return
end
+ if key == "AdvancedGraphics" then
+ -- MONKEY (advanced graphics): the page's own dependencies — the same shape the
+ -- reference's own rules have (a master's own switch greys what it gates), read
+ -- off the CVar docs rather than invented: `interiorShadows` "needs
+ -- interiorLight", the two shadow-rig rows size a cascade that is not rendered
+ -- with both sun lanes off, and the torch softness is the PCF radius on maps
+ -- neither torch lane is drawing. The CVars keep their values throughout — this
+ -- is a dependency, not a write, so turning a master back on finds its children
+ -- exactly where they were left.
+ local base = "BenillaOptionsFrameContainerBodyAdvancedGraphics"
+ local sun = GetCVar("characterShadows") ~= "0"
+ or GetCVar("worldShadows") ~= "0"
+ OptionsRow_SetEnabled(getglobal(base .. "RowShadowDistance"), sun)
+ OptionsRow_SetEnabled(getglobal(base .. "RowShadowResolution"), sun)
+ local interior = GetCVar("interiorLight") ~= "0"
+ OptionsRow_SetEnabled(getglobal(base .. "RowInteriorShadows"), interior)
+ local torch = (interior and GetCVar("interiorShadows") ~= "0")
+ or GetCVar("exteriorShadows") ~= "0"
+ OptionsRow_SetEnabled(getglobal(base .. "RowTorchSoft"), torch)
+ return
+ end
if key == "Controls" then
-- 1.12's own rule (l.715-719): the Auto-Follow Speed slider is dead while the
-- following style is Never, because there is no follow to set a speed for. The
@@ -2346,8 +2695,16 @@
OptionsCategoryRow_OnLoad(self, "Graphics", "Graphics")
-
+
+
+ OptionsCategoryRow_OnLoad(self, "AdvancedGraphics", BENILLA_ADVGFX.page)
+
+
+
OptionsCategoryRow_OnLoad(self, "Audio", "Audio")
@@ -3099,48 +3456,18 @@
"OPTION_TOOLTIP_VERTICAL_SYNC")
-
+
+
-
- OptionsRow_OnLoad(self, "characterShadows", "Real Character Shadows",
- "BENILLA_TOOLTIP_CHARACTER_SHADOWS")
-
-
-
-
-
-
-
-
- OptionsRow_OnLoad(self, "worldShadows", "World Shadows",
- "BENILLA_TOOLTIP_WORLD_SHADOWS")
-
-
-
-
-
-
-
-
- -- Realtime-shadow render distance (yd): the cascade
- -- range + caster reach. 40-200, matching the CVar's
- -- own clamp (SHADOW_DISTANCE_RANGE). DEFERRED to
- -- Apply so a drag doesn't re-fit the cascade + recollect
- -- casters on every tick (one rebuild on commit instead).
- OptionsRow_OnLoad(self, "shadowDistance", "Shadow Distance",
- "BENILLA_TOOLTIP_SHADOW_DISTANCE")
- OptionsSliderRow_Setup(self, 40, 200, 10, "yards")
- self.deferred = 1
-
-
-
-
-
-
-
-- Render Scale (decision 1639) -- benilla's own
-- row, no 1.12 counterpart: the reference renders
@@ -3199,6 +3526,288 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -- The preset ladder. This row writes ONE CVar and
+ -- nothing else: the host's own table
+ -- (`cvars::LIGHTING_PRESETS`) answers the write by
+ -- setting the member rows, and re-derives this
+ -- row's value from them every frame — which is why
+ -- Custom is an entry the capsule can SHOW but
+ -- picking it writes nothing anyone keeps.
+ --
+ -- The table is deliberately NOT here. Three other
+ -- ways reach the same ladder (the console, the
+ -- native lighting panel, a hand-edited
+ -- config.toml), and a copy carried by this window
+ -- would be a copy the other three cannot see.
+ -- The page's OnUpdate poll is what brings the
+ -- member rows' new values back onto the screen.
+ OptionsRow_OnLoad(self, "lightingQuality",
+ BENILLA_ADVGFX.lightingQuality,
+ "BENILLA_TOOLTIP_LIGHTING_QUALITY")
+ OptionsDropdownRow_Setup(self, {
+ { text = BENILLA_ADVGFX.qOff, value = "Off" },
+ { text = BENILLA_ADVGFX.qLow, value = "Low" },
+ { text = BENILLA_ADVGFX.qMedium, value = "Medium" },
+ { text = BENILLA_ADVGFX.qHigh, value = "High" },
+ { text = BENILLA_ADVGFX.qCustom, value = "Custom" },
+ })
+
+
+
+
+
+
+
+
+ OptionsRow_OnLoad(self, "characterShadows",
+ BENILLA_ADVGFX.characterShadows,
+ "BENILLA_TOOLTIP_CHARACTER_SHADOWS")
+
+
+
+
+
+
+
+
+ OptionsRow_OnLoad(self, "worldShadows",
+ BENILLA_ADVGFX.worldShadows,
+ "BENILLA_TOOLTIP_WORLD_SHADOWS")
+
+
+
+
+
+
+
+
+ -- Realtime-shadow render distance (yd): the cascade
+ -- range + caster reach. 40-200, matching the CVar's
+ -- own clamp (SHADOW_DISTANCE_RANGE). DEFERRED to
+ -- Apply so a drag doesn't re-fit the cascade + recollect
+ -- casters on every tick (one rebuild on commit instead).
+ OptionsRow_OnLoad(self, "shadowDistance",
+ BENILLA_ADVGFX.shadowDistance,
+ "BENILLA_TOOLTIP_SHADOW_DISTANCE")
+ -- `"yd"`, not `"yards"`: the value is already a
+ -- distance in yards, where the format beside it
+ -- multiplies a FACTOR by the 15 yd camera base.
+ -- This row rode the wrong one from the day it was
+ -- seated and printed "1200 yd" for an 80 yd
+ -- cascade.
+ OptionsSliderRow_Setup(self, 40, 200, 10, "yd")
+ self.deferred = 1
+
+
+
+
+
+
+
+
+ -- The three stops the CVar's own snap accepts
+ -- (`shadow_core::SHADOW_MAP_SIZES`): a dropdown
+ -- rather than a slider because an off-ladder value
+ -- is not a weaker setting, it is one the renderer
+ -- rounds UP into a bigger and slower map than the
+ -- one that was asked for. The entry texts are the
+ -- numbers themselves — nothing to translate.
+ OptionsRow_OnLoad(self, "shadowMapSize",
+ BENILLA_ADVGFX.shadowResolution,
+ "BENILLA_TOOLTIP_SHADOW_RESOLUTION")
+ OptionsDropdownRow_Setup(self, {
+ { text = "1024", value = "1024" },
+ { text = "2048", value = "2048" },
+ { text = "4096", value = "4096" },
+ })
+
+
+
+
+
+
+
+
+ -- 0..1, the CVar's own clamp, and 0 is the FAITHFUL
+ -- null rather than a floor: every consumer guards
+ -- on it, so the left end of this groove is the
+ -- night the client rendered before the moon lane
+ -- existed.
+ OptionsRow_OnLoad(self, "moonShadowStrength",
+ BENILLA_ADVGFX.moonShadows,
+ "BENILLA_TOOLTIP_MOON_SHADOWS")
+ OptionsSliderRow_Setup(self, 0, 1, 0.05)
+
+
+
+
+
+
+
+
+ OptionsRow_OnLoad(self, "interiorLight",
+ BENILLA_ADVGFX.interiorLight,
+ "BENILLA_TOOLTIP_INTERIOR_LIGHT")
+
+
+
+
+
+
+
+
+ OptionsRow_OnLoad(self, "interiorShadows",
+ BENILLA_ADVGFX.interiorShadows,
+ "BENILLA_TOOLTIP_INTERIOR_SHADOWS")
+
+
+
+
+
+
+
+
+ OptionsRow_OnLoad(self, "exteriorShadows",
+ BENILLA_ADVGFX.exteriorShadows,
+ "BENILLA_TOOLTIP_EXTERIOR_SHADOWS")
+
+
+
+
+
+
+
+
+ -- 0.5..3, the CVar's own clamp verbatim: it is a
+ -- SCALE on the PCF tap radius at a contact, so the
+ -- readout is a percentage of the shipped radius
+ -- and 100% is what the lane was tuned at.
+ OptionsRow_OnLoad(self, "interiorShadowSoft",
+ BENILLA_ADVGFX.torchSoft,
+ "BENILLA_TOOLTIP_TORCH_SOFT")
+ OptionsSliderRow_Setup(self, 0.5, 3, 0.1)
+
+
+
+
+
+
+
+
+ -- Full 0..4 registry range, in five-percent steps.
+ OptionsRow_OnLoad(self, "fireLightGain",
+ BENILLA_ADVGFX.fireLight,
+ "BENILLA_TOOLTIP_FIRE_LIGHT")
+ OptionsSliderRow_Setup(self, 0, 4, 0.05)
+
+
+
+
+
+
+
+
+ -- 0..2, the CVar's own clamp verbatim: the
+ -- amplitudes are authored at 100%, and 200% is the
+ -- deliberate over-drive for judging the shape.
+ OptionsRow_OnLoad(self, "fireFlicker",
+ BENILLA_ADVGFX.fireFlicker,
+ "BENILLA_TOOLTIP_FIRE_FLICKER")
+ OptionsSliderRow_Setup(self, 0, 2, 0.05)
+
+
+
+
+
+
+
+
+ -- Full 0..4 registry range, in five-percent steps.
+ OptionsRow_OnLoad(self, "spellLightGain",
+ BENILLA_ADVGFX.spellLights,
+ "BENILLA_TOOLTIP_SPELL_LIGHTS")
+ OptionsSliderRow_Setup(self, 0, 4, 0.05)
+
+
+
+
+
+
+
+
+ -- 0.2..1.5, the CVar's own clamp verbatim: the
+ -- floor is 0.2 rather than 0 because a true 0 is
+ -- indistinguishable from a broken light pack, and
+ -- the "off" a player wants is the reference's own
+ -- night at gain 1.
+ --
+ -- The readout is `darkness` — travel read from the
+ -- right — because the stored unit is a GAIN while
+ -- the row is named for the darkness: left is
+ -- darker, and this is what makes the number say so
+ -- too. The store keeps the gain, untouched.
+ OptionsRow_OnLoad(self, "nightGain",
+ BENILLA_ADVGFX.nightDarkness,
+ "BENILLA_TOOLTIP_NIGHT_DARKNESS")
+ OptionsSliderRow_Setup(self, 0.2, 1.5, 0.05, "darkness")
+
+
+
+
+
+
+
+
+ -- The room lane's own dim dial, same clamp and same
+ -- backwards readout as the night one above it.
+ OptionsRow_OnLoad(self, "interiorGain",
+ BENILLA_ADVGFX.interiorDarkness,
+ "BENILLA_TOOLTIP_INTERIOR_DARKNESS")
+ OptionsSliderRow_Setup(self, 0.2, 1.5, 0.05, "darkness")
+
+
+
+
+
+ AdvGfx_OnUpdate(this, arg1)
+
+
+
+
+ self.categoryKey = "AdvancedGraphics"
+ getglobal(self:GetName() .. "Label"):SetText(BENILLA_ADVGFX.page)
+
+
self.categoryKey = "Combat"
diff --git a/crates/benilla-app/src/blob_shadow.rs b/crates/benilla-app/src/blob_shadow.rs
index cacc5293..27936f21 100644
--- a/crates/benilla-app/src/blob_shadow.rs
+++ b/crates/benilla-app/src/blob_shadow.rs
@@ -99,9 +99,12 @@ use bevy::prelude::*;
use crate::creature_anim::AnimData;
use crate::net::{Embodied, NetEntity};
use benilla_world::decal::{DecalFrame, WorldDecal};
-use benilla_world::lighting::WowLighting;
+// 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::particles::buffer::{begin_effect_frame, EffectVertex};
-use benilla_world::schedule::WorldStage;
use benilla_world::view::WorldCamera;
use benilla_world::wmo_portal::UnitWmoRoom;
@@ -158,14 +161,16 @@ impl Plugin for BlobShadowPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, setup_shadow_assets)
.add_systems(
- Update,
+ PostUpdate,
(sync_shadows, update_shadows)
.chain()
- // After net motion + input: the decal follows this frame's unit transforms.
- .after(WorldStage::Input),
+ // MONKEY (moon shadows): after ALL Update work (resolve + strength bridge)
+ // and this frame's rig verdict. Push only after the blob has consumed it.
+ .after(ShadowSet::Rig)
+ .after(bevy::transform::TransformSystems::Propagate)
+ .before(push_shadows),
)
- // The stream push: after the frame's stream clear (the caches were rebuilt in
- // `Update`, so this is a pure copy).
+ // The stream push: after both the frame's stream clear and the blob cache rebuild.
.add_systems(PostUpdate, push_shadows.after(begin_effect_frame));
}
}
@@ -241,10 +246,12 @@ fn update_shadows(
shadow_assets: Option>,
images: Res>,
decals: WorldDecal,
- // MONKEY (night blob): the live celestial sun, for the realtime lane's day strength — the SAME
- // `celestial_dir` height `global_light` packs the shader's night fade from, so the oval's
- // crossfade and the realtime shadow's fade are two halves of one number.
- lighting: Res,
+ // MONKEY (moon shadows): SAME acknowledged weight as the packed receivers, plus actual
+ // per-unit caster readiness. The cvar alone cannot promise a silhouette exists.
+ handover: Res,
+ // Missing core/character plugins are a valid no-silhouette state: keep the oval there too.
+ shadow_frame: Option>,
+ ready: Option>,
// …and the camera, for the realtime lane's EDGE fade (its cascade ends at `shadowDistance`).
cam_pos: Query<&GlobalTransform, With>,
// The unit's render alpha, asked of the unit — see the alpha block below.
@@ -279,24 +286,9 @@ fn update_shadows(
// report, answerable from a log instead of a debugger.
mut census_at: Local,
) {
- // `characterShadows 1`: the realtime shadow-map path owns OUTDOOR unit shadows — the per-unit
- // gate in the loop below fades the oval out for outdoor units so they don't wear both, while
- // INDOOR units keep it at full strength (the realtime sun shadow is gated out of interiors, so
- // an indoor unit would otherwise have no ground shadow at all). (This keys on the CHARACTER
- // lane, not the world lane: the oval is a character's shadow, so `worldShadows` alone must
- // leave it be.) With the cvar off — the shipped default — that gate is a single false branch
- // and this is the reference path.
- //
- // MONKEY (night blob): it is a FADE, not the hard hide it used to be. The realtime lane's own
- // strength is `night · (1 − edge_fade)` (`shadow_hook.wgsl::realtime_shadow`), and `night` is
- // `sun_shadow_strength(celestial sun height)` — exactly ZERO once the sun is under the horizon.
- // So with the old hide, an outdoor unit between dusk and dawn had NO shadow of any kind: no
- // realtime cast (faded to nothing) and no oval (hidden by the cvar). The reference client draws
- // the oval all night, which is what the complement below restores. The oval is NOT replaced by
- // a moon-lit realtime cast: benilla's rig aims only at the celestial SUN (`shadow_sun_travel`),
- // the moon direction is `pub(super)` inside `benilla-world`'s day/night module and never
- // reaches a shadow lane, and the packed `night` scalar would zero a moon cast anyway.
- let sun_strength = sun_shadow_strength(lighting.celestial_dir().y);
+ // MONKEY (moon shadows): a time jump or pending aim must restore the oval in the SAME
+ // frame that the receiver goes fully lit. Do not independently reconstruct the clock weight.
+ let lane_strength = handover.weight.abs();
// No camera yet → treat every unit as beyond the cascade, i.e. keep its oval. Failing toward
// "has a shadow" is the safe direction for a lane whose bug is a missing shadow.
let camera_pos = cam_pos.iter().next().map(GlobalTransform::translation);
@@ -331,9 +323,12 @@ 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 {
+ 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(sun_strength, cam_dist, video.shadow_distance);
+ lane_w = blob_weight(lane_strength, cam_dist, video.shadow_distance);
if lane_w <= 1.0 / 255.0 {
hide(&mut key, &mut verts);
continue;
@@ -636,6 +631,7 @@ fn key_changed(a: &ShadowKey, b: &ShadowKey) -> bool {
/// `benilla_world::lighting::global_light` (private there; that copy is what gets packed into the
/// shader's `night` scalar). The pair is pinned by `oval_is_the_complement_of_the_realtime_lane`
/// below — if the curve is ever tuned, tune both or the handover gains a seam.
+#[cfg(test)]
fn sun_shadow_strength(sun_height: f32) -> f32 {
let t = (sun_height / 0.208).clamp(0.0, 1.0); // 0.208 ≈ sin(12°)
t * t * (3.0 - 2.0 * t)
@@ -714,6 +710,45 @@ mod tests {
assert!((prev - 1.0).abs() < 1e-6);
}
+ /// MONKEY (moon shadows): the oval yields to the NIGHT cast by exactly what that cast lands.
+ ///
+ /// The bug this forbids is the one the feature creates if the blob is left alone: a unit at
+ /// midnight now HAS a real silhouette shadow, and an unfaded oval under it is a second,
+ /// differently-shaped shadow in the same place. The complement law already handles that — what
+ /// has to hold is that the moon's weight enters it the same way the sun's does, and that
+ /// `moonShadowStrength 0` puts the full oval back, bit for bit.
+ ///
+ /// The sum is exact rather than approximate because the two weights are mutually exclusive
+ /// (`benilla_world::lighting::moon_shadow_weight`, swept over the whole game day by its own
+ /// test), so `sun + moon` IS the realtime lane's total strength at every instant.
+ #[test]
+ fn the_oval_yields_to_a_moon_cast_by_exactly_its_strength() {
+ // Midnight, close in: the sun casts nothing, the moon casts at the shipped 0.35.
+ 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");
+ let under_moon = blob_weight(night_sun + 0.35, 10.0, 70.0);
+ assert!(
+ (under_moon - 0.65).abs() < 1e-6,
+ "a 0.35 moon cast leaves 0.65 of the oval, not {under_moon}"
+ );
+ // …and the two together are the constant the day arm is: whatever the realtime lane lands,
+ // the oval lands the rest.
+ assert!((under_moon + 0.35 - 1.0).abs() < 1e-6);
+ // Past the cascade edge the moon cast resolves nothing, so the full oval returns even
+ // though the moon is up — the same edge behaviour the sun arm has.
+ assert_eq!(blob_weight(night_sun + 0.35, 80.0, 70.0), 1.0);
+ // The moonrise ramp reaches the oval monotonically: no pop as the weight lifts off zero.
+ 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}");
+ prev = w;
+ }
+ assert!((prev - 0.65).abs() < 1e-6);
+ }
+
/// The box law: clamp INTO ±5 pre-scale (a cap, not a floor), then scale.
#[test]
fn box_clamp_caps_pre_scale() {
diff --git a/crates/benilla-app/src/character_shadow.rs b/crates/benilla-app/src/character_shadow.rs
index 149dda4a..cc4cf4dd 100644
--- a/crates/benilla-app/src/character_shadow.rs
+++ b/crates/benilla-app/src/character_shadow.rs
@@ -7,6 +7,7 @@
//! exists — remove either lane and the other is untouched.
use bevy::pbr::MeshMaterial3d;
+use bevy::ecs::entity::EntityHashSet;
use bevy::prelude::*;
use benilla_assets::materials::WowModelMaterial;
@@ -26,10 +27,17 @@ pub(crate) struct CharacterShadowPlugin;
impl Plugin for CharacterShadowPlugin {
fn build(&self, app: &mut App) {
app.init_resource::()
+ .init_resource::()
.add_systems(Last, collect_character_shadows.in_set(ShadowSet::Lanes));
}
}
+/// MONKEY (moon shadows): roots/ancestors with triangles in the LAST completed caster build.
+/// Blobs consume this in PostUpdate, before this frame's Last rebuild: a newly uploaded proxy
+/// gets a render frame before its oval yields. Cadence skips retain the same geometry/verdict.
+#[derive(Resource, Default)]
+pub(crate) struct CharacterShadowReady(pub EntityHashSet);
+
/// The character lane's retained per-frame caster.
#[derive(Resource, Default)]
struct CharacterLane {
@@ -49,10 +57,13 @@ fn collect_character_shadows(
mut demand: ResMut,
frame: Res,
mut lane: ResMut,
+ mut ready: ResMut,
+ parents: Query<&ChildOf>,
mut commands: Commands,
mut meshes: ResMut>,
parts: Query<
(
+ Entity,
&PickMesh,
&ModelPart,
Option<&GlobalTransform>,
@@ -71,6 +82,7 @@ fn collect_character_shadows(
// Rig down or lane off → drop our caster so it stops casting.
if !(frame.active && on) {
+ ready.0.clear();
if let Some(entity) = lane.caster.take() {
commands.entity(entity).despawn();
}
@@ -81,6 +93,12 @@ fn collect_character_shadows(
lane.rate.reset();
return;
}
+ // MONKEY (moon shadows): keep the cache but suspend CPU skin/re-upload on feature-off nights.
+ if frame.suspended {
+ ready.0.clear();
+ lane.rate.reset();
+ return;
+ }
let Some(material) = frame.material.clone() else {
return;
};
@@ -114,6 +132,7 @@ fn collect_character_shadows(
if let Some(handle) = lane.mesh.clone() {
if let Some(mesh) = meshes.get_mut(&handle) {
let (mut positions, mut indices) = take_mesh_buffers(mesh);
+ let mut built_parts = EntityHashSet::default();
let (admitted, rejected) = collect_entity_geometry(
&parts,
&rigs,
@@ -125,7 +144,14 @@ fn collect_character_shadows(
frame.tall_reach,
&mut positions,
&mut indices,
+ Some(&mut built_parts),
);
+ ready.0.clear();
+ for part in built_parts {
+ ready.0.insert(part);
+ // The unit root owns its blob; mounts and equipment can be nested below it.
+ ready.0.extend(parents.iter_ancestors(part));
+ }
let tris = (indices.len() / 3) as u32;
restore_mesh_buffers(mesh, positions, indices);
if shadow_trace() {
diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs
index 0615c76b..8f2ce5d4 100644
--- a/crates/benilla-app/src/cvars.rs
+++ b/crates/benilla-app/src/cvars.rs
@@ -908,6 +908,24 @@ pub(crate) const REGISTERED: &[Registered] = &[
// `gxRestart = 1` does not apply (wgpu swaps the presentation interval live, so the box takes
// effect on click), and `$WOW_NOVSYNC=1` overrides it session-only, below.
same("gxVSync", "1").latched(),
+ // MONKEY (advanced graphics): the one row on the Advanced Graphics page that is not a knob —
+ // it is a NAME for a combination of the rows under it ([`LIGHTING_PRESETS`]). Writing a preset
+ // name writes that preset's members; writing anything else is corrected on the next frame by
+ // [`lighting_quality`], which re-DERIVES the name from the members those rows actually hold.
+ // So the stored value is never stale: it is either a preset every member agrees with, or
+ // `Custom`.
+ //
+ // A STRING row, not an int, so `config.toml` and `/console set lightingQuality Medium` both
+ // read as what they mean — and so the ladder can gain a rung without renumbering a player's
+ // saved value. `Row::numeric` is false for it (the default does not parse as a number), which
+ // is what lets the registry accept a non-numeric write at all; `realmList` is the precedent.
+ ours(
+ "lightingQuality",
+ "High",
+ "benilla's own: the preset ladder over the dynamic light + shadow rows — Off / Low / \
+ Medium / High, or Custom when the members match none of them; the reference has no \
+ realtime light or shadow system to preset",
+ ),
// Benilla's opt-in realtime shadow-map path, split into two INDEPENDENT lanes over one shared
// shadow rig (one sun / one map). `worldShadows` = the static world (trees, buildings, foliage)
// casts realtime shadows and baked MCSH terrain shadows switch off; `characterShadows` =
@@ -968,6 +986,17 @@ pub(crate) const REGISTERED: &[Registered] = &[
"1",
"benilla's own: multiplier on the shadow caster-collection reach, 0.25..2 (1 = unchanged)",
),
+ // MONKEY (moon shadows): how dark a MOON-shadowed fragment is allowed to get at night — the
+ // dial over the night lane's own shadow term (`benilla_world::lighting::MoonShadowStrength`,
+ // bridged by `dynamic_interior` beside the spell gain). `0` is the FAITHFUL null: every
+ // consumer guards on it, so a night at 0 renders as the build before the feature did — which
+ // is also what the Off and Low presets ask for.
+ ours(
+ "moonShadowStrength",
+ "0.35",
+ "benilla's own: how dark a moon-shadowed fragment gets at night, 0..1 (0 = no moon \
+ shadow, the reference's own night)",
+ ),
// MONKEY (dynamic interiors): WMO interiors + their props light from the room's LIVE fixtures
// instead of the MOCV bake / the baked prop probe (`static_gx.wgsl` `interior_room_light`;
// bridged by `dynamic_interior`). The three numeric knobs are live-tunable from chat —
@@ -1087,8 +1116,8 @@ pub(crate) const REGISTERED: &[Registered] = &[
// `build_light_data`, so `SetCVar` moves the whole world on the very next frame — which is how
// "20 % / 30 % darker" gets judged at all, and `1` on either is the restore.
//
- // Neither touches the fires: a point light keeps its brightness under both dials, because the
- // ask is for a darker night AROUND the flame, not a dimmer flame.
+ // Night gain leaves fire lights alone; interior gain also scales interior fixtures,
+ // including their candlelight. Outdoor fires retain their brightness under both dials.
ours(
"nightGain",
"0.45",
@@ -1647,12 +1676,22 @@ impl Cvars {
/// the minimap zoom, the camera views, the remembered character, the loading screen's tip
/// cursor. Mirrored into the VM, persisted, and observed like any other write.
pub(crate) fn set(&mut self, name: &str, value: &str) -> SetOutcome {
- self.write(name, value, false)
+ let outcome = self.write(name, value, false);
+ if name.eq_ignore_ascii_case("lightingQuality") {
+ apply_lighting_preset(self, value);
+ }
+ outcome
}
/// A write the VM's mirror already made — drained from its change queue.
pub(crate) fn set_from_vm(&mut self, name: &str, value: &str) -> SetOutcome {
- self.write(name, value, true)
+ let outcome = self.write(name, value, true);
+ // Apply at the write's position in the queue: a later member edit must win,
+ // including a re-selection of the currently displayed preset.
+ if name.eq_ignore_ascii_case("lightingQuality") {
+ apply_lighting_preset(self, value);
+ }
+ outcome
}
/// **The table follows a value the engine already applied** — a mirror, not a write: the
@@ -1917,6 +1956,165 @@ impl Plugin for CvarPlugin {
}
}
+// ─── MONKEY (advanced graphics): the lighting preset ladder ──────────────────────────────────
+
+/// **The one place the presets are written down** — the Advanced Graphics page's `lightingQuality`
+/// ladder, as (name, members) pairs in the order [`derive_lighting_quality`] tries them.
+///
+/// A preset is a NAME FOR A SET OF ROWS, not a knob: applying one writes each member through the
+/// ordinary registry path ([`Cvars::set`]), so every observer, clamp, bridge and save that a
+/// hand-typed `/console set` would run, runs. That is why this table lives beside the registry
+/// rather than in the options window's Lua: the page is one of four ways to reach it (the others
+/// being `/console`, the lighting debug panel, and a hand-edited `config.toml`), and a table
+/// carried by the window would be a table three of them cannot see.
+///
+/// **A preset names only the members it decides.** `Off` says nothing about `shadowMapSize` — with
+/// both shadow lanes off there is no map to size — and Low says nothing about the torch counts,
+/// which its `interiorShadows 0` has already made inert. Derivation matches on exactly the members
+/// a preset lists, so a row nobody's preset mentions never pushes the ladder to `Custom`.
+///
+/// Order matters twice: the ladder reads Off → High for a human, and the first preset whose whole
+/// member list matches wins. The four are mutually exclusive on `characterShadows` /
+/// `worldShadows` / `exteriorShadows`, so no value can answer to two of them — a property
+/// `every_preset_derives_back_to_its_own_name` holds rather than a reader has to check.
+pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
+ // Everything this whole system invented, off — the client as it rendered before any of it
+ // existed. Not "cheap": OFF, so a machine that cannot afford the lanes can say so in one
+ // click, and so a bug report can be split from a taste report in one click too.
+ (
+ "Off",
+ &[
+ ("characterShadows", "0"),
+ ("worldShadows", "0"),
+ ("interiorLight", "0"),
+ ("interiorShadows", "0"),
+ ("exteriorShadows", "0"),
+ ("spellLightGain", "0"),
+ ("fireLightGain", "0"),
+ ("nightGain", "1.0"),
+ ("interiorGain", "1.0"),
+ ("moonShadowStrength", "0"),
+ ("fireFlicker", "0"),
+ ],
+ ),
+ // Character silhouettes and lit rooms, and nothing that costs a second shadow pass: no world
+ // lane, no torch maps indoors or out, no moon term, and the smallest sun map on the ladder.
+ (
+ "Low",
+ &[
+ ("characterShadows", "1"),
+ ("worldShadows", "0"),
+ ("shadowMapSize", "1024"),
+ ("interiorLight", "1"),
+ ("interiorShadows", "0"),
+ ("exteriorShadows", "0"),
+ ("spellLightGain", "1"),
+ ("fireLightGain", "1"),
+ ("nightGain", "0.45"),
+ ("interiorGain", "0.5"),
+ ("moonShadowStrength", "0"),
+ ("fireFlicker", "1"),
+ ],
+ ),
+ // Both sun lanes, indoor torch shadows at half the residency, and the moon term — but no
+ // OUTDOOR torch shadows, which are the one lane whose cost is paid in the open world where the
+ // sun lanes are already paying.
+ (
+ "Medium",
+ &[
+ ("characterShadows", "1"),
+ ("worldShadows", "1"),
+ ("shadowMapSize", "2048"),
+ ("interiorLight", "1"),
+ ("interiorShadows", "1"),
+ ("interiorShadowCasters", "6"),
+ ("interiorShadowDynamic", "2"),
+ ("exteriorShadows", "0"),
+ ("spellLightGain", "1"),
+ ("fireLightGain", "1"),
+ ("nightGain", "0.45"),
+ ("interiorGain", "0.5"),
+ ("moonShadowStrength", "0.35"),
+ ("fireFlicker", "1"),
+ ],
+ ),
+ // **High IS the shipped default**, member for member — which is a property, not a coincidence:
+ // `the_high_preset_is_the_registered_defaults` welds the two, so a fresh `benilla-config`
+ // reads "High" on this row rather than "Custom".
+ (
+ "High",
+ &[
+ ("characterShadows", "1"),
+ ("worldShadows", "1"),
+ ("shadowMapSize", "2048"),
+ ("interiorLight", "1"),
+ ("interiorShadows", "1"),
+ ("interiorShadowCasters", "12"),
+ ("interiorShadowDynamic", "4"),
+ ("exteriorShadows", "1"),
+ ("spellLightGain", "1"),
+ ("fireLightGain", "1"),
+ ("nightGain", "0.45"),
+ ("interiorGain", "0.5"),
+ ("moonShadowStrength", "0.35"),
+ ("fireFlicker", "1"),
+ ],
+ ),
+];
+
+/// What the ladder shows when the members match no preset. Not a preset: selecting it writes
+/// nothing, and the next derivation puts back whatever the members really say.
+pub(crate) const LIGHTING_CUSTOM: &str = "Custom";
+
+/// Two registry values are the SAME setting when they are the same number — `"0"`, `"0.00"` and
+/// `"0.0"` all mean a lane that is off, and a preset whose member reads `"1"` must not be called
+/// `Custom` because a slider wrote `"1.00"`. Falls back to a case-insensitive string compare for
+/// the rows that hold words.
+fn same_value(a: &str, b: &str) -> bool {
+ match (a.trim().parse::(), b.trim().parse::()) {
+ (Ok(x), Ok(y)) => (x - y).abs() < 1e-4,
+ _ => a.eq_ignore_ascii_case(b),
+ }
+}
+
+/// **The name the members currently spell** — the first preset every one of whose members matches
+/// the live registry, or [`LIGHTING_CUSTOM`]. This is the only thing `lightingQuality` is ever
+/// allowed to say, which is what keeps the row from going stale behind a `/console` write.
+pub(crate) fn derive_lighting_quality(cvars: &Cvars) -> &'static str {
+ for (name, members) in LIGHTING_PRESETS {
+ let matched = members
+ .iter()
+ .all(|(k, v)| cvars.get(k).is_some_and(|live| same_value(live, v)));
+ if matched {
+ return name;
+ }
+ }
+ LIGHTING_CUSTOM
+}
+
+/// Write one preset's members. Ordinary registry writes: observers, clamps and the save all run.
+/// Unknown names (including `Custom`) write nothing and say so by answering `false`.
+pub(crate) fn apply_lighting_preset(cvars: &mut Cvars, name: &str) -> bool {
+ let Some((_, members)) = LIGHTING_PRESETS
+ .iter()
+ .find(|(n, _)| n.eq_ignore_ascii_case(name))
+ else {
+ return false;
+ };
+ for (k, v) in *members {
+ cvars.set(k, v);
+ }
+ true
+}
+
+/// Derive after all ordered writes, before the mirror/observer flush. File loading bypasses
+/// `set`, so saved members remain authoritative even when the saved preset name disagrees.
+fn lighting_quality(cvars: &mut Cvars) {
+ let derived = derive_lighting_quality(cvars);
+ // A derived label is a mirror, never another request to apply a preset.
+ cvars.mirror("lightingQuality", derived);
+}
+
/// **What the environment took for this session, and what it set it to** — read off the knobs
/// the env levers already seeded (`RenderScale::default()` reads `$WOW_RENDER_SCALE`, and so
/// on), so the registry answers `GetCVar` with the value the client is actually running at.
@@ -2119,6 +2317,7 @@ pub(crate) fn sync_cvars(
}
}
let Some(mut script) = script else {
+ lighting_quality(&mut cvars);
// Nothing to mirror into; a later VM is seeded from the table, which carries it all.
if cvars.has_outbox() {
cvars.take_outbox();
@@ -2146,6 +2345,7 @@ pub(crate) fn sync_cvars(
cvars.set_from_vm(&name, &value);
}
}
+ lighting_quality(&mut cvars);
if seeded.claim(&script) {
// The file's unclaimed entries go in FIRST (decision 1291): an addon's `RegisterCVar`
// later starts its key at the saved value. Then the table itself, at its live values.
@@ -2208,8 +2408,12 @@ pub(crate) fn sync_cvars(
});
}
if cvars.has_outbox() {
- for (name, value) in cvars.take_outbox() {
- script.set_cvar_host(&name, &value);
+ for (name, _) in cvars.take_outbox() {
+ // A preset may have queued this before a newer VM member edit. Publish
+ // the final registry value, never that older queued snapshot.
+ if let Some(value) = cvars.get(&name) {
+ script.set_cvar_host(&name, value);
+ }
}
}
if cvars.has_events() {
@@ -2249,6 +2453,7 @@ pub(crate) fn fold_dying_vm_cvars(world: &mut World) {
for (name, value) in changes {
cvars.set_from_vm(&name, &value);
}
+ lighting_quality(&mut cvars);
if cvars.has_outbox() {
cvars.take_outbox(); // the VM this was for is going away
}
@@ -2660,7 +2865,7 @@ mod tests {
// a row, forgot its weld" fail HERE: the length check below is the gate.
let shadows = VideoConfig::default();
let flag = |b: bool| if b { 1.0 } else { 0.0 };
- let lighting: [(&str, f32); 29] = [
+ let lighting: [(&str, f32); 30] = [
// The two sun lanes and the cascade they share.
("worldShadows", flag(shadows.world_shadows)),
("characterShadows", flag(shadows.character_shadows)),
@@ -2671,6 +2876,8 @@ mod tests {
("characterShadowRate", shadows.character_shadow_rate as f32),
("worldShadowRate", shadows.world_shadow_rate as f32),
("shadowCasterReach", shadows.shadow_caster_reach),
+ // MONKEY (moon shadows): the night lane's own shadow darkness.
+ ("moonShadowStrength", shadows.moon_shadow_strength),
// MONKEY (dynamic interiors): the room lane's switch and its light law.
("interiorLight", flag(shadows.interior_light)),
("interiorAmbient", shadows.interior_ambient),
@@ -2710,9 +2917,9 @@ mod tests {
assert_eq!(d[name], want, "{name}: registered default left the knob");
}
// …and the census IS the row set. A name here that nothing registers would weld against a
- // row the client does not have; the length is the other half — 29 rows, 29 welds.
+ // row the client does not have; the length is the other half — 30 rows, 30 welds.
let welded: std::collections::BTreeSet<&str> = lighting.iter().map(|(n, _)| *n).collect();
- assert_eq!(welded.len(), 29, "the lighting lane welds 29 distinct rows");
+ assert_eq!(welded.len(), 30, "the lighting lane welds 30 distinct rows");
for name in &welded {
assert!(
REGISTERED.iter().any(|r| r.name == *name),
@@ -2727,6 +2934,10 @@ mod tests {
// candle-lit surfaces moving under +10 % — only true at this number.
assert_eq!(d["interiorBakeFloor"], 0.12);
assert_eq!(d["spellLightGain"], 1.0, "the spell lane ships neutral");
+ // The 31st lighting row, `lightingQuality`, is deliberately NOT in that census: it is the
+ // only one with no `VideoConfig` knob to weld to, because it is a NAME for the rows above
+ // rather than a knob of its own. Its own weld is `the_high_preset_is_the_registered_
+ // defaults` — a fresh config must read "High", not "Custom".
// ── end MONKEY (lighting) ─────────────────────────────────────────────────────────────
// The pane half-rate (1444) welds to the portrait knob's shipped default.
assert_eq!(d["boothHalfRate"] != 0.0, PaneRate::default().half);
@@ -2736,6 +2947,205 @@ mod tests {
assert_eq!(d["renderScale"], 1.0);
}
+ // ── MONKEY (advanced graphics): the preset ladder ─────────────────────────────────────────
+
+ /// A registry holding nothing but the registered defaults — the state a fresh
+ /// `benilla-config` boots into, which is what every claim below is measured against.
+ fn fresh_registry() -> Cvars {
+ Cvars::default()
+ }
+
+ /// **The round trip, for every rung**: apply a preset, derive, and get that preset's own name
+ /// back. This is the property the whole ladder rests on — without it the dropdown would show
+ /// `Custom` the instant after the player picked something, which is the exact failure the
+ /// "derived, never stored stale" rule exists to prevent.
+ #[test]
+ fn every_preset_derives_back_to_its_own_name() {
+ for (from, _) in LIGHTING_PRESETS {
+ for (name, _) in LIGHTING_PRESETS {
+ let mut cvars = fresh_registry();
+ apply_lighting_preset(&mut cvars, from);
+ cvars.set("shadowDistance", "120");
+ cvars.set("interiorShadowSoft", "2.5");
+ assert!(apply_lighting_preset(&mut cvars, name), "{name}: applied");
+ assert_eq!(derive_lighting_quality(&cvars), *name, "{from} -> {name}");
+ assert_eq!(cvars.get("shadowDistance"), Some("120"));
+ assert_eq!(cvars.get("interiorShadowSoft"), Some("2.5"));
+ for member in ["fireLightGain", "nightGain", "interiorGain"] {
+ let expected = if *name == "Off" {
+ if member == "fireLightGain" { "0" } else { "1.0" }
+ } else {
+ cvars.default_of(member).unwrap()
+ };
+ assert!(same_value(cvars.get(member).unwrap(), expected), "{name}/{member}");
+ }
+ }
+ }
+ }
+
+ /// **High is the shipped default, member for member.** A fresh config must read "High" on this
+ /// row rather than "Custom" — otherwise the very first thing a player sees on the page is a
+ /// word that says their settings are a hand-made combination when they have touched nothing.
+ #[test]
+ fn the_high_preset_is_the_registered_defaults() {
+ let cvars = fresh_registry();
+ assert_eq!(derive_lighting_quality(&cvars), "High");
+ assert_eq!(
+ cvars.get("lightingQuality"),
+ Some("High"),
+ "the registered default agrees with what a fresh registry derives"
+ );
+ // …and the table is not merely CONSISTENT with the defaults, it IS them: a member whose
+ // registered default moved without its preset entry moving would still derive "High"
+ // above (both sides move together), so the weld is checked against the row's own default.
+ let (_, members) = LIGHTING_PRESETS
+ .iter()
+ .find(|(n, _)| *n == "High")
+ .expect("the High rung");
+ for (k, v) in *members {
+ assert!(
+ same_value(cvars.default_of(k).expect("a registered member"), v),
+ "{k}: the High preset says {v}, the registry's default says {:?}",
+ cvars.default_of(k)
+ );
+ }
+ }
+
+ /// **One member off the rung is `Custom`** — for every rung, and for every member of it, which
+ /// is the half that keeps the derivation from being a lucky match on a favourite row.
+ #[test]
+ fn one_changed_member_derives_custom() {
+ for (name, members) in LIGHTING_PRESETS {
+ for (k, v) in *members {
+ let mut cvars = fresh_registry();
+ apply_lighting_preset(&mut cvars, name);
+ // Somewhere else on the row's own scale: the flags flip, the numbers move by one.
+ let moved = match v.trim().parse::() {
+ Ok(x) if x == 0.0 => "1".to_string(),
+ Ok(x) if x == 1.0 => "0".to_string(),
+ Ok(x) => (x + 1.0).to_string(),
+ Err(_) => format!("{v}x"),
+ };
+ assert_eq!(cvars.set(k, &moved), SetOutcome::Changed, "{name}/{k}");
+ assert_eq!(
+ derive_lighting_quality(&cvars),
+ LIGHTING_CUSTOM,
+ "{name}: {k} moved to {moved} and the ladder still says {name}"
+ );
+ }
+ }
+ }
+
+ /// A value that says the same NUMBER is the same setting: the options window's sliders write
+ /// `"1"`, a hand-edited config may say `"1.0"`, and `%.2f` formatting elsewhere says `"1.00"`.
+ /// A ladder that compared strings would call every one of those `Custom`.
+ #[test]
+ fn the_ladder_compares_numbers_not_spellings() {
+ let mut cvars = fresh_registry();
+ apply_lighting_preset(&mut cvars, "High");
+ for spelling in ["1.0", "1.00", "1"] {
+ cvars.set("spellLightGain", spelling);
+ assert_eq!(derive_lighting_quality(&cvars), "High", "{spelling}");
+ }
+ cvars.set("moonShadowStrength", "0.350");
+ assert_eq!(derive_lighting_quality(&cvars), "High");
+ }
+
+ /// `Custom` is not a rung: picking it writes nothing, so the members — and therefore the name
+ /// the next derivation lands on — are exactly where they were.
+ #[test]
+ fn picking_custom_writes_nothing() {
+ let mut cvars = fresh_registry();
+ apply_lighting_preset(&mut cvars, "Medium");
+ assert!(!apply_lighting_preset(&mut cvars, LIGHTING_CUSTOM));
+ assert!(!apply_lighting_preset(&mut cvars, "Ultra"));
+ assert_eq!(derive_lighting_quality(&cvars), "Medium");
+ }
+
+ /// Every member every rung names is a REGISTERED row — a typo'd member would otherwise make
+ /// its whole preset underivable (a `cvars.get` that answers `None` never matches), and the
+ /// only symptom would be a dropdown stuck on `Custom`.
+ #[test]
+ fn every_preset_member_is_registered_and_inside_its_observers_clamp() {
+ let mut app = cvar_app();
+ for (name, members) in LIGHTING_PRESETS {
+ for (k, value) in *members {
+ assert!(
+ REGISTERED.iter().any(|r| r.name == *k),
+ "{name}: member {k} is not registered"
+ );
+ // Drive the real setter: a clamp, integer conversion or map-size snap
+ // must leave every preset value unchanged.
+ apply(&mut app, k, "-1");
+ apply(&mut app, k, value);
+ let video = res::(&app);
+ let applied = match *k {
+ "characterShadows" => video.character_shadows as u32 as f32,
+ "worldShadows" => video.world_shadows as u32 as f32,
+ "interiorLight" => video.interior_light as u32 as f32,
+ "interiorShadows" => video.interior_shadows as u32 as f32,
+ "exteriorShadows" => video.exterior_shadows as u32 as f32,
+ "shadowMapSize" => video.shadow_map_size as f32,
+ "interiorShadowCasters" => video.interior_shadow_casters as f32,
+ "interiorShadowDynamic" => video.interior_shadow_dynamic as f32,
+ "spellLightGain" => video.spell_light_gain,
+ "fireLightGain" => video.fire_light_gain,
+ "moonShadowStrength" => video.moon_shadow_strength,
+ "fireFlicker" => video.fire_flicker,
+ "nightGain" => video.night_gain,
+ "interiorGain" => video.interior_gain,
+ _ => panic!("add the observer readback for {k}"),
+ };
+ assert_eq!(applied, value.parse::().unwrap(), "{name}/{k}");
+ }
+ }
+ }
+
+ #[test]
+ fn preset_then_member_edit_survives_sync_observers_and_later_flushes() {
+ for seeded in [false, true] {
+ for flush_preset_first in [false, true] {
+ let mut app = cvar_app();
+ 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();
+ 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();
+ for _ in 0..3 {
+ app.world_mut().run_schedule(Update);
+ let cvars = res::(&app);
+ assert_eq!(cvars.get("shadowMapSize"), Some("4096"));
+ assert_eq!(cvars.get("lightingQuality"), Some(LIGHTING_CUSTOM));
+ 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));
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn fire_and_spell_gains_above_two_reach_the_video_observer() {
+ let mut app = cvar_app();
+ for value in ["3.25", "4"] {
+ apply(&mut app, "fireLightGain", value);
+ apply(&mut app, "spellLightGain", value);
+ let expected = value.parse::().unwrap();
+ assert_eq!(res::(&app).fire_light_gain, expected);
+ assert_eq!(res::(&app).spell_light_gain, expected);
+ }
+ }
+
/// **Every arm, through the registry and its observers.** The old central `apply_to_knobs`
/// match is gone (2303); each arm lives beside the knob it writes, and this drives the whole
/// set through a real `App` — a host write, the observers firing synchronously — so a knob
diff --git a/crates/benilla-app/src/debug_panel/lighting_controls.rs b/crates/benilla-app/src/debug_panel/lighting_controls.rs
index 9776689d..579af128 100644
--- a/crates/benilla-app/src/debug_panel/lighting_controls.rs
+++ b/crates/benilla-app/src/debug_panel/lighting_controls.rs
@@ -43,6 +43,7 @@ macro_rules! knob {
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."),
@@ -89,6 +90,10 @@ const GROUPS: &[(&str, &[Knob])] = &[
]),
("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."),
@@ -147,8 +152,29 @@ pub(super) fn section(ui: &mut egui::Ui, video: &VideoConfig, script: Option<&mu
ui.weak("Lighting controls are available once the UI session is ready.");
return;
};
+ // MONKEY (advanced graphics): the `lightingQuality` ladder — the same row the player reaches
+ // on the Advanced Graphics page, so this panel and that window cannot show different answers.
+ // The rungs are `cvars::LIGHTING_PRESETS`, never a second table here; the CURRENT name is read
+ // 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("Presets");
+ 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
+ .selectable_label(current.eq_ignore_ascii_case(name), *name)
+ .clicked()
+ {
+ script.set_cvar_engine("lightingQuality", name);
+ }
+ }
+ if current.eq_ignore_ascii_case(crate::cvars::LIGHTING_CUSTOM) {
+ ui.weak(crate::cvars::LIGHTING_CUSTOM);
+ }
+ });
+ ui.horizontal(|ui| {
+ ui.label("Interior presets");
for (label, values) in [
// MONKEY (bake floor): the fourth value is `interiorBakeFloor`, and it rides the
// presets because it is a room BRIGHTNESS INPUT exactly like the three beside it.
diff --git a/crates/benilla-app/src/dynamic_interior.rs b/crates/benilla-app/src/dynamic_interior.rs
index 621924ef..e838b44f 100644
--- a/crates/benilla-app/src/dynamic_interior.rs
+++ b/crates/benilla-app/src/dynamic_interior.rs
@@ -16,7 +16,13 @@
//!
//! MONKEY (spellLightGain): and `spellLightGain` → [`benilla_world::lighting::SpellLightGain`],
//! the third of the same shape.
-use benilla_world::lighting::{DynamicInteriors, FireLightGain, SpellLightGain};
+//!
+//! MONKEY (moon shadows): and `moonShadowStrength` →
+//! [`benilla_world::lighting::MoonShadowStrength`], the fourth. Same shape, same guard, same
+//! liveness requirement — and `0` is that lane's faithful null the way `fireLightGain 0` is the
+//! synthesised-fire lane's kill switch. It is what the Advanced Graphics page's Moon Shadows row
+//! and the Off/Low presets reach.
+use benilla_world::lighting::{DynamicInteriors, FireLightGain, MoonShadowStrength, SpellLightGain};
use bevy::prelude::*;
use crate::video::VideoConfig;
@@ -46,6 +52,9 @@ fn bridge(
// an interior knob at all: a fireball down a street takes it exactly as one down a corridor
// does, and `FireLightGain` beside it already stands as the precedent for that.
mut spell: ResMut,
+ // MONKEY (moon shadows): the night lane's shadow darkness, on the same bridge and for the same
+ // reasons as the two gains above it — one live `f32`, one benilla-world resource, one guard.
+ mut moon: ResMut,
) {
if fire.0 != video.fire_light_gain {
fire.0 = video.fire_light_gain;
@@ -53,6 +62,9 @@ fn bridge(
if spell.0 != video.spell_light_gain {
spell.0 = video.spell_light_gain;
}
+ if moon.0 != video.moon_shadow_strength {
+ moon.0 = video.moon_shadow_strength;
+ }
let want = DynamicInteriors {
enabled: video.interior_light,
ambient: video.interior_ambient,
diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs
index bb264ee9..8fe30a91 100644
--- a/crates/benilla-app/src/entities.rs
+++ b/crates/benilla-app/src/entities.rs
@@ -51,6 +51,9 @@ use carried_light::spawn_carried_lights;
// firework throws, hung on the effect's own root so the effect's death reaps it. Used by the kit,
// missile and dest-anchored lanes.
use carried_light::spawn_spell_light;
+// MONKEY (area spell light): and the same file's PERSISTENT GROUND spawner — one light per
+// DynamicObject area effect, hung on the anchor so the server's destroy is what ends it.
+use carried_light::spawn_area_spell_light;
// MONKEY (carried light stability): `torch_shadow` needs the settle verdict to refuse a MOVING
// carried light a cube-shadow slot.
pub(crate) use carried_light::CarriedLightMotion;
diff --git a/crates/benilla-app/src/entities/carried_light.rs b/crates/benilla-app/src/entities/carried_light.rs
index daf059d4..59d0dd3a 100644
--- a/crates/benilla-app/src/entities/carried_light.rs
+++ b/crates/benilla-app/src/entities/carried_light.rs
@@ -38,7 +38,11 @@ use benilla_world::wmo_portal::{WmoGroupVis, WmoPortalInstance, WmoRoom};
// MONKEY (spell light): the effect-side lifecycle this file's spawn helper stamps on — the
// envelope, the budget and the kill switch all live beside the rest of the effect lifecycle
// (`spell_fx::lifecycle`), because that is what a spell light's lifetime IS.
-use super::spell_fx::{spell_lights_enabled, SpellLight, SpellLightMode, SPELL_BURST_SPAN};
+// MONKEY (area spell light): `AreaSpellLight` too — the persistent ground lane's marker, stamped
+// here beside the rest of a spell light's tags.
+use super::spell_fx::{
+ spell_lights_enabled, AreaSpellLight, SpellLight, SpellLightMode, SPELL_BURST_SPAN,
+};
/// Spawn a `PointLight` child for each **casting** (`type==1`, not visibility-gated dark) M2 light of
/// an entity's model.
@@ -127,8 +131,12 @@ pub(super) fn spawn_carried_lights(
commands,
parent,
local,
- l,
- fx,
+ SpellLightSource {
+ color: l.def.diffuse_color,
+ intensity: l.def.diffuse_intensity,
+ kind: fx.kind,
+ onset: fx.onset,
+ },
// A GameObject that carries a spell light IS a one-shot: a firework shell, a
// trigger's effect model. Nothing on this lane holds.
SpellLightMode::Burst {
@@ -210,7 +218,125 @@ pub(super) fn spawn_spell_light(
let Some((l, fx)) = lights.iter().find_map(|l| l.spell.map(|fx| (l, fx))) else {
return false;
};
- spawn_spell_light_child(commands, root, l.def.position, l, fx, mode).is_some()
+ spawn_spell_light_child(
+ commands,
+ root,
+ l.def.position,
+ SpellLightSource {
+ color: l.def.diffuse_color,
+ intensity: l.def.diffuse_intensity,
+ kind: fx.kind,
+ onset: fx.onset,
+ },
+ mode,
+ )
+ .is_some()
+}
+
+/// MONKEY (area spell light): the ONE light a persistent ground effect throws, hung on the
+/// DynamicObject anchor itself so it lives and dies with the area object.
+///
+/// `kind` is the area lane's own verdict ([`benilla_formats::area_light_kind`] — the model path,
+/// then the spell's school, then the model's hue), and it is handed in rather than read back off
+/// the model because **the model routinely has nothing to read**: Flamestrike's burning patch and
+/// Explosive Trap's are flat animated decals with zero particle emitters, so
+/// `synthesize_spell_light` returns `None` for exactly the effects this feature is about. Where the
+/// model DOES carry a synthesised light we take its colour, intensity and onset (the artist's own
+/// ramp beats any default); where it does not we light it from the school's hue at the area
+/// fallback rung.
+///
+/// The light hangs [`AREA_LIGHT_LIFT`](benilla_formats::AREA_LIGHT_LIFT) above the emitter's own
+/// point — a dynobj sits ON the ground, and the faithful `1/(0.7d + 0.03d²)` falloff at `d → 0`
+/// would blow out the terrain under the centre while the rim of the same patch got nothing.
+///
+/// `radius` is the wire `DYNAMICOBJECT_RADIUS`: it sizes the interior pool
+/// ([`benilla_formats::area_reach`], not the shared intensity bucket — an area effect's footprint
+/// is stated on the wire and guessing it from particle size would be a worse answer than the one we
+/// were handed) and it is the impact dedupe's test radius.
+pub(super) fn spawn_area_spell_light(
+ commands: &mut Commands,
+ lights: &[benilla_assets::ModelLight],
+ kind: benilla_formats::SpellLightKind,
+ anchor: Entity,
+ radius: f32,
+) -> bool {
+ if !kind.lights() {
+ return false;
+ }
+ // The model's own synthesised light, if it has one — its `SpellLightInfo` is deliberately NOT
+ // read: the school is the area lane's (handed in above, school column included) and the onset
+ // is a fact about an emitter's FIRST pass, i.e. a fuse, on an object that loops until the
+ // server destroys it. The ground catches when the object appears.
+ let synth = lights.iter().find(|l| l.spell.is_some());
+ let (local, source) = match synth {
+ Some(l) => (
+ l.def.position,
+ SpellLightSource {
+ color: benilla_formats::area_color(kind, Some(l.def.diffuse_color)),
+ intensity: l.def.diffuse_intensity,
+ kind,
+ onset: 0.0,
+ },
+ ),
+ // The decal-only half: no emitter to read, so the school's own hue at the area rung, hung
+ // over the object's origin (its model has no emitter position either).
+ None => (
+ [0.0, 0.0, 0.0],
+ SpellLightSource {
+ color: benilla_formats::area_color(kind, None),
+ intensity: benilla_formats::AREA_FALLBACK_INTENSITY,
+ kind,
+ onset: 0.0,
+ },
+ ),
+ };
+ let lifted = [
+ local[0],
+ local[1],
+ local[2] + benilla_formats::AREA_LIGHT_LIFT,
+ ];
+ // The breathing phase: seeded from the anchor's bits so two overlapping patches swell out of
+ // step, and stable for the light's whole life (it is baked into the mode, never re-rolled).
+ let phase = (anchor.to_bits() % 1000) as f32 * 0.006_283_2;
+ let Some(glow) = spawn_spell_light_child(
+ commands,
+ anchor,
+ lifted,
+ source,
+ SpellLightMode::Area { phase },
+ ) else {
+ return false;
+ };
+ let reach = benilla_formats::area_reach(radius);
+ commands.entity(glow).insert((
+ AreaSpellLight { radius },
+ // The pool's own size, stated rather than bucketed — see [`benilla_formats::area_reach`].
+ benilla_world::lighting::LightReach(reach),
+ ));
+ if benilla_assets::trace::enabled() {
+ // Beside the shared `spell light` line the helper already wrote: the two numbers only an
+ // area light has, so "did the patch light, and how big was its pool" is answered from the
+ // trace instead of by eye.
+ benilla_assets::trace::line(
+ "fx",
+ &format!("area light e={glow} radius={radius:.1} reach={reach:.1}"),
+ );
+ }
+ true
+}
+
+/// What one spell light is made of, as its spawn site knows it — the synthesised model light's
+/// numbers, or (MONKEY (area spell light)) the area lane's school-derived stand-in for a model that
+/// carries none.
+struct SpellLightSource {
+ /// Linear RGB, hue preserved.
+ color: [f32; 3],
+ /// The authored `diffuse_intensity` the [`point_light`] recipe scales.
+ intensity: f32,
+ /// The school, for the trace and any future per-school gain.
+ kind: benilla_formats::SpellLightKind,
+ /// Seconds from the instance's birth before the light comes up (a firework's fuse).
+ onset: f32,
}
/// The shared body of the two spawn sites (the effect lanes' [`spawn_spell_light`] and the
@@ -244,14 +370,13 @@ fn spawn_spell_light_child(
commands: &mut Commands,
parent: Entity,
local: [f32; 3],
- l: &benilla_assets::ModelLight,
- fx: benilla_assets::SpellLightInfo,
+ fx: SpellLightSource,
mode: SpellLightMode,
) -> Option {
if !spell_lights_enabled() {
return None;
}
- let lit = point_light(l.def.diffuse_color, l.def.diffuse_intensity);
+ let lit = point_light(fx.color, fx.intensity);
// Born DARK and raised by the envelope: the onset is real time (a firework's fuse), and a
// light that showed its full strength on its first frame and only then ramped would flash
// once before every effect it belongs to.
diff --git a/crates/benilla-app/src/entities/dest_fx.rs b/crates/benilla-app/src/entities/dest_fx.rs
index 4051a0a2..78fe1f59 100644
--- a/crates/benilla-app/src/entities/dest_fx.rs
+++ b/crates/benilla-app/src/entities/dest_fx.rs
@@ -101,6 +101,31 @@ impl GroundFx {
}
}
+/// MONKEY (area spell light): what an armed DynamicObject anchor knows about the light its area
+/// visual should throw, stamped at arm time and read once the model finishes building.
+///
+/// It exists because the two halves of the answer arrive at different moments, in different
+/// systems. `radius` (the wire AoE) and `school` (the spell's `Spell.dbc` column) are only in hand
+/// at [`arm_ground_effects`], where the create's fields and the spell catalog are; the model's own
+/// verdict — whether its emitters synthesised a light, and in what hue — is only in hand at
+/// [`attach_ground_fx_models`], however many frames later the M2 finishes building. Carrying the
+/// first pair forward on the anchor is cheaper and far less fragile than re-resolving the spell id
+/// there.
+///
+/// Its PRESENCE is load-bearing too: it marks the instance as a dynobj AREA visual, so the looping
+/// branch of the attach takes the area verdict *including its refusals*. Without that a frost
+/// area's visual A would fall through to the impact-flash light this lane used to spawn for it, and
+/// Blizzard would light after all.
+#[derive(Component)]
+pub(super) struct AreaLightPlan {
+ /// `DYNAMICOBJECT_RADIUS` — the effect's real footprint (the shard emitter spreads over exactly
+ /// it), which sizes the pool and is the impact dedupe's test radius.
+ radius: f32,
+ /// The spell's `Spell.dbc` **School** index — the locale-proof half of the classification
+ /// ([`benilla_formats::area_light_kind`]).
+ school: u32,
+}
+
/// The shard emitter riding a DynamicObject anchor (visual B — the ref's `AUBlizzardObject`).
/// Dies with the anchor (emission stops at destroy); its spawned shards are free entities and
/// finish on their own — the ref's emitter tail.
@@ -164,6 +189,14 @@ pub(super) fn arm_ground_effects(
.dynamicobject_position()
.map(|(_, f)| f)
.unwrap_or(0.0);
+ // MONKEY (area spell light): stamp the two facts only this system holds, for the attach to
+ // finish the verdict with. Unconditional — a plan whose school ends up dark is exactly how
+ // Blizzard's visual A is told to throw NO light (see [`AreaLightPlan`]).
+ let radius = store.0.dynamicobject_radius().unwrap_or(0.0);
+ commands.entity(anchor).insert(AreaLightPlan {
+ radius,
+ school: spells.catalog.get(spell_id).map_or(0, |d| d.school),
+ });
// Visual A — the object's own model, gated on field 11, Z-rotated by FACING. A child of
// the anchor: the destroy pop takes it exactly like the ref's scene-node teardown.
if stages.area_gate != 0 && stages.area_effect != 0 {
@@ -189,7 +222,7 @@ pub(super) fn arm_ground_effects(
ensure_model(&mut fx, &asset_server, &path);
commands.entity(anchor).insert(ShardEmitter {
path,
- radius: store.0.dynamicobject_radius().unwrap_or(0.0),
+ radius,
rate: proc.params[1],
accum: 0.0,
rng: 0x9e3779b97f4a7c15 ^ anchor.to_bits(),
@@ -264,13 +297,49 @@ pub(super) fn tick_shard_emitters(
}
}
+/// MONKEY (area spell light): how far ABOVE OR BELOW a live area pool's own height an impact may
+/// still be deduped against it (yd).
+///
+/// The footprint test itself is HORIZONTAL, because that is what `DYNAMICOBJECT_RADIUS` describes —
+/// a disc of ground. A vertical window is needed anyway so the disc does not become an infinite
+/// column: a Rain of Fire on the bridge above must not silence the impacts under it. Generous
+/// enough to cover the lift the pool hangs at plus a shard landing on a slope, tight enough to be a
+/// storey.
+const AREA_DEDUPE_HEIGHT: f32 = 10.0;
+
+/// Is `at` (Bevy world space) inside a live area pool's footprint? See [`AREA_DEDUPE_HEIGHT`].
+fn inside_a_live_area(
+ at: Vec3,
+ areas: &Query<(&GlobalTransform, &super::spell_fx::AreaSpellLight)>,
+) -> bool {
+ areas.iter().any(|(gt, area)| {
+ let d = at - gt.translation();
+ d.y.abs() <= AREA_DEDUPE_HEIGHT && d.x * d.x + d.z * d.z <= area.radius * area.radius
+ })
+}
+
/// Attach model parts to pending instances whose M2 finished building (the missile pattern —
/// free world models, ground-anchored so authored flat quads decal to the terrain), start the
/// one-shot clocks, and run both reapers (one-shot expiry; the loop-repeat override).
+#[allow(clippy::type_complexity)]
pub(super) fn attach_ground_fx_models(
mut commands: Commands,
time: Res,
- mut instances: Query<(Entity, &mut GroundFx, Option<&mut AnimationPlayer>)>,
+ // MONKEY (area spell light): `ChildOf` joins a VISUAL A instance to the DynamicObject anchor
+ // that owns it — where the plan is, and the entity the light must hang on so it dies with the
+ // area object. `Transform` is the instance's world point for a FREE one (a shard, a dest
+ // one-shot), which is the dedupe's input.
+ mut instances: Query<(
+ Entity,
+ &mut GroundFx,
+ Option<&mut AnimationPlayer>,
+ Option<&ChildOf>,
+ &Transform,
+ )>,
+ // MONKEY (area spell light): the arm-time half of the area verdict, read through the anchor.
+ plans: Query<&AreaLightPlan>,
+ // MONKEY (area spell light): every live area pool, for the impact dedupe.
+ areas: Query<(&GlobalTransform, &super::spell_fx::AreaSpellLight)>,
fx: Option>,
asset_server: Res,
mut wow_materials: ResMut>,
@@ -282,7 +351,7 @@ pub(super) fn attach_ground_fx_models(
) {
let Some(mut fx) = fx else { return };
let now = time.elapsed_secs();
- for (entity, mut inst, player) in &mut instances {
+ for (entity, mut inst, player, parent, at) in &mut instances {
if !inst.spawned {
ensure_model(&mut fx, &asset_server, &inst.path);
let Some(dm) = fx.models.get(&inst.path) else {
@@ -313,22 +382,59 @@ pub(super) fn attach_ground_fx_models(
continue; // model still building — attach on a later pass
}
inst.spawned = true;
+ // MONKEY (area spell light): a VISUAL A on an armed anchor is a persistent ground
+ // effect, and takes the AREA lane instead of the flash below — one light for the
+ // DynamicObject's whole life, hung on the ANCHOR (not on this instance) so the
+ // server's destroy is what ends it. `area_light_kind` finishes the verdict the plan
+ // started, and its refusals are honoured: when it says dark, this instance gets no
+ // light at all rather than falling through to the flash.
+ let plan = parent.map(|c| c.parent()).and_then(|anchor| {
+ plans
+ .get(anchor)
+ .ok()
+ .map(|plan| (anchor, plan))
+ .filter(|_| inst.looping)
+ });
+ if let Some((anchor, plan)) = plan {
+ let kind = benilla_formats::area_light_kind(
+ &inst.path,
+ 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,
+ );
+ 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
// light must not hold — a Fire Nova lights the ground it lands on and is gone. The
// burst span is the model's own first-sequence duration where it authors one (so the
// light fades with the effect rather than on a guess), the shared default otherwise;
// a LOOPING plant (a persistent ground aura) takes the default too, because "as long
// as the aura lasts" is exactly what a burst must not be.
- let span = dm
- .first_seq_span
- .filter(|_| !inst.looping)
- .unwrap_or(super::spell_fx::SPELL_BURST_SPAN);
- super::spawn_spell_light(
- &mut commands,
- &dm.lights,
- entity,
- super::spell_fx::SpellLightMode::Burst { span },
- );
+ //
+ // MONKEY (area spell light): …unless it lands INSIDE a live area pool, in which case it
+ // throws nothing. Rain of Fire emits a shard 5× a second from this very model, each of
+ // which used to spawn its own 0.6 s flash: 5 lights/s stacking on one patch of ground,
+ // strobing, and — before the budget's area exemption — evicting the standing pool they
+ // were landing in. The area light already IS the light of that ground; a second one per
+ // impact adds nothing but the flicker.
+ if !inside_a_live_area(at.translation, &areas) {
+ let span = dm
+ .first_seq_span
+ .filter(|_| !inst.looping)
+ .unwrap_or(super::spell_fx::SPELL_BURST_SPAN);
+ super::spawn_spell_light(
+ &mut commands,
+ &dm.lights,
+ entity,
+ super::spell_fx::SpellLightMode::Burst { span },
+ );
+ }
if !inst.looping {
// One pass of the first sequence — the kit pipeline's completion-callback
// stand-in (`spell_fx`'s span clock, same law).
diff --git a/crates/benilla-app/src/entities/spell_fx/lifecycle.rs b/crates/benilla-app/src/entities/spell_fx/lifecycle.rs
index ef9866ba..c27e54b3 100644
--- a/crates/benilla-app/src/entities/spell_fx/lifecycle.rs
+++ b/crates/benilla-app/src/entities/spell_fx/lifecycle.rs
@@ -250,6 +250,31 @@ pub(crate) const SPELL_BURST_SPAN: f32 = 0.6;
/// light that outlived the visual by a second would read as a stuck effect.
pub(crate) const SPELL_REAP_FADE: f32 = 0.25;
+/// MONKEY (area spell light): the AREA mode's ramp — longer than [`SPELL_LIGHT_RAMP`] on purpose.
+/// The 0.1 s ramp exists to keep a FLASH from appearing on one frame; a ground effect is a thing
+/// that CATCHES, and a patch of terrain that reaches full brightness in a tenth of a second reads
+/// as a light being switched on rather than as a fire spreading over it.
+pub(crate) const SPELL_AREA_RAMP: f32 = 0.25;
+
+/// MONKEY (area spell light): how fast an area light goes dark once the DynamicObject it belongs to
+/// is destroyed. Slower than the kit's [`SPELL_REAP_FADE`] because the thing ending is bigger — a
+/// 24 yd pool snapping off in a quarter second is a visible pop across a whole clearing — and still
+/// far inside the ~2 s the anchor's own teardown fade takes, so the light is long gone before the
+/// burning decal it lit has finished fading out.
+pub(crate) const SPELL_AREA_FADE: f32 = 0.4;
+
+/// MONKEY (area spell light): the area light's BREATHING — depth (fraction of base) and rate (Hz).
+///
+/// Explicitly NOT [`FlameFlicker`](benilla_world::lighting::FlameFlicker), which is the fire lane's
+/// fast noisy wobble: at 8 % and 0.7 Hz this is a slow swell, under the threshold where the eye
+/// reads it as the light *changing* rather than as the fire on the ground being alive. Something is
+/// needed, though — an 8-second Flamestrike patch whose light is a perfectly constant disc is the
+/// one thing that gives the whole effect away as a light source, and the model under it is
+/// animating the entire time.
+pub(crate) const SPELL_AREA_BREATH_DEPTH: f32 = 0.08;
+/// See [`SPELL_AREA_BREATH_DEPTH`].
+pub(crate) const SPELL_AREA_BREATH_HZ: f32 = 0.7;
+
/// How many spell lights may be alive at once, across every lane. The shared point-light table is
/// 512 rows packed from the 256 nearest sources every frame, and the whole feature is worth
/// nothing if a raid's worth of casts can evict a city's torches: 24 is a generous ceiling for
@@ -288,6 +313,53 @@ pub(crate) enum SpellLightMode {
/// An impact / destination effect / firework shell: full at onset, gone `span` seconds later.
/// The one shape whose *whole point* is that it does not persist.
Burst { span: f32 },
+ /// MONKEY (area spell light): a PERSISTENT GROUND EFFECT — one light for a whole
+ /// DynamicObject's life (Flamestrike's burning patch, Rain of Fire's storm, Consecration's
+ /// ground, a hunter's Flare). Up on the slower [`SPELL_AREA_RAMP`], held with a slow
+ /// [`SPELL_AREA_BREATH_DEPTH`] swell for as long as the area object stands, out over
+ /// [`SPELL_AREA_FADE`] when the server destroys it.
+ ///
+ /// It is a mode of its own rather than a [`Self::Kit`] with a different fade because the two
+ /// differ in every part: the ending is driven by a `DespawnFade` on a net entity instead of an
+ /// `FxDecay` on an effect instance, the hold is modulated, and the ceiling treats it as the one
+ /// spell light that must NOT be evicted (see [`budget_spell_lights`]) — a strobe of impact
+ /// flashes must never be able to push the standing pool out of the table.
+ ///
+ /// `phase` staggers the breathing per light (seeded from the anchor), so two overlapping
+ /// patches swell out of step rather than pulsing the clearing in unison.
+ Area { phase: f32 },
+}
+
+impl SpellLightMode {
+ /// Seconds this mode takes to come up.
+ fn ramp(self) -> f32 {
+ match self {
+ Self::Area { .. } => SPELL_AREA_RAMP,
+ _ => SPELL_LIGHT_RAMP,
+ }
+ }
+
+ /// Seconds this mode takes to go dark once its host has been reaped.
+ fn reap_fade(self) -> f32 {
+ match self {
+ Self::Area { .. } => SPELL_AREA_FADE,
+ _ => SPELL_REAP_FADE,
+ }
+ }
+}
+
+/// MONKEY (area spell light): the marker on a live area light, carrying the AoE footprint
+/// (`DYNAMICOBJECT_RADIUS`, yd) the wire gave it.
+///
+/// Two readers, and neither wants it on [`SpellLight`] itself:
+/// - the DEDUPE ([`super::super::dest_fx`]) — a repeating impact that lands inside a live area
+/// light's footprint spawns no light of its own, which is the whole of why Rain of Fire is one
+/// steady pool instead of five flashes a second;
+/// - the BUDGET below, which evicts area lights last.
+#[derive(Component)]
+pub(crate) struct AreaSpellLight {
+ /// The wire AoE radius (yd), horizontal — the dedupe's own test radius.
+ pub(crate) radius: f32,
}
/// MONKEY (spell light): one live spell light. A child of the effect root it belongs to, so the
@@ -331,18 +403,33 @@ impl SpellLight {
if t <= 0.0 {
return 0.0; // the flame this light stands for does not exist yet
}
- let up = (t / SPELL_LIGHT_RAMP).min(1.0);
+ let ramp = self.mode.ramp();
+ let up = (t / ramp).min(1.0);
let down = match self.mode {
// The fade starts where the ramp ended, so a burst's peak is a real (if brief) plateau
// rather than a single-frame spike that a low frame rate could skip entirely.
SpellLightMode::Burst { span } => {
- (1.0 - (t - SPELL_LIGHT_RAMP).max(0.0) / span.max(1e-3)).clamp(0.0, 1.0)
+ (1.0 - (t - ramp).max(0.0) / span.max(1e-3)).clamp(0.0, 1.0)
+ }
+ // MONKEY (area spell light): a hold that BREATHES. Deliberately allowed above 1.0 —
+ // the swell is centred on the calibrated base, so clamping the top half would turn a
+ // symmetric ±8 % into a one-sided dimming and leave every area light reading 4 %
+ // darker than the rung it was filed on.
+ SpellLightMode::Area { phase } => {
+ 1.0 + SPELL_AREA_BREATH_DEPTH
+ * (std::f32::consts::TAU * SPELL_AREA_BREATH_HZ * t + phase).sin()
}
SpellLightMode::Kit | SpellLightMode::Missile => 1.0,
};
- let reap = (1.0 - self.reaped / SPELL_REAP_FADE).clamp(0.0, 1.0);
+ let reap = (1.0 - self.reaped / self.mode.reap_fade()).clamp(0.0, 1.0);
up * down * reap
}
+
+ /// MONKEY (area spell light): has this light finished going dark after its host was reaped?
+ /// Only an AREA light asks — see the despawn note in [`advance_spell_lights`].
+ fn reap_complete(&self) -> bool {
+ self.reaped >= self.mode.reap_fade()
+ }
}
/// MONKEY (spell light): run every live spell light's envelope.
@@ -357,17 +444,40 @@ impl SpellLight {
/// ([`FxDecay`] on the effect root, written by the reap) must take its light down with it, and the
/// light's parent IS that root.
pub(crate) fn advance_spell_lights(
+ mut commands: Commands,
time: Res,
- mut lights: Query<(&mut SpellLight, &mut WorldPointLight, Option<&ChildOf>)>,
- reaped: Query>,
+ mut lights: Query<(
+ Entity,
+ &mut SpellLight,
+ &mut WorldPointLight,
+ Option<&ChildOf>,
+ )>,
+ // MONKEY (area spell light): the reap signal now has TWO sources, one per persistent mode.
+ // `FxDecay` is written on an effect INSTANCE by the kit lane's own reap; `DespawnFade` is
+ // written on a NET ENTITY by `SMSG_DESTROY_OBJECT` (`net::apply::objects`), which is the only
+ // notice a DynamicObject's end ever gives. That the anchor fades rather than popping is what
+ // makes an area fade possible at all: the child light outlives the server's destroy by the
+ // ~2 s the anchor's geometry teardown takes, and needs only 0.4 of them.
+ reaped: Query<(Has, Has)>,
) {
let dt = time.delta_secs();
- for (mut light, mut point, parent) in &mut lights {
+ for (entity, mut light, mut point, parent) in &mut lights {
light.age += dt;
- if light.mode == SpellLightMode::Kit
- && parent.is_some_and(|c| reaped.get(c.parent()).unwrap_or(false))
- {
+ let host = parent.and_then(|c| reaped.get(c.parent()).ok());
+ let ended = match light.mode {
+ SpellLightMode::Kit => host.is_some_and(|(decay, _)| decay),
+ SpellLightMode::Area { .. } => host.is_some_and(|(_, fade)| fade),
+ SpellLightMode::Missile | SpellLightMode::Burst { .. } => false,
+ };
+ if ended {
light.reaped += dt;
+ // An area light's anchor lingers for its own teardown fade, several times longer than
+ // the light's. Give the slot back the moment the pool is dark rather than holding one
+ // of the 24 for a light nobody can see — the kit lane has no such gap, because its
+ // instance despawns on the heels of its `FxDecay`.
+ if matches!(light.mode, SpellLightMode::Area { .. }) && light.reap_complete() {
+ commands.entity(entity).try_despawn();
+ }
}
let want = light.base * light.envelope();
// Write only on a real change: a held kit light sits at its plateau for the whole aura,
@@ -389,14 +499,23 @@ pub(crate) fn advance_spell_lights(
///
/// Despawning the light alone never disturbs its effect: the light is a leaf child of the effect
/// root and nothing reads back from it.
-pub(crate) fn budget_spell_lights(mut commands: Commands, lights: Query<(Entity, &SpellLight)>) {
+pub(crate) fn budget_spell_lights(
+ mut commands: Commands,
+ lights: Query<(Entity, &SpellLight, Has)>,
+) {
let live = lights.iter().count();
if live <= SPELL_LIGHTS_MAX {
return;
}
- let mut by_age: Vec<(Entity, f32)> = lights.iter().map(|(e, s)| (e, s.age)).collect();
- by_age.sort_by(|a, b| b.1.total_cmp(&a.1)); // oldest first
- for (light, _) in by_age.into_iter().take(live - SPELL_LIGHTS_MAX) {
+ let mut order: Vec<(Entity, bool, f32)> =
+ lights.iter().map(|(e, s, area)| (e, area, s.age)).collect();
+ // MONKEY (area spell light): AREA lights go LAST, whatever their age — and an area light is
+ // always among the oldest, because it is the only spell light that stands for seconds. Under
+ // pure age ordering a Rain of Fire's own impact flashes would evict the very pool they are
+ // landing in, i.e. the ceiling would delete the steady light and keep the strobe. Within each
+ // class the rule is unchanged: oldest first, for the reasons above.
+ order.sort_by(|a, b| a.1.cmp(&b.1).then(b.2.total_cmp(&a.2)));
+ for (light, _, _) in order.into_iter().take(live - SPELL_LIGHTS_MAX) {
commands.entity(light).try_despawn();
}
}
@@ -534,6 +653,68 @@ mod tests {
assert_eq!(reaped.envelope(), 0.0);
}
+ /// MONKEY (area spell light) — GOLDEN: the AREA envelope's four legs, which differ from every
+ /// other mode's in all four. The slower ramp, the BREATHING hold (the leg that exists so an
+ /// 8-second Flamestrike patch is not a frozen disc), and the slower removal fade that the
+ /// anchor's own ~2 s teardown leaves room for.
+ #[test]
+ fn an_area_light_ramps_slowly_breathes_and_fades_out() {
+ let at = |mut l: SpellLight, age: f32| {
+ l.age = age;
+ l.envelope()
+ };
+ let area = || SpellLight::new(100.0, 0.0, SpellLightMode::Area { phase: 0.0 });
+ // 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()
+ };
+
+ // The ramp is the AREA one — at the flash ramp's 0.1 s an area light is well short of up.
+ assert!(at(area(), SPELL_LIGHT_RAMP) < 0.5, "not a flash");
+ let half = SPELL_AREA_RAMP * 0.5;
+ assert!((at(area(), half) - 0.5 * swell(half)).abs() < 1e-3);
+ assert!((at(area(), SPELL_AREA_RAMP) - swell(SPELL_AREA_RAMP)).abs() < 1e-3);
+
+ // The hold BREATHES: a full period later it is back at its base, a quarter period past
+ // 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.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
+ );
+ for step in 0..200 {
+ let v = at(area(), 1.0 + step as f32 * 0.05);
+ assert!(
+ (1.0 - SPELL_AREA_BREATH_DEPTH - 1e-3..=1.0 + SPELL_AREA_BREATH_DEPTH + 1e-3)
+ .contains(&v),
+ "the hold never drifts off its base: {v}"
+ );
+ }
+
+ // Removal: the area fade, not the kit's — and `reap_complete` is what hands the budget
+ // slot back rather than waiting out the anchor's own teardown.
+ let mut ending = area();
+ ending.age = 8.0;
+ ending.reaped = SPELL_AREA_FADE * 0.5;
+ assert!(!ending.reap_complete());
+ assert!(ending.envelope() < 0.55 && ending.envelope() > 0.45);
+ ending.reaped = SPELL_AREA_FADE;
+ assert_eq!(ending.envelope(), 0.0);
+ assert!(ending.reap_complete());
+ // A KIT light of the same age is NOT finished at the area fade's length — the two clocks
+ // are genuinely separate.
+ let mut kit_ending = SpellLight::new(1.0, 0.0, SpellLightMode::Kit);
+ kit_ending.reaped = SPELL_REAP_FADE * 0.5;
+ assert!(!kit_ending.reap_complete());
+ }
+
fn test_anims(clips: &[AnimClip]) -> ModelAnimations {
ModelAnimations {
graph: Handle::default(),
diff --git a/crates/benilla-app/src/entities/spell_fx/mod.rs b/crates/benilla-app/src/entities/spell_fx/mod.rs
index 8a082bec..14f02ddf 100644
--- a/crates/benilla-app/src/entities/spell_fx/mod.rs
+++ b/crates/benilla-app/src/entities/spell_fx/mod.rs
@@ -83,9 +83,11 @@ pub(super) use lifecycle::advance_fx_anim;
// switch and the mode each lane spawns it in. Exported at `pub(crate)` because the spawners live
// outside this module: `entities::carried_light` holds the one spawn helper (and the firework
// GameObject's own branch), and `entities.rs` registers the two systems.
+// MONKEY (area spell light): plus the persistent ground lane's marker, which `dest_fx` reads for
+// the impact dedupe and the budget reads to evict a standing pool last.
pub(crate) use lifecycle::{
- advance_spell_lights, budget_spell_lights, spell_lights_enabled, SpellLight, SpellLightMode,
- SPELL_BURST_SPAN,
+ advance_spell_lights, budget_spell_lights, spell_lights_enabled, AreaSpellLight, SpellLight,
+ SpellLightMode, SPELL_BURST_SPAN,
};
use lifecycle::{decay_span, FxAnimLife, FxDecay};
diff --git a/crates/benilla-app/src/shadow_core.rs b/crates/benilla-app/src/shadow_core.rs
index 9028d4df..01d0ca8a 100644
--- a/crates/benilla-app/src/shadow_core.rs
+++ b/crates/benilla-app/src/shadow_core.rs
@@ -26,6 +26,7 @@
//! 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::light::{
CascadeShadowConfigBuilder, DirectionalLight, DirectionalLightShadowMap, NotShadowReceiver,
@@ -45,7 +46,13 @@ use benilla_assets::materials::WowModelMaterial;
use benilla_formats::ModelBlend;
use benilla_world::billboard::BillboardCard;
use benilla_world::interact::PickMesh;
-use benilla_world::lighting::{ShadowDistance, ShadowFilterGaussian, WowLighting};
+// MONKEY (moon shadows): `sun_shadow_strength` is the rig's half of the hand-over law — WHICH body
+// 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,
+};
use benilla_world::model_render::{ModelKind, ModelPart, ShadowOccluder};
use benilla_world::rig_palette::{RigPalettes, RigPart, RigSkin};
use benilla_world::view::WorldCamera;
@@ -227,6 +234,13 @@ fn sun_snap_due(held: Option, live: Vec3) -> bool {
/// celestial TO-sun direction, elevation clamped to [`MIN_SHADOW_SUN_ELEVATION`,
/// `MAX_SHADOW_SUN_ELEVATION`]. Azimuth is preserved so shadows sweep with the real sun; only the
/// height is tamed so a set/zenith sun can't invert or degenerate the basis.
+///
+/// MONKEY (moon shadows): the MOON is aimed through this same function, unchanged. The clamp is
+/// exactly what a moonrise needs — the white moon climbs from −10° through 0° to +55°, and an
+/// unclamped basis at 1° of elevation stretches the cascade's footprint toward the horizon until
+/// every shadow in it is a smear. The moon also has no separate azimuth problem to solve: its
+/// bearing is the SAME constant 45° the celestial sun uses (`daynight::moon_direction`), so a moon
+/// shadow falls along a midday sun shadow's compass line, only longer.
fn shadow_sun_travel(to_sun: Vec3) -> Vec3 {
let s = to_sun.normalize_or_zero();
if s == Vec3::ZERO {
@@ -259,8 +273,8 @@ struct ShadowCameraLayer {
had_layers: bool,
}
-/// The two ordered phases of the shadow feature within `Last`: the rig runs FIRST (spawns/aims the
-/// sun, publishes [`ShadowFrame`]), then every lane runs in [`ShadowSet::Lanes`] — reading the frame
+/// MONKEY (moon shadows): the rig runs in PostUpdate BEFORE Propagate (spawns/aims the
+/// sun, publishes [`ShadowFrame`]), then lanes run in Last in [`ShadowSet::Lanes`] — reading the frame
/// to build its casters and ORing its demand back in for the rig to read next frame.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) enum ShadowSet {
@@ -280,6 +294,8 @@ pub(crate) struct ShadowDemand(pub bool);
#[derive(Resource, Default)]
pub(crate) struct ShadowFrame {
pub active: bool,
+ /// MONKEY (moon shadows): keep cached geometry, but spend no rebuild work on an off night.
+ pub suspended: bool,
pub light_position: Vec3,
pub sun_direction: Vec3,
/// Reach for short casters (creatures/gameobjects — at most a body tall).
@@ -347,12 +363,15 @@ impl Plugin for ShadowCorePlugin {
.init_resource::()
.init_resource::()
// The rig runs before the lanes every frame; the lanes read the frame it publishes.
- .configure_sets(Last, (ShadowSet::Rig, ShadowSet::Lanes).chain())
+ // 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(
- Last,
+ PostUpdate,
(apply_shadow_quality, manage_rig)
.chain()
.in_set(ShadowSet::Rig),
@@ -433,19 +452,24 @@ fn manage_rig(
state: Res>,
video: Res,
lighting: Res,
+ time: Res,
+ mut handover: ResMut,
mut demand: ResMut,
mut frame: ResMut,
mut rig: ResMut,
// MONKEY (distance slider): bridge the app-side `shadowDistance` to benilla-world so
// `global_light` can pack it for the receivers' edge fade (which must fade at THIS distance).
mut shadow_distance_out: ResMut,
+ // MONKEY (moon shadows): read-only here — the settings registry owns the write. `0` keeps the
+ // rig aimed at the celestial sun all night, i.e. the pre-feature behaviour exactly.
+ moon_strength: Res,
mut commands: Commands,
mut materials: ResMut>,
mut cameras: Query<
(Entity, Option<&mut RenderLayers>, Option<&ShadowCameraLayer>),
With,
>,
- mut suns: Query<&mut Transform, With>,
+ mut suns: Query<(&mut Transform, &mut DirectionalLight), With>,
cameras_for_position: Query<&GlobalTransform, With>,
) {
let in_world = *state.get() == ClientState::InWorld;
@@ -459,6 +483,13 @@ fn manage_rig(
// (they run after us, in ShadowSet::Lanes). One-frame lag on rig spawn — imperceptible.
let wanted = demand.0 && in_world;
demand.0 = false;
+ // 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,
+ moon_shadow_weight(lighting.celestial_dir().y, lighting.moon_dir().y),
+ moon_strength.0, wanted, time.delta_secs());
+ frame.suspended = sun_w <= 0.0 && moon_strength.0 <= 0.0;
if !wanted {
if let Some(entity) = rig.sun.take() {
@@ -485,6 +516,17 @@ fn manage_rig(
return;
}
+ // MONKEY (moon shadows): an externally removed light is not an acknowledged live rig.
+ // Deferred first spawns have no written basis yet, so they are allowed their creation frame.
+ if rig.sun_written.is_some() && rig.sun.is_some_and(|sun| !suns.contains(sun)) {
+ rig.sun = None;
+ rig.sun_written = None;
+ *handover = ShadowHandover::default();
+ handover.request(sun_w,
+ moon_shadow_weight(lighting.celestial_dir().y, lighting.moon_dir().y),
+ moon_strength.0, wanted, 0.0);
+ }
+
if rig.material.is_none() {
rig.material = Some(materials.add(ShadowCasterMaterial {}));
}
@@ -495,7 +537,7 @@ fn manage_rig(
// The light exists to populate a shadow map; WoW's shader lighting stays
// authoritative, so it must not add a second diffuse term.
illuminance: 0.0,
- shadows_enabled: true,
+ shadows_enabled: !frame.suspended,
shadow_depth_bias: 0.02,
shadow_normal_bias: 0.8,
..default()
@@ -558,8 +600,15 @@ fn manage_rig(
.map(GlobalTransform::translation)
.unwrap_or(Vec3::ZERO);
- // MONKEY (moving sun): aim at the VISIBLE celestial sun (rises/sets), clamped in elevation.
- let sun_direction = shadow_sun_travel(lighting.celestial_dir());
+ // MONKEY (moon shadows): request/acknowledge rather than assuming the clock is continuous.
+ // The published weight stays zero across the write and its propagation frame, including a
+ // noon-to-midnight jump, login and a strength-zero-to-enabled transition at midnight.
+ let aim_at_moon = handover.wanted == Some(ShadowBody::Moon);
+ let sun_direction = shadow_sun_travel(if aim_at_moon {
+ lighting.moon_dir()
+ } else {
+ lighting.celestial_dir()
+ });
// MONKEY (sun shadow perf): `shadowCasterReach` scales BOTH reaches by the same factor, after
// the reach law rather than inside it — the law's terms (resolve range, rebuild margin, the
// sun-elevation extension) each mean something, and a dial that rewrote one of them would
@@ -572,15 +621,21 @@ fn manage_rig(
if let Some(sun) = rig.sun {
// Only the rotation matters; QUANTISED to hold the cascade texel snap (see SUN_SNAP_RADIANS).
- if sun_snap_due(rig.sun_written, sun_direction) {
- if let Ok(mut current) = suns.get_mut(sun) {
+ if let Ok((mut current, mut light)) = suns.get_mut(sun) {
+ if light.shadows_enabled == frame.suspended {
+ light.shadows_enabled = !frame.suspended;
+ }
+ if handover.aimed != handover.wanted || sun_snap_due(rig.sun_written, sun_direction) {
*current = Transform::IDENTITY.looking_to(sun_direction, Vec3::Y);
rig.sun_written = Some(sun_direction);
+ if let Some(body) = handover.wanted {
+ handover.aim_written(body);
+ }
}
}
}
- frame.active = true;
+ frame.active = rig.sun.is_some_and(|sun| suns.contains(sun));
frame.light_position = light_position;
frame.sun_direction = sun_direction;
frame.entity_reach = entity_reach;
@@ -676,6 +731,7 @@ pub(crate) fn empty_cutout_mesh() -> Mesh {
pub(crate) fn collect_entity_geometry(
parts: &Query<
(
+ Entity,
&PickMesh,
&ModelPart,
Option<&GlobalTransform>,
@@ -694,9 +750,10 @@ pub(crate) fn collect_entity_geometry(
doodad_reach: f32,
positions: &mut Vec<[f32; 3]>,
indices: &mut Vec,
+ mut built_parts: Option<&mut EntityHashSet>,
) -> (u32, u32) {
let (mut admitted, mut rejected) = (0u32, 0u32);
- for (pick, part, global, rig_part, occluder, _material) in parts.iter() {
+ for (entity, pick, part, global, rig_part, occluder, _material) in parts.iter() {
if !casts_realtime_shadow(part.kind, part.blend, want_creatures, want_environment) {
continue;
}
@@ -734,7 +791,15 @@ pub(crate) fn collect_entity_geometry(
}
let added = positions.len() as u32 - base;
if added != 0 {
+ let before = indices.len();
append_triangles(&pick.0.indices, added, base, indices);
+ // MONKEY (moon shadows): readiness is a successful triangle append, not admission
+ // or a material handle. Missing palettes / pending mesh data must keep the oval.
+ if indices.len() > before {
+ if let Some(built) = built_parts.as_deref_mut() {
+ built.insert(entity);
+ }
+ }
}
}
(admitted, rejected)
@@ -850,6 +915,52 @@ mod tests {
assert_eq!(shadow_sun_travel(Vec3::ZERO), Vec3::NEG_Z);
}
+ /// MONKEY (moon shadows): the RIG's half of the hand-over — WHICH body the one directional
+ /// light is aimed at, and that the swap lands where nothing is being cast.
+ ///
+ /// `manage_rig`'s predicate is `moonShadowStrength > 0 && sun_shadow_strength(sun.y) <= 0`.
+ /// What has to hold for that to be a swap and not a POP is that on the frame it flips, BOTH
+ /// weights are zero — the sun's because the predicate says so, the moon's because
+ /// `moon_shadow_weight` has its own elevation ramp and the moon is still under the horizon
+ /// there. (The weights' own continuity over the whole game day is
+ /// `benilla_world`'s `the_two_shadow_weights_never_overlap_and_neither_jumps`; this is the
+ /// aiming side of the same contract, and the reason the two are not one test is that the aim
+ /// lives in this crate and the weights in that one.)
+ ///
+ /// The third assertion is the one a reviewer should look at hardest: a moon at or under the
+ /// horizon must still produce a DOWNWARD, elevation-clamped basis. Unclamped, a moonrise aim
+ /// either inverts (a below-horizon body's travel points up, so every shadow falls the wrong
+ /// way) or stretches the cascade's footprint toward the horizon until the map resolves nothing.
+ #[test]
+ fn the_rig_hands_over_to_the_moon_only_where_neither_body_casts() {
+ use benilla_world::lighting::{moon_shadow_weight, sun_shadow_strength};
+
+ // 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");
+ // …and the moon is still under it (the shipped tables put moonrise ~1h45m later).
+ let moon_under = -0.17_f32;
+ assert_eq!(
+ moon_shadow_weight(sun_down, moon_under),
+ 0.0,
+ "the moon must not start casting the instant the sun stops — the swap has to happen \
+ where BOTH weights are zero or the map's contents change under a live weight"
+ );
+ // 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");
+ // 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 —
+ // the same treatment `shadow_sun_travel` gives a set sun.
+ let t = shadow_sun_travel(Vec3::new(0.7, moon_under, 0.7).normalize());
+ assert!(t.y < 0.0, "a moonrise basis still points down");
+ assert!(
+ ((-t.y).asin() - MIN_SHADOW_SUN_ELEVATION).abs() < 1e-3,
+ "a low moon is pinned to the floor elevation, so its shadows cannot smear to the horizon"
+ );
+ }
+
#[test]
fn the_shadow_sun_snaps_on_first_write_holds_under_drift_and_rearms() {
let held = Vec3::new(0.3, -0.8, 0.52).normalize();
diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs
index 72b67521..56272f59 100644
--- a/crates/benilla-app/src/ui_script/options_tests.rs
+++ b/crates/benilla-app/src/ui_script/options_tests.rs
@@ -1968,6 +1968,321 @@ fn defaults_resets_the_graphics_page_to_registered_defaults() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+// ── MONKEY (advanced graphics): the Advanced Graphics page ───────────────────────────────────
+
+/// The name every row on the new page is derived from.
+const ADVGFX: &str = "BenillaOptionsFrameContainerBodyAdvancedGraphics";
+
+#[test]
+fn advanced_graphics_fire_and_spell_sliders_read_and_write_the_full_range() {
+ let mut s = audio_harness();
+ s.set_cvar_host("fireLightGain", "3.25");
+ s.set_cvar_host("spellLightGain", "3.25");
+ let mut s = harness_on(s);
+ s.run("ShowUIPanel(BenillaOptionsFrame) \
+ BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
+ .unwrap();
+ let _ = s.take_cvar_changes();
+ for (row, cvar) in [("RowFireLight", "fireLightGain"), ("RowSpellLights", "spellLightGain")] {
+ assert_eq!(s.eval::(&format!(
+ "return {ADVGFX}{row}ControlSlider:GetValue()"
+ )).unwrap(), 3.25);
+ assert_eq!(s.eval::(&format!(
+ "return {ADVGFX}{row}ControlValue:GetText()"
+ )).unwrap(), "325%");
+ for value in ["4", "2.75"] {
+ 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));
+ }
+ }
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
+
+#[test]
+fn advanced_graphics_selects_its_locale_at_load_including_yards() {
+ for (locale, title, distance) in [
+ ("enUS", "Advanced Graphics", "80 yd"),
+ ("ruRU", "Расширенная графика", "80 ярд."),
+ ("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()")
+ .unwrap();
+ assert_eq!(s.eval::(
+ "return BenillaOptionsFrameContainerTitle:GetText()"
+ ).unwrap(), title);
+ assert_eq!(s.eval::(&format!(
+ "return {ADVGFX}RowShadowDistanceControlValue:GetText()"
+ )).unwrap(), distance);
+ assert!(s.eval::(
+ "return BENILLA_TOOLTIP_NIGHT_DARKNESS == BENILLA_ADVGFX.tips.NIGHT_DARKNESS"
+ ).unwrap());
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+ }
+}
+
+/// **The page reads the lighting CVars on select** — every control flavour on it at once, which
+/// is the point of checking one page rather than one row: this is the first page in the window
+/// carrying a dropdown, a checkbox and four sliders whose store is a benilla CVar with no
+/// reference counterpart, and the first whose labels come out of a table rather than a literal.
+///
+/// The three rows that moved here off Graphics are checked BOTH ways: present here, and gone from
+/// there. A row that was copied rather than moved would show up as two live controls over one
+/// CVar, which nothing else in this file would catch.
+#[test]
+fn the_advanced_graphics_page_reads_the_lighting_cvars_on_select() {
+ let mut s = audio_harness();
+ s.set_cvar_host("shadowMapSize", "4096");
+ s.set_cvar_host("interiorShadows", "0");
+ let s = harness_on(s);
+ s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
+ s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
+ .unwrap();
+
+ assert!(s
+ .eval::(&format!("return {ADVGFX}:IsVisible()"))
+ .unwrap());
+ assert_eq!(
+ s.eval::("return BenillaOptionsFrameContainerTitle:GetText()")
+ .unwrap(),
+ "Advanced Graphics",
+ "the page title is the category row's label"
+ );
+ assert!(
+ s.eval::("return BenillaOptionsFrameContainerDefaults:IsEnabled() ~= 0")
+ .unwrap(),
+ "Defaults is live on a page with rows"
+ );
+
+ // The dropdowns: the ladder reads the registered default, the resolution reads the seed.
+ assert_eq!(
+ s.eval::(&format!("return {ADVGFX}RowLightingQualityDropdownText:GetText()"))
+ .unwrap(),
+ "High",
+ "a fresh registry is the High preset, member for member (cvars::tests)"
+ );
+ assert_eq!(
+ s.eval::(&format!("return {ADVGFX}RowShadowResolutionDropdownText:GetText()"))
+ .unwrap(),
+ "4096"
+ );
+ // The checkboxes read the table, not a restated default.
+ assert!(s
+ .eval::(&format!("return {ADVGFX}RowCharacterShadowsCheck:GetChecked()"))
+ .unwrap());
+ assert!(!s
+ .eval::(&format!("return {ADVGFX}RowInteriorShadowsCheck:GetChecked()"))
+ .unwrap());
+ // The sliders, each on its own registry clamp with its own readout grammar.
+ for (row, text) in [
+ ("RowShadowDistance", "80 yd"),
+ ("RowMoonShadows", "35%"),
+ ("RowFireFlicker", "100%"),
+ ("RowSpellLights", "100%"),
+ ] {
+ assert_eq!(
+ s.eval::(&format!("return {ADVGFX}{row}ControlValue:GetText()"))
+ .unwrap(),
+ text,
+ "{row}"
+ );
+ }
+ // The labels come out of the bilingual table (enUS here — benilla's `GetLocale` answers
+ // nothing else yet).
+ assert_eq!(
+ s.eval::(&format!("return {ADVGFX}RowInteriorLightLabel:GetText()"))
+ .unwrap(),
+ "Dynamic Interior Lighting"
+ );
+
+ // MOVED, not copied: the Graphics page has no shadow rows left.
+ for row in ["RowCharacterShadows", "RowWorldShadows", "RowShadowDistance"] {
+ assert!(
+ !s.eval::(&format!(
+ "return BenillaOptionsFrameContainerBodyGraphics{row} ~= nil"
+ ))
+ .unwrap(),
+ "{row} is still seated on the Graphics page"
+ );
+ }
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
+
+/// **The Lighting Quality row writes ONE CVar** — and that is the whole of its contract with this
+/// window. The preset's members are the host's to write (`cvars::LIGHTING_PRESETS`), so a page
+/// that wrote them here would be a second copy of that table, silently drifting from the one the
+/// console and the native panel use. What this pins is the boundary: one write, the engine's own
+/// spelling, and nothing else queued.
+#[test]
+fn the_lighting_quality_row_writes_one_cvar_and_leaves_the_members_to_the_host() {
+ let mut s = harness_on(audio_harness());
+ s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
+ s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
+ .unwrap();
+ let _ = s.take_cvar_changes();
+
+ // The entry click's own body (`OptionsDropdownEntry_OnClick`), which is the row's Set
+ // followed by the capsule's redisplay — driven directly so the test does not depend on the
+ // shared dropdown kit's list geometry.
+ s.run(&format!(
+ "OptionsRow_Set({ADVGFX}RowLightingQuality, \"Low\") \
+ OptionsDropdown_ShowValue({ADVGFX}RowLightingQuality)"
+ ))
+ .unwrap();
+ assert_eq!(
+ s.take_cvar_changes(),
+ vec![("lightingQuality".to_string(), "Low".to_string())],
+ "one write, and the value is the engine's own string — never the localised label"
+ );
+ assert_eq!(
+ s.eval::(&format!("return {ADVGFX}RowLightingQualityDropdownText:GetText()"))
+ .unwrap(),
+ "Low"
+ );
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
+
+/// **The two darkness dials read backwards, and that is the bug this row shape exists to avoid.**
+/// Their store is a GAIN — `nightGain` 0.2..1.5, where 0.2 is the darkest — while the rows are
+/// named for the darkness. Left is darker either way; without the `darkness` readout the NUMBER
+/// beside the label would climb as the world got lighter, under a label that says "Darkness".
+#[test]
+fn the_darkness_sliders_read_out_so_that_left_is_darker() {
+ let mut s = harness_on(audio_harness());
+ s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
+ s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
+ .unwrap();
+
+ // 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::(&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%")] {
+ s.run(&format!(
+ "{ADVGFX}RowNightDarknessControlSlider:SetValue({value})"
+ ))
+ .unwrap();
+ assert_eq!(
+ s.eval::(&format!("return {ADVGFX}RowNightDarknessControlValue:GetText()"))
+ .unwrap(),
+ text,
+ "nightGain {value}"
+ );
+ }
+ // …and the STORE keeps the gain, unchanged — the readout is a readout.
+ assert!(
+ s.take_cvar_changes()
+ .contains(&("nightGain".to_string(), "0.85".to_string())),
+ "the slider writes the gain the engine reads, not the percentage it shows"
+ );
+ assert_eq!(
+ s.eval::(&format!("return {ADVGFX}RowInteriorDarknessControlValue:GetText()"))
+ .unwrap(),
+ "77%",
+ "interiorGain 0.5 of 0.2..1.5"
+ );
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
+
+/// **The page's dependencies**, in the reference's own shape: a master's switch greys what it
+/// gates, and the CVars keep their values throughout so turning the master back on finds its
+/// children where they were left.
+#[test]
+fn the_advanced_graphics_masters_grey_what_they_gate() {
+ let s = harness_on(audio_harness());
+ s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
+ s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
+ .unwrap();
+
+ // A greyed row wears the DISABLE font — 1.12's own signal, and the one `OptionsRow_SetEnabled`
+ // sets on every flavour of row whatever else it does to the control. Compared as an OBJECT,
+ // not a name: `GetFontObject` answers the object (`script::font`).
+ let live = |s: &UiScript, row: &str| -> bool {
+ s.eval::(&format!(
+ "return {ADVGFX}{row}Label:GetFontObject() == GameFontNormal"
+ ))
+ .unwrap()
+ };
+ assert!(live(&s, "RowInteriorShadows"));
+ assert!(live(&s, "RowShadowResolution"));
+
+ // Dynamic Interior Lighting off greys the indoor torch row — the CVar's own documented
+ // dependency ("needs interiorLight").
+ s.run(&format!("{ADVGFX}RowInteriorLightCheck:Click()"))
+ .unwrap();
+ assert!(!live(&s, "RowInteriorShadows"));
+ // Both sun lanes off leave nothing for the cascade rows to size.
+ s.run(&format!("{ADVGFX}RowCharacterShadowsCheck:Click()"))
+ .unwrap();
+ s.run(&format!("{ADVGFX}RowWorldShadowsCheck:Click()"))
+ .unwrap();
+ assert!(!live(&s, "RowShadowResolution"));
+ assert!(!live(&s, "RowShadowDistance"));
+ // …and one of them back on wakes them, with their values untouched.
+ s.run(&format!("{ADVGFX}RowWorldShadowsCheck:Click()"))
+ .unwrap();
+ assert!(live(&s, "RowShadowResolution"));
+ assert_eq!(
+ s.eval::(&format!("return {ADVGFX}RowShadowDistanceControlValue:GetText()"))
+ .unwrap(),
+ "80 yd"
+ );
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
+
+/// **The page follows a write it did not make.** This is the one page in the window whose CVars
+/// move from outside it — the Lighting Quality row's members are written by the host a frame or
+/// two after the click, and the console and the dev lighting panel reach the same rows while the
+/// page is open. Without the poll the player would pick a preset and watch nothing below it move.
+#[test]
+fn the_page_repaints_when_a_host_write_moves_a_row_under_it() {
+ let mut s = harness_on(audio_harness());
+ s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
+ s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
+ .unwrap();
+ // The page has to be effectively visible before its OnUpdate is in the sweep at all.
+ s.resolve();
+ assert!(s
+ .eval::(&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
+ // Lua write behind it.
+ s.set_cvar_host("exteriorShadows", "0");
+ s.set_cvar_host("moonShadowStrength", "0");
+ // Under the poll interval: nothing has happened yet.
+ s.tick(0.1);
+ assert!(s
+ .eval::(&format!("return {ADVGFX}RowExteriorShadowsCheck:GetChecked()"))
+ .unwrap());
+ // Past it: one refresh, and the page tells the truth again.
+ s.tick(0.2);
+ assert!(!s
+ .eval::(&format!("return {ADVGFX}RowExteriorShadowsCheck:GetChecked()"))
+ .unwrap());
+ assert_eq!(
+ s.eval::(&format!("return {ADVGFX}RowMoonShadowsControlValue:GetText()"))
+ .unwrap(),
+ "0%"
+ );
+ // The refresh writes NOTHING back — it is a read of the table, not a round trip.
+ assert!(
+ s.take_cvar_changes().is_empty(),
+ "the poll must never write a CVar"
+ );
+ // …and a settled page costs nothing: the signature has not moved, so no further refresh runs.
+ s.tick(0.3);
+ assert!(s.take_cvar_changes().is_empty());
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
+
/// Controls is the DEFAULT page and has rows since 0961: opening the window lands on it with
/// Defaults armed, and the rows read the table — Sticky Targeting INVERTED (checked when
/// `deselectOnClick` is "0", the 1.12 interface panel's own arm), the plain flags direct.
@@ -2560,9 +2875,66 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
"AudioRowBackgroundSound",
),
("BENILLA_TOOLTIP_BRIGHTNESS", "GraphicsRowBrightness"),
- ("BENILLA_TOOLTIP_CHARACTER_SHADOWS", "GraphicsRowCharacterShadows"),
- ("BENILLA_TOOLTIP_WORLD_SHADOWS", "GraphicsRowWorldShadows"),
- ("BENILLA_TOOLTIP_SHADOW_DISTANCE", "GraphicsRowShadowDistance"),
+ // MONKEY (advanced graphics): the whole Advanced Graphics page. Every row on it is
+ // benilla's own mechanism — 1.12 has no realtime shadow, no fixture-lit interior and
+ // no invented light — so there is no `OPTION_TOOLTIP_*` any of them could resolve,
+ // and each carries a `BENILLA_` key for the same reason the four above it do. The
+ // first three are the rows that MOVED here off the Graphics page: same keys, same
+ // texts, new seat, which is why they read `AdvancedGraphicsRow…` now.
+ (
+ "BENILLA_TOOLTIP_CHARACTER_SHADOWS",
+ "AdvancedGraphicsRowCharacterShadows",
+ ),
+ (
+ "BENILLA_TOOLTIP_WORLD_SHADOWS",
+ "AdvancedGraphicsRowWorldShadows",
+ ),
+ (
+ "BENILLA_TOOLTIP_SHADOW_DISTANCE",
+ "AdvancedGraphicsRowShadowDistance",
+ ),
+ (
+ "BENILLA_TOOLTIP_LIGHTING_QUALITY",
+ "AdvancedGraphicsRowLightingQuality",
+ ),
+ (
+ "BENILLA_TOOLTIP_SHADOW_RESOLUTION",
+ "AdvancedGraphicsRowShadowResolution",
+ ),
+ (
+ "BENILLA_TOOLTIP_MOON_SHADOWS",
+ "AdvancedGraphicsRowMoonShadows",
+ ),
+ (
+ "BENILLA_TOOLTIP_INTERIOR_LIGHT",
+ "AdvancedGraphicsRowInteriorLight",
+ ),
+ (
+ "BENILLA_TOOLTIP_INTERIOR_SHADOWS",
+ "AdvancedGraphicsRowInteriorShadows",
+ ),
+ (
+ "BENILLA_TOOLTIP_EXTERIOR_SHADOWS",
+ "AdvancedGraphicsRowExteriorShadows",
+ ),
+ ("BENILLA_TOOLTIP_TORCH_SOFT", "AdvancedGraphicsRowTorchSoft"),
+ ("BENILLA_TOOLTIP_FIRE_LIGHT", "AdvancedGraphicsRowFireLight"),
+ (
+ "BENILLA_TOOLTIP_FIRE_FLICKER",
+ "AdvancedGraphicsRowFireFlicker",
+ ),
+ (
+ "BENILLA_TOOLTIP_SPELL_LIGHTS",
+ "AdvancedGraphicsRowSpellLights",
+ ),
+ (
+ "BENILLA_TOOLTIP_NIGHT_DARKNESS",
+ "AdvancedGraphicsRowNightDarkness",
+ ),
+ (
+ "BENILLA_TOOLTIP_INTERIOR_DARKNESS",
+ "AdvancedGraphicsRowInteriorDarkness",
+ ),
];
if let Some((_, want_row)) = BENILLA_OWNED.iter().find(|(k, _)| *k == key) {
assert_eq!(row, *want_row, "{row}: not this row's string");
@@ -2623,7 +2995,12 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
// 21-step grey ramp, which this page does not have (see the guard above). 77 -> 78.
// MONKEY (world shadows): +3 for Real Character Shadows, World Shadows and the Shadow
// Distance slider (all benilla-owned keys, carved out in BENILLA_OWNED above). 78 -> 81.
- assert_eq!(checked, 81, "every tipped row carries a live key");
+ // MONKEY (advanced graphics): +12, the rows the new page ADDS — Lighting Quality, Shadow
+ // Resolution, Moon Shadows, Dynamic Interior Lighting, Indoor and Outdoor Torch Shadows,
+ // Torch Shadow Softness, Fire Light Brightness, Fire Flicker, Spell Lights, Night Darkness
+ // and Interior Darkness. The three rows that MOVED onto it are already in the 81: a row
+ // changing pages does not change this count, only its entry in BENILLA_OWNED. 81 -> 93.
+ assert_eq!(checked, 93, "every tipped row carries a live key");
assert_eq!(
untipped,
vec![
@@ -2674,6 +3051,9 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
"Controls",
"Audio",
"Graphics",
+ // MONKEY (advanced graphics): the tenth category, and the one with the most
+ // benilla-owned descriptions on it — every row here raises a `BENILLA_TOOLTIP_*`.
+ "AdvancedGraphics",
"Nameplates",
"Combat",
"Interface",
@@ -2745,7 +3125,11 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
// sliders.
// MONKEY (world shadows): +3 for Real Character Shadows, World Shadows and the Shadow
// Distance slider (all benilla-owned keys, carved out in BENILLA_OWNED above). 78 -> 81.
- assert_eq!(raised, 81, "every row but Auto Loot raises a description");
+ // MONKEY (advanced graphics): +12 for the rows the new page ADDS (the three it inherits were
+ // already counted, on their old page). 81 -> 93 — and the page is where this test's own
+ // teeth bite hardest, since it is the first one to seat a dropdown, a checkbox and a slider
+ // whose descriptions are all benilla's.
+ assert_eq!(raised, 93, "every row but Auto Loot raises a description");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
@@ -3705,7 +4089,11 @@ fn the_defaults_button_is_armed_by_rows_not_by_a_category() {
.split(',')
.map(str::to_string)
.collect();
- assert_eq!(keys.len(), 9, "the nine 1.15.9 categories: {keys:?}");
+ // MONKEY (advanced graphics): ten, not nine — the era's own tree has no Advanced Graphics
+ // 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:?}");
for key in &keys {
let has_rows = s
.eval::(&format!(
diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs
index d829c03d..a642bc18 100644
--- a/crates/benilla-app/src/video.rs
+++ b/crates/benilla-app/src/video.rs
@@ -267,6 +267,13 @@ pub(crate) struct VideoConfig {
/// proxies. Trimming it is the direct dial on caster POPULATION — the input to both lanes'
/// per-rebuild cost — at the risk of a tall caster's shadow popping in as you approach.
pub(crate) shadow_caster_reach: f32,
+ /// MONKEY (moon shadows): how dark a MOON-shadowed fragment is allowed to get at night
+ /// (`moonShadowStrength`, 0..1, default 0.35 = the fragment keeps 65 % of the night
+ /// directional term). Bridged to [`benilla_world::lighting::MoonShadowStrength`] by
+ /// `dynamic_interior`, beside the spell gain and for the same reasons — one live `f32`, one
+ /// resource, one guard. `0` is the faithful null: every consumer of the resource guards on it,
+ /// so a night at 0 renders as the build before the moon lane existed.
+ pub(crate) moon_shadow_strength: f32,
/// MONKEY (dynamic interiors): WMO interiors + their props light from the room's live fixtures
/// (`interiorLight`) instead of the baked path. The three knobs are `interiorAmbient` (base
/// ambient, 0..1), `interiorFill` (per-fixture bounce gain, 0..2) and `interiorExposure`
@@ -349,8 +356,8 @@ pub(crate) struct VideoConfig {
/// MONKEY (interior debug): the interior-lane diagnostic overlay (`interiorDebug`, 0..4). See
/// [`benilla_world::lighting::DynamicInteriors::debug`].
pub(crate) interior_debug: u32,
- /// MONKEY (darkness gains): the exterior night dim (`nightGain`, 0.2..1.5, default **0.8** =
- /// nights 20 % darker). Bridged to `DynamicInteriors::night_gain`, which the light packer folds
+ /// MONKEY (darkness gains): the exterior night dim (`nightGain`, 0.2..1.5, default **0.45** =
+ /// nights 55 % darker). Bridged to `DynamicInteriors::night_gain`, which the light packer folds
/// into the packed ambient/diffuse/specular rows on a `mix(1, gain, night_w)` ramp — so it is
/// exactly inert while the sun is up and live the frame it changes after dark.
pub(crate) night_gain: f32,
@@ -411,6 +418,8 @@ impl Default for VideoConfig {
character_shadow_rate: crate::shadow_core::DEFAULT_SHADOW_RATE,
world_shadow_rate: crate::shadow_core::DEFAULT_SHADOW_RATE,
shadow_caster_reach: 1.0,
+ // MONKEY (moon shadows): 0.35 — a hint of a silhouette, not a daylight-hard shadow.
+ moon_shadow_strength: 0.35,
// The cvar defaults are the source of truth at load; these only stand in until then.
interior_light: true,
interior_ambient: 0.015,
@@ -507,7 +516,7 @@ pub(crate) fn on_cvar(
// Display mode (1627) — the reference's own polarity: `1` is WINDOWED (the row is
// "Windowed Mode"). `apply_window_mode` pushes it to the window when this moves.
"gxwindow" => cfg.display = display_from_flag(v),
- // ── MONKEY (lighting): the dynamic light + shadow system's 29 rows ────────────────────
+ // ── MONKEY (lighting): the dynamic light + shadow system's 30 rows ────────────────────
// They live in THIS observer, and not in one of their own beside `shadow_core` /
// `dynamic_interior`, because of the law the arm above states: *each arm writes only its
// own resource*. Every one of these knobs IS a field of [`VideoConfig`] — the lanes read
@@ -523,7 +532,7 @@ pub(crate) fn on_cvar(
//
// Clamps are each row's own, stated beside it, exactly as for the reference rows above;
// the `ours(...)` entries in `cvars::REGISTERED` carry the matching defaults, and
- // `cvars::tests::registered_defaults_mirror_the_code_truths` welds all 29 pairs.
+ // `cvars::tests::registered_defaults_mirror_the_code_truths` welds all 30 pairs.
"worldshadows" => cfg.world_shadows = ev.flag(),
"charactershadows" => cfg.character_shadows = ev.flag(),
"shadowdistance" => {
@@ -557,6 +566,10 @@ pub(crate) fn on_cvar(
*crate::shadow_core::CASTER_REACH_RANGE.end(),
)
}
+ // MONKEY (moon shadows): the night lane's shadow darkness. 0 IS meaningful (the lane off,
+ // bit-identical to the pre-feature night), so it floors at 0 rather than at a
+ // minimum-useful value; 1 is a daylight-hard silhouette by moonlight.
+ "moonshadowstrength" => cfg.moon_shadow_strength = v.clamp(0.0, 1.0),
// MONKEY (dynamic interiors): the interior lane's on/off + knobs, clamped at the edge like
// every other numeric row. `dynamic_interior::bridge` publishes them to benilla-world.
"interiorlight" => cfg.interior_light = ev.flag(),
diff --git a/crates/benilla-app/src/world_shadow.rs b/crates/benilla-app/src/world_shadow.rs
index c3bd7662..68775ec6 100644
--- a/crates/benilla-app/src/world_shadow.rs
+++ b/crates/benilla-app/src/world_shadow.rs
@@ -138,6 +138,7 @@ fn update_world_shadows(
mut world_active: ResMut,
parts: Query<
(
+ Entity,
&PickMesh,
&ModelPart,
Option<&GlobalTransform>,
@@ -163,6 +164,12 @@ fn update_world_shadows(
teardown(&mut lane, &mut commands, &mut meshes, &mut cutout_materials);
return;
}
+ // MONKEY (moon shadows): no receiver can use the off-night map. Preserve cached geometry,
+ // but suspend BOTH entity and retained-world rebuilds until a body can cast again.
+ if frame.suspended {
+ lane.env_rate.reset();
+ return;
+ }
let Some(material) = frame.material.clone() else {
return;
};
@@ -200,6 +207,7 @@ fn update_world_shadows(
frame.tall_reach,
&mut positions,
&mut indices,
+ None,
);
let tris = (indices.len() / 3) as u32;
restore_mesh_buffers(mesh, positions, indices);
diff --git a/crates/benilla-assets/src/shaders/shadow_hook.wgsl b/crates/benilla-assets/src/shaders/shadow_hook.wgsl
index da85a643..d7b3206f 100644
--- a/crates/benilla-assets/src/shaders/shadow_hook.wgsl
+++ b/crates/benilla-assets/src/shaders/shadow_hook.wgsl
@@ -22,6 +22,40 @@
// it fades in rather than popping at the resolve boundary.
const SHADOW_EDGE_BAND: f32 = 14.0;
+// MONKEY (moon shadows): the `fog_params.z` lane decodes. ONE SIGNED float carries BOTH directional
+// weights — `+w` the sun's, `-w` the moon's — because `global_light::moon_shadow_weight` guarantees
+// they are never both non-zero (the rig holds ONE body's depth map at a time; the receiver loop
+// below ASSIGNS, last light wins). The CPU half is `global_light::pack_shadow_lane`; KEEP THE TWO
+// ENDS IN SYNC.
+//
+// Every OTHER reader of this lane was already sign-safe, which is what made the sign free: the
+// three `ext_night_w` decodes are `clamp(1 - z, 0, 1)`, and a negative `z` gives them the same 1.0
+// the 0.0 they used to read gave them, so the exterior torch lane is bit-identical after dark.
+fn sun_shadow_w(lane: f32) -> f32 {
+ return max(lane, 0.0);
+}
+
+fn moon_shadow_w(lane: f32) -> f32 {
+ return max(-lane, 0.0);
+}
+
+// MONKEY (moon shadows): ONE map fetch, the piece both weights share. Split out of
+// `realtime_shadow` so `realtime_shadow_terms` can weight the SAME sample twice instead of
+// sampling twice — the fetch is a 9-tap Gaussian PCF and the whole feature's cost budget is "no new
+// passes, no new textures, and nothing a second time".
+fn shadow_fetch(sample_pos: vec4, normal: vec3, view_z: f32) -> f32 {
+ var shadow = 1.0;
+ if (lights.n_directional_lights > 0u) {
+ for (var light_id = 0u; light_id < lights.n_directional_lights; light_id = light_id + 1u) {
+ if ((lights.directional_lights[light_id].flags & 1u) != 0u) {
+ shadow = shadows::fetch_directional_shadow(light_id, sample_pos, normal, view_z);
+ break;
+ }
+ }
+ }
+ return shadow;
+}
+
// The realtime directional-shadow factor at `sample_pos`: 1.0 = lit, 0.0 = fully shadowed.
//
// - `sample_pos` world-space point to sample the shadow map at — a per-fragment position, or a
@@ -43,19 +77,49 @@ fn realtime_shadow(
shadow_range: f32,
night: f32,
) -> f32 {
- var shadow = 1.0;
- if (lights.n_directional_lights > 0u) {
- for (var light_id = 0u; light_id < lights.n_directional_lights; light_id = light_id + 1u) {
- if ((lights.directional_lights[light_id].flags & 1u) != 0u) {
- shadow = shadows::fetch_directional_shadow(light_id, sample_pos, normal, view_z);
- break;
- }
- }
- }
+ let shadow = shadow_fetch(sample_pos, normal, view_z);
let edge_fade = smoothstep(shadow_range - SHADOW_EDGE_BAND, shadow_range, cam_dist);
return 1.0 - (1.0 - shadow) * night * (1.0 - edge_fade);
}
+// MONKEY (moon shadows): BOTH directional weights over ONE fetch — `.x` the SUN arm (exactly what
+// `realtime_shadow` above returns, character for character), `.y` the MOON arm.
+//
+// The three receivers call this instead of `realtime_shadow` because they apply the two arms to
+// DIFFERENT terms, and must do so from the same sample:
+// · the SUN arm keeps its existing home in each receiver (terrain's `character_shadow_term`, the
+// model's and static_gx's `ambient + (lit − ambient) × shadow_term`), untouched;
+// · the MOON arm scales the exterior NIGHT law — see each receiver's own note for which term and
+// why point lights, torches and spell lights are outside it.
+//
+// Both arms are written as the ONE original expression rather than factored through a shared
+// `occ = (1 − shadow)·(1 − edge_fade)`: float multiply is not associative, and `.x` must be the
+// same BITS as before this function existed. At `moon == 0.0` — every daylight frame, every frame
+// with `moonShadowStrength 0`, and the ≈20:30-22:17 window with the sun down and the moon not yet
+// up — `.y` is exactly `1.0` whatever the map holds, and every receiver's moon branch is guarded on
+// exactly that, so it is not entered and the render is the pre-feature one.
+fn realtime_shadow_terms(
+ sample_pos: vec4,
+ normal: vec3,
+ view_z: f32,
+ cam_dist: f32,
+ shadow_range: f32,
+ night: f32,
+ moon: f32,
+) -> vec2 {
+ // MONKEY (moon shadows): neither body casts during hand-over or feature-off night. Skip
+ // the map entirely, including PCF; the old night cost must not grow with an inert moon.
+ if (night <= 0.0 && moon <= 0.0) {
+ return vec2(1.0);
+ }
+ let shadow = shadow_fetch(sample_pos, normal, view_z);
+ let edge_fade = smoothstep(shadow_range - SHADOW_EDGE_BAND, shadow_range, cam_dist);
+ return vec2(
+ 1.0 - (1.0 - shadow) * night * (1.0 - edge_fade),
+ 1.0 - (1.0 - shadow) * moon * (1.0 - edge_fade),
+ );
+}
+
// MONKEY (torch shadows Phase 1): the point/cluster-based torch shadow is DEAD. benilla's world
// camera sets `ClusterConfig::None`, which starves `clusterable_objects` AND the point-shadow prep,
// so `fetch_point_shadow`/the clusterable scan can never fire. The replacement is a from-scratch
diff --git a/crates/benilla-assets/src/shaders/terrain.wgsl b/crates/benilla-assets/src/shaders/terrain.wgsl
index cecef46e..5f6ae40b 100644
--- a/crates/benilla-assets/src/shaders/terrain.wgsl
+++ b/crates/benilla-assets/src/shaders/terrain.wgsl
@@ -63,7 +63,7 @@ struct WowLight {
light_sun: vec4, // xyz = world-space sun TRAVEL direction (to-light = −xyz); w unused.
light_spec: vec4, // rgb = row 9 specular color; w = shininess (20). rgb == 0 disables.
fog_color: vec4, // rgb = row 7 fog (raw, gamma 0..1); w = enable (>0.5 ⇒ blend).
- fog_params: vec4, // x = fog_start yd; y = fog_end yd; z unused; w = farclip wall.
+ fog_params: vec4, // x = fog_start yd; y = fog_end yd; z = signed +sun / -moon shadow weight; w = farclip wall.
_sh: array, 6>, // rows 6-11: the model SH coeffs (live in wow_model.wgsl, 0354) — unread by terrain.
sh_c16: vec4, // row 12: xyz the models' c16 quad band; .w a FREE lane (the 0273 point gain is retired).
_water: array, 4>, // rows 13-16: the liquid swatches — unread by terrain.
@@ -281,6 +281,8 @@ struct TerrainVsOut {
// reads `primary` and is therefore bit-identical. Unclamped is safe to interpolate: it is
// linear in `N.L` and the clamp that used to bound it is re-applied per fragment.
@location(9) base_lit: vec3,
+ // MONKEY (moon shadows): preserve the point sum BEFORE saturation; primary cannot recover it.
+ @location(10) point_lit: vec3,
}
// Terrain's point-light **candidacy half-width** (yd): the reference's guaranteed covered box is
@@ -611,6 +613,7 @@ fn vertex(in: Vertex) -> TerrainVsOut {
let sel = point_light_pick(mcnk_cell_anchor(out.world_position.xyz), MCNK_CELL_HALF);
out.ext_sel = sel;
let points = point_light_eval(sel, out.world_position.xyz, n);
+ out.point_lit = points;
let sun_lighting = wow_light.light_diffuse.rgb * ndotl;
// The sun/ambient half on its own slot, UNCLAMPED (see `base_lit`) - the night lane's only way
// back to a `primary` that does not already have the unshadowed point term baked into it.
@@ -693,18 +696,24 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 {
// MCSH fade-back.
let view_z = (view.view_from_world * in.world_position).z;
let cam_dist = distance(in.world_position.xyz, view.world_position.xyz);
- let sun_shadow_strength = wow_light.fog_params.z;
+ // MONKEY (moon shadows): the lane is SIGNED now — `+` the sun's weight, `-` the moon's (see
+ // `shadow_hook::sun_shadow_w`). `max(z, 0)` is the identity on every value this lane ever held
+ // before the feature, so the name, the MCSH gate and the torch-lane decode below are unchanged.
+ let sun_shadow_strength = shadow_hook::sun_shadow_w(wow_light.fog_params.z);
+ let moon_shadow_strength = shadow_hook::moon_shadow_w(wow_light.fog_params.z);
// Hoisted out of the `realtime_shadow` call (same expression, same bits) because the torch lane
// below needs the same normal for its normal-offset sample.
let n_lit = normalize(in.world_normal);
- let world_shadow = shadow_hook::realtime_shadow(
+ let shadow_terms = shadow_hook::realtime_shadow_terms(
in.world_position,
n_lit,
view_z,
cam_dist,
wow_light._wmo_fog[1].z,
sun_shadow_strength,
+ moon_shadow_strength,
);
+ let world_shadow = shadow_terms.x;
// MONKEY (outdoor torch shadows: terrain): the exterior point term, CAST-SHADOWED at night.
//
// `in.primary` is the Gouraud (per-vertex, per-vertex-CLAMPED) diffuse, and it stays the only
@@ -734,6 +743,19 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 {
primary = mix(in.primary, primary_night, ext_night_w);
}
+ // MONKEY (moon shadows): attenuate the SKY (ambient AND directional) BEFORE adding points
+ // and saturating. Subtracting from primary loses over-gamut torch energy: sky .2 + point 1
+ // must remain 1 under a .35 moon shadow, not .93. Keep the entire off arm above untouched;
+ // neither a zero weight nor a fully lit fragment earns a different Gouraud/clamp path.
+ if (shadow_terms.y < 1.0) {
+ var points = in.point_lit;
+ if (ext_night_w > 0.0) {
+ points = mix(points,
+ point_light_eval_shadowed(in.ext_sel, in.world_position.xyz, n_lit), ext_night_w);
+ }
+ primary = clamp(in.base_lit * shadow_terms.y + points, vec3(0.0), vec3(1.0));
+ }
+
// STEP 4: MCSH baked shadow. On the reference path (pixelShaders+specular) terrain is ONE pass
// through `terrainp_s.bls`, which carries the MCSH bit in the blend texture's alpha (1.0 lit / 0.0
// shadowed) and uses it TWO ways (Q15) — NOT the Q11 separate ambient-tint overlay (that's the
@@ -756,6 +778,14 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 {
// world shadow fades at night the baked MCSH fades back IN. By day (strength 1) MCSH is fully
// replaced; at night (strength 0) the authored bake returns; dusk crossfades. Character-only
// (lane flag off) never suppresses MCSH.
+ // MONKEY (moon shadows): this stays on the SUN weight alone — deliberately. The symmetric
+ // reading ("a moon shadow replaces 35 % of the bake too") lightens MCSH everywhere, including
+ // the ground BEYOND the cascade where the realtime lane resolves nothing and there would be no
+ // replacement for what was removed. The two also compose rather than double-count: the moon
+ // arm scales the light SUM (`primary`, above) and MCSH multiplies the result, and the moon arm
+ // is ≤ 0.35 of a term the bake is already a flat −30 % of. The visible consequence is that a
+ // tree at night wears its baked noon-sun shadow AND a faint moon-direction one — which is worth
+ // eyes on a capture before it is called wrong.
let world_shadow_lane = wow_light.sh_c16.w > 0.5;
let mcsh_suppress = select(0.0, sun_shadow_strength, world_shadow_lane);
let shadow_lit_eff = mix(shadow_lit, 1.0, mcsh_suppress);
diff --git a/crates/benilla-assets/src/shaders/wdl.wgsl b/crates/benilla-assets/src/shaders/wdl.wgsl
index 832600ba..c958710a 100644
--- a/crates/benilla-assets/src/shaders/wdl.wgsl
+++ b/crates/benilla-assets/src/shaders/wdl.wgsl
@@ -80,7 +80,7 @@ struct WowLight {
_light_sun: vec4, // 2
_light_spec: vec4, // 3
fog_color: vec4, // 4 rgb = Light.dbc row 7 (gamma 0..1); w = enable (>0.5 ⇒ blend)
- fog_params: vec4, // 5 x/y = the SCENE fog start/end yd (unread here — the hull has its own pair); z unused; w = farclip wall (0 ⇒ off)
+ fog_params: vec4, // 5 x/y = the SCENE fog start/end yd (unread here — the hull has its own pair); z = the signed directional-shadow weight (MONKEY moon shadows: +sun / -moon; unread here); w = farclip wall (0 ⇒ off)
};
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var w: WowLight;
diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl
index 0af0af19..cdaff568 100644
--- a/crates/benilla-assets/src/shaders/wow_model.wgsl
+++ b/crates/benilla-assets/src/shaders/wow_model.wgsl
@@ -123,7 +123,7 @@ struct WowLight {
light_sun: vec4, // xyz sun TRAVEL dir (to-light = −xyz); w = directional-light enable (>0.5)
light_spec: vec4, // rgb spec color; w = shininess (terrain's — unused by the matte model path)
fog_color: vec4, // rgb row-7 fog (gamma); w = enable (>0.5)
- fog_params: vec4, // x=start y=end z=linear-lighting A/B flag w=farclip wall
+ fog_params: vec4, // x=start y=end z=signed +sun / -moon shadow weight w=farclip wall
// The global Model2.bls SH rows (6-12): the scene day/night light as an order-2 probe at
// intensity 1 — DC (ambient, `.w` of c10) + the sun's linear/quad bands in c10.xyz / c13 /
// c16.xyz, the disassembled closed form (wow-re model2-bls-vertex-sh.md). Every sun band is
@@ -1800,9 +1800,14 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// MONKEY (shadow hook): the realtime shadow (fetch + edge/night fade) is computed by
// `benilla::shadow_hook`. This lane keeps only the rig-skin SAMPLE-POINT choice + the
// interior/rig exclusion + ambient-preserving apply below.
+ // MONKEY (moon shadows): the NIGHT arm of the same one fetch — 1.0 (inert) by day, by feature-off
+ // and in the sun-down/moon-not-yet-up window. See `shadow_hook::realtime_shadow_terms`.
var player_shadow = 1.0;
+ var player_moon = 1.0;
let view_z = (view.view_from_world * in.world_position).z;
let shadow_cam_dist = distance(in.world_position.xyz, view.world_position.xyz);
+ let sun_lane_w = shadow_hook::sun_shadow_w(wow_light.fog_params.z);
+ let moon_lane_w = shadow_hook::moon_shadow_w(wow_light.fog_params.z);
#ifdef WOW_RIG_SKIN
// A skinned UNIT samples the map ONCE at its rig origin (between the feet), nudged 2.5 units
// TOWARD the sun, and dims uniformly — the reference's per-unit response. Per-fragment sampling
@@ -1815,24 +1820,30 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
wow_light.rig_origin[(fade_tag >> 19u) & 0x7ffu].xyz - 2.5 * sun_ray,
1.0,
);
- player_shadow = shadow_hook::realtime_shadow(
+ let terms_rig = shadow_hook::realtime_shadow_terms(
anchor,
vec3(0.0, 1.0, 0.0),
view_z,
shadow_cam_dist,
wow_light.wmo_fog_params.z,
- wow_light.fog_params.z,
+ sun_lane_w,
+ moon_lane_w,
);
+ player_shadow = terms_rig.x;
+ player_moon = terms_rig.y;
}
#else
- player_shadow = shadow_hook::realtime_shadow(
+ let terms_frag = shadow_hook::realtime_shadow_terms(
in.world_position,
wow_normalize(in.world_normal),
view_z,
shadow_cam_dist,
wow_light.wmo_fog_params.z,
- wow_light.fog_params.z,
+ sun_lane_w,
+ moon_lane_w,
);
+ player_shadow = terms_frag.x;
+ player_moon = terms_frag.y;
#endif
// The realtime map blocks only the directional sun. Preserve the authored ambient/probe
// contribution instead of multiplying the whole lighting result; the latter makes interiors,
@@ -1844,7 +1855,7 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// arm keeps `worldShadows 0` (and a fully sunlit fragment) byte-identical: `ambient +
// (lit − ambient) × 1.0` is not guaranteed to round back to `lit`.
let shadow_term = mix(SHADOW_SUN_FLOOR, 1.0, player_shadow);
- let lit_with_shadow = select(
+ var lit_with_shadow = select(
lit,
clamp(
wow_light.light_ambient.rgb + (lit - wow_light.light_ambient.rgb) * shadow_term,
@@ -1853,6 +1864,16 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
),
!is_interior && !is_rig && player_shadow < 0.999,
);
+ // MONKEY (moon shadows): the night sky includes ambient AND the directional/SH lobe.
+ // Scale its UNCLAMPED value; points join below, then the combine saturates. A saturated
+ // torch must never lose energy to the moon. Interiors/authored rigs and the exact off path
+ // retain their old expressions; no multiply-by-one round trip on daylight or strength zero.
+ if (player_moon < 1.0 && !is_interior && !is_rig) {
+ let sky = select(wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb * ndotl,
+ sun_lobe, use_doodad_shade);
+ lit_with_shadow = sky * player_moon;
+ }
+
// Gamma-space albedo — the lane (0161): the buffer holds bytes, lighting math runs on the
// authored values. (The old fog_params.z linear-space A/B is dead — settled by the lane.)
// `m.tint` is the animated M2Color RGB (identity 1 for static batches) — the same per-batch
diff --git a/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs b/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs
index 92a8d614..4fedadfc 100644
--- a/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs
+++ b/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs
@@ -93,6 +93,33 @@ pub fn run(chain: &mut Chain, spell_id: u32) -> Result<()> {
}
}
}
+ // The AREA block (fields 11/12/13 — the DynamicObject machine, decision 0797): visual A is
+ // the persistent area model the dynobj instances verbatim (gated on field 11 ≠ 0), and the
+ // area kit's type-9 CharProc is the shard emitter. Printed because "what does Flamestrike
+ // actually put on the ground" is unanswerable from the five unit stages above — those are the
+ // CASTER's kit, and a ground effect's own model hangs off this row instead.
+ println!(
+ " area gate={} effect {:<5} -> {} kit {}",
+ stages.area_gate,
+ stages.area_effect,
+ visuals
+ .effect_path(stages.area_effect)
+ .unwrap_or("(none/MISSING PATH)"),
+ stages.area_kit,
+ );
+ if stages.area_kit != 0 {
+ match visuals.kit(stages.area_kit) {
+ Some(kit) => {
+ println!(
+ " areakit anim={:<12} sound={}",
+ kit.anim_id.map_or("—".into(), |a| a.to_string()),
+ kit.sound.map_or("—".into(), |s| s.to_string()),
+ );
+ crate::charprocs::print_kit_procs(&visuals, stages.area_kit, " ");
+ }
+ None => println!(" areakit {} (MISSING ROW)", stages.area_kit),
+ }
+ }
// The missile block (phase 4): the projectile exists whenever Speed > 0; its model is
// field 7's SpellVisualEffectName (else the ammo/ErrorCube fallbacks) and it homes to
// field 9's dest-attach ordinal on a live target.
diff --git a/crates/benilla-formats/src/fire_light.rs b/crates/benilla-formats/src/fire_light.rs
index b1f2cfb5..5a8eace2 100644
--- a/crates/benilla-formats/src/fire_light.rs
+++ b/crates/benilla-formats/src/fire_light.rs
@@ -829,7 +829,7 @@ impl SpellLightKind {
/// After the vetoes the luminous keys run. Note `FIRE` catches `FIREWORK`, `FIRECRACKER`,
/// `SOULFIRE`, `HELLFIRE`, `RAINOFFIRE`, `FIREBALL`, `FIREBOLT` and `FIREBLAST` in one entry,
/// which is why the list is this short: the artists named the corpus by school already.
-const SPELL_NAME_RULES: [(&str, SpellLightKind); 32] = [
+const SPELL_NAME_RULES: [(&str, SpellLightKind); 33] = [
// --- the dark schools, and the name collisions they must win ---
("LIGHTNING", SpellLightKind::None),
("STARFIRE", SpellLightKind::None),
@@ -863,6 +863,19 @@ const SPELL_NAME_RULES: [(&str, SpellLightKind); 32] = [
("HOLY", SpellLightKind::Holy),
("DIVINE", SpellLightKind::Holy),
("SMITE", SpellLightKind::Holy),
+ // MONKEY (area spell light): the hunter's Flare. `Holy` names the LIGHT FAMILY, not the spell's
+ // school (Flare is school 0, Physical) — a magnesium signal flare burns white-hot, and Holy is
+ // this enum's warm-white. Without the rule its own ramp decides, and `spell_color`'s
+ // most-saturated key on `Flare_State_Base` is the `(1.00, 0.37, 0.08)` ember the particle COOLS
+ // to, not the white core it is born at, so the one spell in 1.12 that exists to be a light
+ // would light the ground campfire-orange.
+ //
+ // The key is anchored to a PATH SEPARATOR (`\FLARE_`) rather than the bare word, because the
+ // bare word over-reaches in this corpus: `SPELLS\Infernal_Flare_Rec` (a warlock summon) and
+ // `SPELLS\Missile_Flare` (a generic projectile streak) both contain `FLARE` and neither is a
+ // signal flare. Only `SPELLS\Flare_Cast_Base` / `SPELLS\Flare_State_Base` — the cast arc and
+ // the burning ground object — start a path component with it.
+ ("\\FLARE_", SpellLightKind::Holy),
("FEL", SpellLightKind::Fel),
];
@@ -1112,6 +1125,129 @@ pub fn synthesize_spell_light<'a>(
})
}
+// ---------------------------------------------------------------------------------------------
+// MONKEY (area spell light): the PERSISTENT GROUND EFFECT arm of the spell route.
+//
+// A ground cast's visual is not the unit-kit lane at all: the server creates a TYPEID-6
+// DynamicObject and the client instances the `SpellVisual` row's own area model on it for the
+// object's whole life (`benilla_app::entities::dest_fx`). That lane is exactly the one the spell
+// light had no shape for — it spawned a `Burst`, i.e. an impact flash, on something that stands on
+// the ground for eight seconds, and then a shard flash 5× a second on top of it.
+//
+// The three questions this section answers for that lane, and why each one needed a rule of its
+// own rather than reusing [`synthesize_spell_light`]'s answer:
+//
+// 1. **Does it light?** Half the area models author NO particle emitter whatsoever — they are flat
+// animated ground DECALS (`Spells\Flamestrike_Impact_Base` and `spells\noname_area`, Explosive
+// Trap's patch, are pure geometry). The emitter route returns `None` for them, so the burning
+// ground of a Flamestrike could never light anything. [`area_light_kind`] is the fallback
+// ladder for exactly that case.
+// 2. **What colour?** See [`area_color`] — a POOL is read for seconds where a flash is read for a
+// frame, and the holy ramps in this corpus are ember art.
+// 3. **How far?** The wire `DYNAMICOBJECT_RADIUS` is the effect's real footprint (the shard
+// emitter spreads its particles over exactly it), so an area light sizes its pool from the
+// spell's own AoE rather than from its particles ([`area_reach`]).
+// ---------------------------------------------------------------------------------------------
+
+/// `Spell.dbc` field 1 `School` = **0, Physical** — a spell that has no magic school at all.
+///
+/// Called out because it is the one value that must NOT be read as "this school does not glow":
+/// a hunter's Flare, a Volley of arrows and every trap are school 0, and the three want opposite
+/// answers. School 0 therefore means "the column says nothing" ([`area_light_kind`]).
+pub const SPELL_SCHOOL_PHYSICAL: u32 = 0;
+
+/// The luminous family of a `Spell.dbc` **School** index (field 1 — an index, not a mask):
+/// `1 Holy`, `2 Fire`, and the rest (Nature 3, Frost 4, Shadow 5, Arcane 6) dark.
+///
+/// This is the owner's rule read off the data instead of guessed off a filename, and it is why the
+/// area lane needs almost no new name keys: Blizzard IS frost, Hurricane IS nature, Consecration IS
+/// holy and Explosive Trap IS fire, stated by the same table the damage comes from. It is also
+/// LOCALE-PROOF where matching the spell's NAME would not be — `Spell.dbc`'s name column is
+/// translated, its school column is not.
+///
+/// No `Fel` arm: 1.12 has no fel school (a warlock's spells are Fire or Shadow), so green fire is
+/// only ever named by a model path ([`SPELL_NAME_RULES`]'s `FEL`).
+pub fn spell_school_kind(school: u32) -> SpellLightKind {
+ match school {
+ 1 => SpellLightKind::Holy,
+ 2 => SpellLightKind::Fire,
+ _ => SpellLightKind::None,
+ }
+}
+
+/// The luminous family of a persistent AREA effect — `model_path` is the area model the
+/// DynamicObject instances, `spell_school` its spell's `Spell.dbc` school index, and `hue_kind` the
+/// verdict [`synthesize_spell_light`] already reached on that model (`None` when it reached none,
+/// i.e. no emitter or a vetoed one).
+///
+/// Three sources, most-authoritative first, and the order is the design:
+///
+/// 1. **The model's own path.** An artist who called the file `Flamestrike` or `FrostTrap` has
+/// stated the answer, and stated it about the very object that will be on the ground. This arm
+/// carries the VETOES too, so `Blizzard_Impact_Base` is dark here and can never be re-lit below.
+/// 2. **The spell's school**, whenever it is a real one. This is what lets a model with a
+/// meaningless name light correctly: Explosive Trap's patch is `spells\noname_area.m2`, which
+/// names nothing and emits nothing, and only the Fire school says it burns.
+/// 3. **The model's hue**, last, because it is the only guess in the list — and by the time we
+/// reach it both the artist and the DBC have declined to answer.
+pub fn area_light_kind(
+ model_path: &str,
+ spell_school: u32,
+ hue_kind: Option,
+) -> SpellLightKind {
+ if let Some(named) = spell_name_kind(model_path) {
+ return named;
+ }
+ if spell_school != SPELL_SCHOOL_PHYSICAL {
+ return spell_school_kind(spell_school);
+ }
+ hue_kind.unwrap_or(SpellLightKind::None)
+}
+
+/// An area light's colour: the model's own ramp ([`spell_color`], passed in as `ramp`) — **except
+/// for the HOLY family, which always takes [`DEFAULT_HOLY`]**.
+///
+/// The exception is the area lane's alone, and it exists because a POOL is read differently from a
+/// FLASH. Both holy ground effects in 1.12 author warm DUST: Consecration's strongest key is
+/// `(1.00, 0.72, 0.17)` and Flare's is `(1.00, 0.37, 0.08)`. Taken literally, a paladin's
+/// consecrated ground and a hunter's flare both put an orange pool on the terrain — which is
+/// indistinguishable from Flamestrike's, for seconds at a time, right next to it. An impact keeps
+/// its ramp (its whole life is a frame or two and the hue is the effect's signature); a light that
+/// stands still long enough to be compared has to be the colour its school reads as.
+pub fn area_color(kind: SpellLightKind, ramp: Option<[f32; 3]>) -> [f32; 3] {
+ match kind {
+ SpellLightKind::Holy => DEFAULT_HOLY,
+ _ => ramp.unwrap_or_else(|| kind.default_color()),
+ }
+}
+
+/// How high above the DynamicObject's own point an area light hangs (yd).
+///
+/// A dynobj sits ON the ground and its model is a flat decal, so a light at the object's exact
+/// position is a light INSIDE the terrain: the faithful falloff `1/(0.7d + 0.03d²)` blows up at
+/// `d → 0`, and every ground vertex within a step of the centre would clip to white while the ring
+/// two yards out got nothing. Lifting it to roughly waist height makes the pool read as a pool.
+pub const AREA_LIGHT_LIFT: f32 = 1.5;
+
+/// The intensity an area light takes when its model authors **no usable emitter at all** — the
+/// decal-only population (Flamestrike, Explosive Trap). `2.0` is the shared ladder's *brazier*
+/// rung ([`spell_intensity`]), which is where an 8-yard patch of burning ground belongs: a torch
+/// (1.5) is a hand-held flame and the 3.0 bonfire rung is reserved for a forge, per the spell
+/// route's own "a spell must not out-blaze a forge".
+pub const AREA_FALLBACK_INTENSITY: f32 = 2.0;
+
+/// An area light's interior reach (yd) from the wire `DYNAMICOBJECT_RADIUS`.
+///
+/// `× 1.6` because the light has to spill PAST the effect: a pool that stopped exactly at the AoE
+/// edge would draw a hard disc of lit terrain with a dark rim, where a real fire lights the ground
+/// around it more faintly than the ground under it. The floor stops a 1-yard dynobj from producing
+/// a light too small to notice, and the ceiling is the top rung of the shared reach ladder
+/// (`room_claim::m2_light_reach`) — an area effect may reach as far as a bonfire and no further,
+/// so a single spell can never out-light a building's own fixtures.
+pub fn area_reach(radius: f32) -> f32 {
+ (radius * 1.6).clamp(8.0, 24.0)
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -1850,4 +1986,143 @@ mod tests {
assert_eq!(spell_intensity(0.5, 200.0), (2.0, "brazier"));
assert_eq!(spell_intensity(1.1, 500.0), (2.0, "brazier"));
}
+
+ // -- MONKEY (area spell light) ---------------------------------------------------------
+
+ /// Vanilla `Spell.dbc` school indices, so the area tests below read as the spells they are.
+ const PHYSICAL: u32 = 0;
+ const HOLY: u32 = 1;
+ const FIRE: u32 = 2;
+ const NATURE: u32 = 3;
+ const FROST: u32 = 4;
+
+ /// GOLDEN — the whole named worklist, on the REAL `SpellVisual` field-12 area models each spell
+ /// actually instances on its DynamicObject (dumped with `benilla-extract spellvis `; the
+ /// `area` line). The third argument is what [`synthesize_spell_light`] independently reached on
+ /// that model, `None` for the decal-only half that authors no emitter at all — which is the
+ /// case the whole ladder exists for, and is Flamestrike's.
+ #[test]
+ fn the_ground_effects_that_light_and_the_ones_that_stay_dark() {
+ use SpellLightKind::{Fire as F, Holy as H, None as Dark};
+
+ // --- lights ---
+ // Flamestrike (2120): a flat animated burn decal — ZERO particle emitters, so the emitter
+ // route has nothing to say and the model's own name is the only source that does.
+ assert_eq!(
+ area_light_kind("Spells\\Flamestrike_Impact_Base.m2", FIRE, None),
+ F
+ );
+ // Rain of Fire (5740): five emitters, `FIRE` in the path — named and measured agree.
+ assert_eq!(
+ area_light_kind("Spells\\RainOfFire_Impact_Base.m2", FIRE, Some(F)),
+ F
+ );
+ // Consecration (26573): its one emitter ramps to `(1.00, 0.72, 0.17)`, which `spell_hue_kind`
+ // reads as FIRE. The school column is what corrects it — and the colour rule below is what
+ // stops the paladin's ground burning orange.
+ assert_eq!(
+ area_light_kind("spells\\consecration_impact_base.m2", HOLY, Some(F)),
+ H
+ );
+ // Explosive Trap (13812): the patch is `noname_area` — no emitters, and a filename that
+ // names nothing. ONLY the school says it burns.
+ assert_eq!(area_light_kind("spells\\noname_area.m2", FIRE, None), F);
+ // Flare (1543): school 0 Physical, so the school arm must abstain; the `\FLARE_` key is
+ // what makes the one spell that IS a light a light.
+ assert_eq!(
+ area_light_kind("spells\\flare_state_base.m2", PHYSICAL, Some(F)),
+ H
+ );
+
+ // --- dark, and each for its own reason ---
+ // Blizzard (10): `BLIZZ` vetoes in the path, and the school agrees. Its shards are this
+ // same model, so the whole storm stays dark.
+ assert_eq!(
+ area_light_kind("Spells\\Blizzard_Impact_Base.m2", FROST, None),
+ Dark
+ );
+ // Hurricane (16914): `LIGHTNING` in `LightningStorm_Cloud_State`.
+ assert_eq!(
+ area_light_kind("Spells\\LightningStorm_Cloud_State.m2", NATURE, None),
+ Dark
+ );
+ // Volley (1510): `ARCANE` in `arcaneshot_area` — and school 0, so the path veto is the
+ // ONLY thing holding it. That is the ordering being pinned.
+ assert_eq!(
+ area_light_kind("spells\\arcaneshot_area.m2", PHYSICAL, Some(F)),
+ Dark
+ );
+ // Frost Trap (13810), and Freezing Trap, which has no area object at all.
+ assert_eq!(
+ area_light_kind("spells\\frosttrap_aura.m2", FROST, None),
+ Dark
+ );
+ // 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);
+ // …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);
+ }
+
+ /// The `\FLARE_` key is anchored to a path separator so it takes the hunter's Flare and NOT the
+ /// two unrelated models that merely contain the word — the reason it is not the bare `FLARE`.
+ #[test]
+ fn the_flare_key_is_anchored_to_a_path_component() {
+ assert_eq!(
+ spell_name_kind("SPELLS\\Flare_State_Base.m2"),
+ Some(SpellLightKind::Holy)
+ );
+ assert_eq!(
+ spell_name_kind("SPELLS\\Flare_Cast_Base.m2"),
+ Some(SpellLightKind::Holy)
+ );
+ // A warlock summon and a generic projectile streak — neither is a signal flare, and both
+ // keep whatever their own art says.
+ assert_eq!(spell_name_kind("SPELLS\\Infernal_Flare_Rec.m2"), None);
+ assert_eq!(spell_name_kind("SPELLS\\Missile_Flare.m2"), None);
+ // `ArcaneConsecration_Impact_Base_Q` is the other collision in this corpus, and the veto
+ // block's position is what resolves it — `ARCANE` is read before any luminous key.
+ assert_eq!(
+ spell_name_kind("Spells\\ArcaneConsecration_Impact_Base_Q.m2"),
+ Some(SpellLightKind::None)
+ );
+ }
+
+ /// The colour rule: a fire pool keeps the artist's ramp, a holy pool never does.
+ #[test]
+ fn a_holy_pool_is_white_gold_whatever_its_dust_ramp_says() {
+ let ember = Some([1.0, 0.718, 0.173]);
+ assert_eq!(area_color(SpellLightKind::Fire, ember), [1.0, 0.718, 0.173]);
+ assert_eq!(area_color(SpellLightKind::Holy, ember), DEFAULT_HOLY);
+ // No ramp at all (the decal-only models) falls back to the family's own hue.
+ assert_eq!(area_color(SpellLightKind::Fire, None), DEFAULT_WARM);
+ assert_eq!(area_color(SpellLightKind::Fel, None), DEFAULT_FEL);
+ assert_eq!(area_color(SpellLightKind::Holy, None), DEFAULT_HOLY);
+ }
+
+ /// The reach ladder from the wire radius, and both clamps.
+ #[test]
+ fn area_reach_scales_the_wire_radius_between_its_clamps() {
+ // Flamestrike / Rain of Fire ship an 8 yd radius: the pool spills ~5 yd past the AoE.
+ assert!((area_reach(8.0) - 12.8).abs() < 1e-4);
+ assert!((area_reach(10.0) - 16.0).abs() < 1e-4);
+ // A tiny (or absent — the wire field is optional) radius still makes a visible pool…
+ assert_eq!(area_reach(0.0), 8.0);
+ assert_eq!(area_reach(2.0), 8.0);
+ // …and nothing reaches past the shared ladder's top rung.
+ assert_eq!(area_reach(20.0), 24.0);
+ assert_eq!(area_reach(500.0), 24.0);
+ }
+
+ /// The school column, read as the index it is. Only 1/2 light; 0 is "says nothing" and is
+ /// handled by [`area_light_kind`], not here.
+ #[test]
+ fn only_holy_and_fire_schools_glow() {
+ assert_eq!(spell_school_kind(0), SpellLightKind::None);
+ assert_eq!(spell_school_kind(1), SpellLightKind::Holy);
+ assert_eq!(spell_school_kind(2), SpellLightKind::Fire);
+ for dark in [3, 4, 5, 6, 99] {
+ assert_eq!(spell_school_kind(dark), SpellLightKind::None);
+ }
+ }
}
diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs
index 153c527f..18e767be 100644
--- a/crates/benilla-formats/src/lib.rs
+++ b/crates/benilla-formats/src/lib.rs
@@ -240,6 +240,14 @@ pub use fire_light::{synthesize_lamp_light, EmissiveBatch, LightRoute, Synthetic
// emitter, which the two above veto outright. Fire, holy and fel burn; frost, nature, arcane and
// shadow emit nothing at all.
pub use fire_light::{is_spell_light_path, synthesize_spell_light, SpellLightKind, SyntheticSpell};
+// MONKEY (area spell light): the same route's PERSISTENT-GROUND-EFFECT arm — a DynamicObject's
+// area model, which is routinely a flat decal with no emitter for the rule above to read, so its
+// school comes off the model path / the spell's `Spell.dbc` school and its pool is sized from the
+// wire AoE radius.
+pub use fire_light::{
+ area_color, area_light_kind, area_reach, spell_school_kind, AREA_FALLBACK_INTENSITY,
+ AREA_LIGHT_LIFT,
+};
// MONKEY (portal claims): which ROOMS one fixture may light -- containment, MOLR, and one portal
// hop. Shared by the runtime spawner and the `wmolights`/`wmolamps` audits so the gate the shader
// enforces and the gate the instrument prints can never be two different rules.
diff --git a/crates/benilla-world/src/lighting/global_light.rs b/crates/benilla-world/src/lighting/global_light.rs
index b7419fb0..54516196 100644
--- a/crates/benilla-world/src/lighting/global_light.rs
+++ b/crates/benilla-world/src/lighting/global_light.rs
@@ -15,6 +15,7 @@
//! rebuilds, regardless of how many tiles/models are loaded or how fast the clock moves.
use benilla_formats::LiquidKind;
+use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin};
use bevy::render::render_resource::{Buffer, BufferDescriptor, BufferUsages};
@@ -35,7 +36,11 @@ use crate::view::WorldCamera;
/// `wow_effect.wgsl`. (`liquid.wgsl`/`wdl.wgsl` reuse the field NAMES but bind their own
/// per-material uniforms fed by `apply_wow_lighting` — editing this layout does NOT reach them.)
/// 0 light_ambient (w=Mod2x 1.0) · 1 light_diffuse (w=clamp on) · 2 light_sun (w=dir/SH enable) ·
-/// 3 light_spec (w=terrain shininess 20) · 4 fog_color (w=enable) · 5 fog_params (x=start y=end w=farclip) ·
+/// 3 light_spec (w=terrain shininess 20) · 4 fog_color (w=enable) · 5 fog_params (x=start y=end
+/// w=farclip; MONKEY (moon shadows) `.z` = the SIGNED directional-shadow weight — `+sun_w`
+/// by day, `−(moon weight × moonShadowStrength)` at night, 0 when neither body casts. The
+/// two are mutually exclusive by [`moon_shadow_weight`], so one float carries both; see
+/// [`pack_shadow_lane`]) ·
/// 6-8 sh_c10_{r,g,b} · 9-11 sh_c13_{r,g,b} · 12 sh_c16 (.w = the world-shadow flag in the
/// integer part, MONKEY (bake floor) `interiorBakeFloor × interiorGain` in the fraction —
/// [`BAKE_LANE_SCALE`]) ·
@@ -603,6 +608,28 @@ impl Default for ShadowDistance {
}
}
+/// MONKEY (moon shadows): how dark a MOON-shadowed fragment gets at night (`moonShadowStrength`,
+/// 0..1, default 0.35), bridged from benilla-app's settings registry exactly as [`ShadowDistance`]
+/// is. `0.35` means a fully moon-shadowed fragment keeps `1 − 0.35 = 65 %` of the night sky (ambient + directional)
+/// term — a hint of a silhouette, not a daylight-hard shadow.
+///
+/// A dial rather than a constant because the honest answer to "how much light does a clear full moon
+/// throw" is *very little* (the reference client casts none at all), and the interesting range —
+/// "just enough that the world is not flat" to "stylised moonlight" — is entirely a taste question
+/// that wants to be answered with the scene on screen.
+///
+/// **`0` is the faithful null.** Every consumer guards on it (the packer refuses to hand the moon a
+/// non-zero weight, and the receivers keep their existing `< 0.999` early-out arm), so a night at
+/// `moonShadowStrength = 0` renders bit-identically to the build before this feature existed.
+#[derive(Resource, Clone, Copy, PartialEq, Debug)]
+pub struct MoonShadowStrength(pub f32);
+
+impl Default for MoonShadowStrength {
+ fn default() -> Self {
+ Self(0.35)
+ }
+}
+
/// MONKEY (sun shadow perf): the live `shadowFilter` choice, bridged from benilla-app's shadow rig
/// and extracted into the RENDER world.
///
@@ -675,6 +702,9 @@ pub(super) fn register(app: &mut App) {
.init_resource::()
.init_resource::()
.init_resource::()
+ // MONKEY (moon shadows): the night directional-shadow strength dial (0 = today's render).
+ .init_resource::()
+ .init_resource::()
// MONKEY (sun shadow perf): the live PCF choice, extracted for `static_gx`'s hand-rolled
// pipeline (every Bevy-material receiver keys off the view component instead).
.init_resource::()
@@ -761,11 +791,170 @@ pub(super) fn per_frame_blob_bytes() -> u64 {
/// MONKEY (night fade): realtime-shadow day strength from the celestial sun's height
/// (`sin(elevation)`): 0 at or below the horizon, ramping to 1 by ~12° so shadows fade out at dusk
/// and in at dawn. A smoothstep for a soft knee rather than a hard switch at the horizon.
-fn sun_shadow_strength(sun_height: f32) -> f32 {
+/// MONKEY (moon shadows): `pub` because the SHADOW RIG needs the same verdict the packer reaches.
+/// The rig (`benilla_app::shadow_core::manage_rig`) re-aims the one directional light at the moon
+/// exactly when this is 0, and the packer hands the moon a weight exactly when this is 0 — two
+/// copies of that threshold would be two chances to disagree about which body the map holds.
+/// (`blob_shadow.rs` keeps its own three-line mirror, pinned by its own test; that one predates
+/// this export and is deliberately self-contained.)
+pub fn sun_shadow_strength(sun_height: f32) -> f32 {
let t = (sun_height / 0.208).clamp(0.0, 1.0); // 0.208 ≈ sin(12°)
t * t * (3.0 - 2.0 * t)
}
+/// MONKEY (moon shadows): the NIGHT half of the one shadow rig's HAND-OVER LAW, in `[0,1]`.
+///
+/// `sun_height` / `moon_height` are `celestial_dir.y` / `moon_dir.y` — the SINE of each body's
+/// elevation, Bevy space. Returns how much the moon is allowed to cast, BEFORE
+/// [`MoonShadowStrength`] scales it.
+///
+/// **Why a hard gate and not a crossfade.** benilla's receivers sample ONE shadow-mapped
+/// directional light (the loop in `shadow_hook.wgsl` ASSIGNS — last light wins), so at any instant
+/// the map holds exactly one body's depth. A weight that overlapped the sun's would be a weight
+/// applied to the WRONG body's map for the duration of the overlap. So the moon's weight is
+/// strictly zero while the sun still has any, and the rig re-aims in the window where BOTH are
+/// zero — `sun_shadow_strength` is exactly 0 at and below the horizon, and this is exactly 0 until
+/// the moon clears the same 12° ramp.
+///
+/// **The gate never actually clips anything**, which is what makes it continuous rather than a
+/// step: with the shipped `DayNight` tables the celestial sun crosses the horizon at ≈20:30 and the
+/// white moon does not clear it until ≈22:17, so `sun_w` and the moon's elevation ramp are never
+/// both positive (asserted over the whole game day by
+/// `the_two_shadow_weights_never_overlap_and_neither_jumps`). The gate is therefore an INVARIANT
+/// guard, not a shaping term: it keeps the single-light rule true even if a zone or a retuned table
+/// ever put the two in the sky together.
+///
+/// The elevation ramp is [`sun_shadow_strength`]'s own curve, evaluated on the moon's height —
+/// deliberately the same smoothstep over the same 0..sin(12°) band, so a body low on the horizon
+/// casts nothing (which is also what keeps a moonrise shadow from stretching to infinity; the RIG
+/// clamps the basis at 18° for the same reason, `shadow_core::MIN_SHADOW_SUN_ELEVATION`).
+pub fn moon_shadow_weight(sun_height: f32, moon_height: f32) -> f32 {
+ if sun_shadow_strength(sun_height) > 0.0 {
+ return 0.0;
+ }
+ sun_shadow_strength(moon_height)
+}
+
+/// MONKEY (moon shadows): one map, one acknowledged body. The app requests the current clock's
+/// body before transform propagation, acknowledges an actual Transform write, then holds zero
+/// through the next frame. Both receivers and blobs read `weight`, never the desired clock weight.
+/// This makes a time jump / midnight enable as safe as the naturally dark dawn/dusk interval.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ShadowBody {
+ Sun,
+ Moon,
+}
+
+#[derive(Resource, Default)]
+pub struct ShadowHandover {
+ pub aimed: Option,
+ pub wanted: Option,
+ pub weight: f32,
+ settling: bool,
+ ramp: f32,
+}
+
+impl ShadowHandover {
+ /// Called ONCE per frame, after lighting resolve and the settings bridge. The rig runs before
+ /// Propagate; an acknowledgement from its preceding invocation has therefore propagated now.
+ pub fn request(&mut self, sun: f32, moon: f32, strength: f32, active: bool, dt: f32) {
+ let wanted = active.then_some(if strength > 0.0 && sun <= 0.0 {
+ ShadowBody::Moon
+ } else {
+ ShadowBody::Sun
+ });
+ if wanted != self.wanted || !active {
+ self.wanted = wanted;
+ self.ramp = 0.0;
+ self.weight = 0.0;
+ }
+ if !active {
+ self.aimed = None;
+ self.settling = false;
+ return;
+ }
+ if self.aimed != wanted {
+ self.weight = 0.0;
+ return;
+ }
+ if self.settling {
+ self.settling = false;
+ self.weight = 0.0;
+ return;
+ }
+ // Fixed envelope: 1.5 seconds from zero to full, independent of frame rate/strength.
+ 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),
+ None => 0.0,
+ };
+ }
+
+ /// Only the rig may acknowledge; choosing a body without writing a transform is not ready.
+ pub fn aim_written(&mut self, body: ShadowBody) {
+ if self.aimed != Some(body) {
+ self.aimed = Some(body);
+ self.settling = true;
+ self.ramp = 0.0;
+ self.weight = 0.0;
+ }
+ }
+}
+
+/// MONKEY (moon shadows): the three realtime-shadow dials [`build_light_data`] packs, as ONE
+/// system param.
+///
+/// A bundle rather than three `Res` arguments because the packer had reached Bevy's **16-param
+/// ceiling** — `MoonShadowStrength` was the seventeenth, and the failure is not a helpful one
+/// (`.chain()` reports that a trait bound is unsatisfied on the system TUPLE, naming neither the
+/// system nor the limit). Grouping them is also the honest shape: all three are bridged from the
+/// app's shadow rig, all three ride packed lanes, and a fourth shadow dial now has somewhere to go
+/// that does not cost the next reader an afternoon.
+#[derive(SystemParam)]
+pub(super) struct ShadowLanes<'w> {
+ /// MONKEY (world shadows): the `worldShadows` lane flag, packed into `sh_c16.w` for the MCSH gate.
+ world_active: Res<'w, WorldShadowActive>,
+ /// MONKEY (distance slider): the realtime-shadow render distance, packed for the edge fade.
+ distance: Res<'w, ShadowDistance>,
+ /// MONKEY (moon shadows): the acknowledged signed weight, shared with the rig and blob.
+ handover: Res<'w, ShadowHandover>,
+}
+
+/// MONKEY (moon shadows): the CPU half of the `fog_params.z` pack — ONE SIGNED lane carrying both
+/// directional-shadow weights.
+///
+/// **There is still no free f32** ([`LightStd430`] is 8528 B, pinned by tests and mirrored by three
+/// shaders plus the portrait booth — see [`DAYLIGHT_LANE_SCALE`] for the full accounting). The
+/// daylight floor took `wmo_fog_params.w`'s fraction and the bake floor took `sh_c16.w`'s, so the
+/// two lanes with spare RANGE are spent. This one needs neither: the hand-over law above guarantees
+/// the two weights are **never both non-zero**, so they can share a single lane by SIGN — the
+/// cheapest possible packing, exact in both directions, with no quantisation and no cliff.
+///
+/// `+w` = the sun's weight, `-w` = the moon's (already scaled by [`MoonShadowStrength`]), `0` =
+/// neither (the ≈20:30-22:17 window when the sun has set and the moon has not risen — and every
+/// frame of a build with `moonShadowStrength 0`).
+///
+/// **The shader end** decodes with `max(z, 0)` / `max(-z, 0)` (`shadow_hook.wgsl`'s `sun_shadow_w`
+/// / `moon_shadow_w`). Every pre-existing reader of this lane is either one of those two calls or
+/// already clamped — `clamp(1 - z, 0, 1)` (the three `ext_night_w` decodes) returns 1 for any
+/// negative `z`, which is the same 1 it returned for the 0 that used to be there, so the exterior
+/// torch lane is bit-identical under the new sign. **Keep in sync with those two WGSL functions.**
+pub fn pack_shadow_lane(sun_w: f32, moon_w: f32) -> f32 {
+ debug_assert!(
+ !(sun_w > 0.0 && moon_w > 0.0),
+ "the shadow rig holds ONE body's map: sun {sun_w} and moon {moon_w} cannot both cast"
+ );
+ sun_w - moon_w
+}
+
+/// MONKEY (moon shadows): the SHADER's two decodes, transcribed. Only exist for the round-trip test
+/// below; the real decodes live in `shadow_hook.wgsl`.
+#[cfg(test)]
+fn unpack_shadow_lane(w: f32) -> (f32, f32) {
+ (w.max(0.0), (-w).max(0.0))
+}
+
/// MONKEY (enclosed day floor): how [`DynamicInteriors::daylight`] rides to the shader — as the
/// FRACTIONAL part of the packed `wmo_fog_params.w` lane, `w = 1 + debug + daylight * this`.
///
@@ -1178,10 +1367,8 @@ fn build_light_data(
// disagree about which entry is which.
mut claims: ResMut,
time: Res,
- // MONKEY (world shadows): the `worldShadows` lane flag, packed into `sh_c16.w` for the MCSH gate.
- world_shadow: Res,
- // MONKEY (distance slider): the realtime-shadow render distance, packed for the edge fade.
- shadow_distance: Res,
+ // The three realtime-shadow dials, bundled (see [`ShadowLanes`]).
+ shadow: ShadowLanes,
// MONKEY (dynamic interiors): the interior lane's on/off + live knobs, packed for `static_gx.wgsl`.
dynamic_interiors: Res,
// MONKEY (fire GO lights): the live gain on synthesised fire lights (0 = the lane off).
@@ -1264,7 +1451,7 @@ fn build_light_data(
// time in exactly this way, three rows down). See [`BAKE_LANE_SCALE`] for why a fraction is
// invisible to the one `> 0.5` decode this lane has, and why the product is clamped first.
rows[12][3] = pack_bake_lane(
- world_shadow.0,
+ shadow.world_active.0,
dynamic_interiors.bake_floor,
dynamic_interiors.interior_gain,
);
@@ -1274,11 +1461,14 @@ fn build_light_data(
// sun still casts the right DIRECTION.
// MONKEY (darkness gains): computed once above — `nightGain` rides this exact same curve, so
// the dim and the shadow fade can never drift onto two different dusk clocks.
- rows[5][2] = sun_w;
+ // MONKEY (moon shadows): pack the rig's ACKNOWLEDGED weight, not the clock's desired one.
+ // The body may have changed abruptly this frame; the shared state holds zero until the new
+ // basis has propagated and then ramps. One signed float still keeps the buffer at 8528 bytes.
+ rows[5][2] = shadow.handover.weight;
// MONKEY (distance slider): the realtime-shadow render distance (yd), packed into the free
// `_wmo_fog[1].z` / `wmo_fog_params.z` lane (row 19). The receivers' edge fade reads it so the
// shadow fades at the cascade's actual `maximum_distance`, whatever the slider is set to.
- rows[19][2] = shadow_distance.0;
+ rows[19][2] = shadow.distance.0;
// MONKEY (dynamic interiors): the lane's on/off into the free `wmo_fog_params.w` lane (1 = WMO
// interior surfaces + props light from the room's live fixtures, 0 = the faithful baked path).
// Its three knobs ride `point_count.yzw`, packed with the table below.
@@ -1745,6 +1935,79 @@ mod tests {
}
}
+ // MONKEY (moon shadows): a CPU mirror of the receivers' order, including the exact off arm.
+ fn moon_combine(sky: f32, point: f32, strength: f32, shadow: f32) -> f32 {
+ let factor = 1.0 - strength * (1.0 - shadow);
+ if factor < 1.0 {
+ (sky * factor + point).clamp(0.0, 1.0)
+ } else {
+ (sky + point).clamp(0.0, 1.0)
+ }
+ }
+
+ #[test]
+ fn moon_shadows_preserve_saturated_points_and_attenuate_the_whole_sky() {
+ 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());
+ }
+ }
+
+ // 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) };
+ 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");
+ }
+ state.aim_written(body);
+ assert_eq!(state.weight, 0.0, "transform-write frame");
+ state.request(sun, moon, strength, true, 1.0 / 60.0);
+ assert_eq!(state.weight, 0.0, "propagation hold frame");
+ let mut previous = 0.0;
+ for _ in 0..91 {
+ state.request(sun, moon, strength, true, 1.0 / 60.0);
+ assert_eq!(state.aimed, state.wanted);
+ assert!(state.weight.abs() >= previous, "monotone hand-over ramp");
+ 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 });
+ }
+
+ #[test]
+ fn moon_shadow_clock_jumps_wait_for_the_right_basis() {
+ let mut state = ShadowHandover::default();
+ assert_shadow_transition(&mut state, ShadowBody::Sun, 0.35);
+ assert_shadow_transition(&mut state, ShadowBody::Moon, 0.35); // noon -> midnight
+ assert_shadow_transition(&mut state, ShadowBody::Sun, 0.35); // midnight -> noon
+ }
+
+ #[test]
+ fn moon_shadow_midnight_login_and_enable_wait_for_the_rig() {
+ let mut state = ShadowHandover::default();
+ assert_shadow_transition(&mut state, ShadowBody::Moon, 0.35); // login at midnight
+ state.request(0.0, 1.0, 0.35, false, 1.0);
+ assert_eq!(state.weight, 0.0);
+ assert_shadow_transition(&mut state, ShadowBody::Moon, 0.35); // enable rig at midnight
+ }
+
+ #[test]
+ fn moon_shadow_strength_zero_to_enabled_waits_for_the_moon() {
+ let mut state = ShadowHandover::default();
+ state.request(0.0, 1.0, 0.0, true, 0.0);
+ state.aim_written(ShadowBody::Sun);
+ state.request(0.0, 1.0, 0.0, true, 0.0);
+ state.request(0.0, 1.0, 0.0, true, 2.0);
+ assert_eq!(state.weight, 0.0);
+ assert_shadow_transition(&mut state, ShadowBody::Moon, 0.35);
+ }
+
/// GOLDEN — the **live exterior M2 response** (0803): `wow_model.wgsl`'s doodad/entity lane must
/// reproduce `E = A + I·D·(4/17)(0.375 + 2μ + 1.875μ²)` off the rows [`pack_model_core_rows`]
/// writes, with `A` NOT scaling by the per-instance intensity and every sun band scaling by it
@@ -1885,6 +2148,9 @@ mod tests {
.init_resource::()
.init_resource::()
.init_resource::()
+ // MONKEY (moon shadows): the packer's sixth dial resource.
+ .init_resource::()
+ .init_resource::()
.init_resource::()
.init_resource::()
.init_resource::()
@@ -2595,6 +2861,9 @@ mod tests {
celestial_dir: Vec3::new(0.0, celestial_y, 0.0),
..base
});
+ // 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::().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),
@@ -2627,6 +2896,194 @@ mod tests {
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
+ /// the REAL `DayNight` tables.
+ ///
+ /// Four properties, and each one is a way the feature breaks in a manner a screenshot cannot
+ /// diagnose:
+ ///
+ /// 1. **Never both.** benilla has exactly ONE shadow-mapped directional light (the receivers
+ /// ASSIGN over the light loop — last one wins), so the map holds one body's depth at a time.
+ /// Two non-zero weights at any minute would mean one of them was being applied to the OTHER
+ /// body's map, which renders as a shadow pointing the wrong way rather than as an error.
+ /// 2. **The gate never clips.** The moon's weight is hard-gated to zero while the sun has any
+ /// ([`moon_shadow_weight`]), and a hard gate biting on a non-zero value would be a STEP.
+ /// This asserts the gate is inert with the shipped tables — the moon's own elevation ramp is
+ /// already 0 everywhere the sun's is positive — so the gate is an invariant guard, not a
+ /// shaping term.
+ /// 3. **Continuity.** No minute-to-minute jump in either weight, at dusk or at dawn. The
+ /// threshold is deliberately loose (0.1 per game minute): the point is "no pop", not a
+ /// derivative bound, and the smoothsteps are far smoother than that.
+ /// 4. **The window is real and it is the blob's.** Between the sun's set and the moon's rise
+ /// NEITHER casts. That is not a bug to be crossfaded away — the moon is genuinely under the
+ /// horizon then — and the oval blob covers it at full strength (`blob_shadow::blob_weight`
+ /// of a zero lane is 1.0). The test pins the window's existence so a future table edit that
+ /// closed or inverted it has to say so here.
+ #[test]
+ fn the_two_shadow_weights_never_overlap_and_neither_jumps() {
+ let sample = |minute: f32| {
+ let sun_h = super::super::daynight::celestial_sun_direction(minute).y;
+ let moon_h = super::super::daynight::moon_direction(minute).y;
+ (
+ sun_shadow_strength(sun_h),
+ moon_shadow_weight(sun_h, moon_h),
+ // The moon's ramp BEFORE the gate — property 2 needs to see what the gate hid.
+ sun_shadow_strength(moon_h),
+ )
+ };
+ let (mut prev_sun, mut prev_moon, _) = sample(0.0);
+ let (mut dark_minutes, mut moon_minutes, mut sun_minutes) = (0u32, 0u32, 0u32);
+ for m in 1..=1440 {
+ let (sun, moon, ungated) = sample(m as f32);
+ assert!(
+ !(sun > 0.0 && moon > 0.0),
+ "minute {m}: both bodies cast (sun {sun}, moon {moon}) — the rig holds ONE map"
+ );
+ assert!(
+ !(sun > 0.0 && ungated > 0.0),
+ "minute {m}: the moon's elevation ramp ({ungated}) is live while the sun's is \
+ ({sun}) — the hand-over gate is now CLIPPING a non-zero value, i.e. it has become \
+ a step in the weight rather than a guard on the invariant"
+ );
+ assert!(
+ (sun - prev_sun).abs() < 0.1,
+ "minute {m}: the sun weight jumped {prev_sun} -> {sun}"
+ );
+ assert!(
+ (moon - prev_moon).abs() < 0.1,
+ "minute {m}: the moon weight jumped {prev_moon} -> {moon}"
+ );
+ if sun > 0.0 {
+ sun_minutes += 1;
+ } else if moon > 0.0 {
+ moon_minutes += 1;
+ } else {
+ dark_minutes += 1;
+ }
+ 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!(
+ dark_minutes > 60,
+ "the sun sets ~1h45m before the moon rises — the oval blob owns that window, and a \
+ zero here would mean the two bodies had been made to overlap: {dark_minutes} min"
+ );
+ }
+
+ /// MONKEY (moon shadows): the SHADER end of the signed lane, asserted against the shader TEXT.
+ ///
+ /// [`pack_shadow_lane`] and `shadow_hook.wgsl`'s `sun_shadow_w` / `moon_shadow_w` are two
+ /// declarations of ONE encoding, and the failure mode of a drift is not a build error: a
+ /// receiver that went back to reading `fog_params.z` raw would take a NEGATIVE sun weight at
+ /// night and BRIGHTEN every shadowed fragment (`1 − occ·negative > 1`), which reads as a
+ /// glowing patch under a tree, not as a broken decode. `static_gx.wgsl` is this crate's own
+ /// receiver, so it is the copy a `benilla-world` test can see; `terrain.wgsl` and
+ /// `wow_model.wgsl` are `benilla-assets`' and are covered by the naga probes.
+ #[test]
+ fn the_static_gx_receiver_decodes_the_signed_shadow_lane() {
+ let src = include_str!("../shaders/static_gx.wgsl");
+ for call in [
+ "shadow_hook::sun_shadow_w(wow_light.fog_params.z)",
+ "shadow_hook::moon_shadow_w(wow_light.fog_params.z)",
+ ] {
+ assert!(
+ src.contains(call),
+ "static_gx.wgsl no longer decodes the signed shadow lane through `{call}` — a raw \
+ read of `fog_params.z` takes the MOON's negative weight as the sun's and brightens \
+ what it should darken"
+ );
+ }
+ // …and the moon arm is still behind its early-out, which is the whole `moonShadowStrength 0`
+ // ⇒ bit-identical-night contract on this receiver.
+ assert!(
+ src.contains("if (world_moon < 1.0) {"),
+ "static_gx.wgsl's moon arm lost its `world_moon < 1.0` guard — every daylight and \
+ feature-off fragment now pays for (and may round through) the night subtraction"
+ );
+ }
+
+ /// GOLDEN — MONKEY (moon shadows): the SIGNED `fog_params.z` pack, both ends.
+ ///
+ /// The lane is one float carrying two mutually-exclusive weights by sign, so the round trip has
+ /// to be exact in both directions — a lossy pack here is a shadow that is silently the wrong
+ /// strength, or (worse) a moon weight leaking into the day arm. [`unpack_shadow_lane`] is the
+ /// transcription of `shadow_hook.wgsl`'s two decodes; if the WGSL and this drift, the assertion
+ /// that a moon frame reads zero on the sun's decode is the one that fires.
+ #[test]
+ fn the_signed_shadow_lane_round_trips_both_weights() {
+ for sun in [0.0f32, 0.25, 0.5, 1.0] {
+ assert_eq!(unpack_shadow_lane(pack_shadow_lane(sun, 0.0)), (sun, 0.0));
+ }
+ for moon in [0.0f32, 0.12, 0.35, 1.0] {
+ assert_eq!(unpack_shadow_lane(pack_shadow_lane(0.0, moon)), (0.0, moon));
+ }
+ // The pre-feature bits: a zero moon weight leaves the lane EXACTLY the sun's own value, so
+ // a `moonShadowStrength 0` build packs the float it always packed.
+ assert_eq!(pack_shadow_lane(1.0, 0.0), 1.0);
+ assert_eq!(pack_shadow_lane(0.0, 0.0), 0.0);
+ }
+
+ /// GOLDEN — MONKEY (moon shadows): the dial's two ends, through the REAL packer.
+ ///
+ /// `moonShadowStrength 0` must pack the byte-identical night frame the build before this
+ /// feature packed — that is the whole "guard it like the existing `world_shadow < 0.999` arm"
+ /// contract, and it is what stands between a faint moon shadow and a renderer whose night rows
+ /// no longer match three rounds of shading forensics. The non-zero arm then proves the strength
+ /// is folded CPU-side (so the receivers carry no extra uniform) and lands NEGATIVE, where the
+ /// sun's own decode (`max(z, 0)`) reads zero out of it.
+ #[test]
+ fn the_moon_strength_dial_is_packed_cpu_side_and_zero_restores_the_old_night() {
+ // 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})");
+ let pack = |strength: f32| {
+ let mut app = packer_app();
+ app.world_mut().insert_resource(WowLighting {
+ celestial_dir: Vec3::new(0.0, sun_h, 0.0),
+ moon_dir_white: Vec3::new(0.0, moon_h, 0.0),
+ ..default()
+ });
+ app.world_mut().insert_resource(MoonShadowStrength(strength));
+ let mut handover = app.world_mut().resource_mut::();
+ handover.request(0.0, 1.0, strength, true, 0.0);
+ 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();
+ app.world().resource::().0
+ };
+ let off = pack(0.0);
+ assert_eq!(
+ off.rows[5][2], 0.0,
+ "moonShadowStrength 0 must pack the pre-feature night lane, to the bit"
+ );
+ let on = pack(MoonShadowStrength::default().0);
+ assert_eq!(
+ on.rows[5][2], -0.35,
+ "a high moon at the shipped strength packs the FULL weight, negated"
+ );
+ assert_eq!(
+ unpack_shadow_lane(on.rows[5][2]),
+ (0.0, 0.35),
+ "the sun's decode reads zero out of a moon frame"
+ );
+ // Every other row is the same night frame either way — the dial reaches ONE lane.
+ for row in 0..LIGHT_HEADER_ROWS {
+ for c in 0..4 {
+ if (row, c) == (5, 2) {
+ continue;
+ }
+ assert_eq!(
+ on.rows[row][c], off.rows[row][c],
+ "row {row}.{c} moved with moonShadowStrength — the dial owns ONE lane"
+ );
+ }
+ }
+ }
+
/// GOLDEN — MONKEY (darkness gains): `interiorGain` scales all THREE of the room lane's inputs
/// and nothing on the exterior lane.
///
diff --git a/crates/benilla-world/src/lighting/mod.rs b/crates/benilla-world/src/lighting/mod.rs
index 75d39559..1847b087 100644
--- a/crates/benilla-world/src/lighting/mod.rs
+++ b/crates/benilla-world/src/lighting/mod.rs
@@ -41,6 +41,13 @@ pub use global_light::{
ShadowDistance, ShadowFilterGaussian, ShadowProxyLight, 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),
+// plus the two halves of the hand-over law the SHADOW RIG has to agree with the light packer
+// about — which body the one directional light is aimed at, and how much it may cast.
+pub use global_light::{
+ moon_shadow_weight, sun_shadow_strength, MoonShadowStrength, ShadowBody, ShadowHandover,
+};
// MONKEY (merge 2026-09-17): upstream's world-light component, adopted as THE world light.
pub use global_light::WorldPointLight;
// MONKEY (spellLightGain): the spell lane's marker + its live gain — the two-word world-side
@@ -204,6 +211,23 @@ impl WowLighting {
self.celestial_dir
}
+ /// MONKEY (moon shadows): the visible **white moon** direction (camera→moon, Bevy space) — the
+ /// body the one shadow rig re-aims at after dark.
+ ///
+ /// This is the renderer's REAL moon, not a stand-in for the sun: [`daynight::moon_direction`]
+ /// is its own `DayNight` track pair off `WoW.exe`'s sky-bodies builder (elevation table
+ /// `0xce8d24`, φ 35°↔100° — overhead at midnight, parked 10° under the horizon 04:00→22:00).
+ /// Its AZIMUTH, however, is the constant 45° the celestial sun also uses (table `0xce8d0c`), so
+ /// a moon shadow falls along the same compass line a midday sun shadow does, only from a lower
+ /// and slower-moving elevation. That is the reference's own geometry, not an approximation
+ /// here.
+ ///
+ /// Exposed beside [`Self::celestial_dir`] and for the same reason: the shadow rig lives in
+ /// `benilla-app` and must aim at the same body the light packer is weighting.
+ pub fn moon_dir(&self) -> Vec3 {
+ self.moon_dir_white
+ }
+
/// Per-kind **water swatch endpoints**: `(shallow_rgb, deep_rgb, shallow_alpha, deep_alpha)`. These
/// are the ENDPOINTS; the ramp between them is a 64-row byte-space accumulator that `liquid.wgsl`
/// reproduces (`swatch_row`), not the plain lerp this doc used to describe — it stops one row short
diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl
index 3908854f..905829f0 100644
--- a/crates/benilla-world/src/shaders/static_gx.wgsl
+++ b/crates/benilla-world/src/shaders/static_gx.wgsl
@@ -1459,18 +1459,25 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 {
// fade, via `benilla::shadow_hook`) — now with the same fades as terrain + models. INTERIOR
// batches (`WORD_INTERIOR`) take the point-light (torch) shadow instead (#2): no sun reaches a
// sealed room, but a promoted torch does. Both are no-ops when their light source is absent.
+ // MONKEY (moon shadows): the NIGHT arm rides the SAME fetch and the SAME `WORD_INTERIOR` gate —
+ // 1.0 (inert) on every interior batch, all day, and with the feature off. Interiors stay
+ // untouched at both ends of the clock: a sealed room has no sky, so it has no moon either.
var world_shadow = 1.0;
+ var world_moon = 1.0;
if ((in.word & WORD_INTERIOR) == 0u) {
let view_z = (view.view_from_world * in.world_position).z;
let cam_dist = distance(in.world_position.xyz, view.world_position.xyz);
- world_shadow = shadow_hook::realtime_shadow(
+ let terms = shadow_hook::realtime_shadow_terms(
in.world_position,
n_lit,
view_z,
cam_dist,
wow_light.wmo_fog_params.z,
- wow_light.fog_params.z,
+ shadow_hook::sun_shadow_w(wow_light.fog_params.z),
+ shadow_hook::moon_shadow_w(wow_light.fog_params.z),
);
+ world_shadow = terms.x;
+ world_moon = terms.y;
} else {
world_shadow = shadow_hook::torch_shadow(in.world_position, n_lit);
}
@@ -1480,7 +1487,7 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 {
// `world_shadow < 0.999` arm keeps `worldShadows 0` (no shadow-mapped light) and a fully
// sunlit fragment byte-identical: `ambient + (lit − ambient) × 1.0` is not guaranteed to
// round back to `lit`.
- let lit_nl_shadowed = select(
+ var lit_nl_shadowed = select(
lit_nl,
clamp(
wow_light.light_ambient.rgb + (lit_nl - wow_light.light_ambient.rgb) * shadow_term,
@@ -1489,6 +1496,14 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 {
),
world_shadow < 0.999,
);
+ // MONKEY (moon shadows): shadow the unclamped exterior SKY, ambient AND directional.
+ // Points join later, before the final saturation, so a hot torch stays hot beneath a canopy.
+ // world_moon is exactly one indoors and when off; leave those arithmetic paths untouched.
+ if (world_moon < 1.0) {
+ lit_nl_shadowed = (wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb * ndotl)
+ * world_moon;
+ }
+
// The order-2 SH basis products over the fragment normal — shared by the exterior
// doodad lobe and the interior-prop probe lane (wow_model.wgsl computes them once too).
let quad = vec4(n_lit.x * n_lit.y, n_lit.y * n_lit.z, n_lit.z * n_lit.z, n_lit.x * n_lit.z);
@@ -1992,7 +2007,7 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 {
wow_light.sh_c10_b.w,
);
let lit_doodad_plain = clamp(sun_lobe, vec3(0.0), vec3(1.0));
- let lit_doodad = select(
+ var lit_doodad = select(
lit_doodad_plain,
clamp(
sun_ambient + (sun_lobe - sun_ambient) * shadow_term,
@@ -2001,6 +2016,11 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 {
),
world_shadow < 0.999,
);
+ // MONKEY (moon shadows): retained ADT doodads and exterior MODD props select this SH
+ // branch, not lit_nl_shadowed. Shadow the whole sky lobe before points/clamp here too.
+ if (world_moon < 1.0) {
+ lit_doodad = sun_lobe * world_moon;
+ }
// Sun disabled (light_sun.w) falls back to the FFP matte, like the entity path.
let lit = select(lit_nl_shadowed, lit_doodad, wow_light.light_sun.w > 0.5);
// FFP combine: the light sum saturates FIRST, the texture (× the baked constant
diff --git a/crates/benilla-world/src/shaders/wow_effect.wgsl b/crates/benilla-world/src/shaders/wow_effect.wgsl
index 1b945d5a..201dd5ea 100644
--- a/crates/benilla-world/src/shaders/wow_effect.wgsl
+++ b/crates/benilla-world/src/shaders/wow_effect.wgsl
@@ -31,7 +31,10 @@ struct WowLight {
light_sun: vec4,
light_spec: vec4,
fog_color: vec4, // rgb row-7 fog (gamma); w = enable (>0.5)
- fog_params: vec4, // x=start y=end z=linear-lighting A/B flag w=farclip wall
+ // MONKEY (moon shadows): `.z` is the SIGNED directional-shadow weight (+sun / -moon, see
+ // `global_light::pack_shadow_lane`) - unread here, but the old "linear-lighting A/B flag"
+ // note was stale twice over and a lane this shader mirrors should not misname it.
+ fog_params: vec4, // x=start y=end z=signed shadow weight w=farclip wall
sh_c10_r: vec4,
sh_c10_g: vec4,
sh_c10_b: vec4,