diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index 94f1d48e..1431e746 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -432,6 +432,24 @@
.. "the processor, so it is close to free on any modern card.\n\n"
.. "Applies when you click Apply."
+ -- 1.12's OPTION_TOOLTIP_TRILINEAR is about a setting that needed a restart, and says
+ -- nothing about the one above it outranking it — which is the only thing a player seeing
+ -- the row greyed out needs told.
+ BENILLA_TOOLTIP_TRILINEAR =
+ "Blends between the two nearest levels of detail on a texture instead of snapping "
+ .. "between them, which removes the visible band where one takes over from the next.\n\n"
+ .. "Anisotropic Filtering already does this, so this setting does nothing while that "
+ .. "one is on. Costs almost nothing either way. Applies when you click Apply."
+
+ -- 1.12's OPTION_TOOLTIP_MULTISAMPLING describes the colour/depth/sample triple its own
+ -- dropdown showed. Ours names only the sample count, and has its own caveat to give.
+ BENILLA_TOOLTIP_MULTISAMPLING =
+ "Smooths the jagged edges of world geometry by sampling each pixel several times.\n\n"
+ .. "It does not smooth the edges inside a texture, which is where most of this game's "
+ .. "foliage and fencing lives, so it does less here than in a modern game and costs "
+ .. "more. Render Scale above 100% is usually the better trade.\n\n"
+ .. "Takes effect the next time you start the game."
+
-- 1.12 has no such checkbox.
BENILLA_TOOLTIP_BACKGROUND_SOUND =
"Keeps the game's sound playing while you are working in another window.\n\n"
@@ -518,6 +536,15 @@
shadowDistance = "Shadow Distance",
shadowResolution = "Shadow Resolution",
moonShadows = "Moon Shadows",
+ -- GFX (shadow rig): the four shadow rows a player could not reach, and the torch
+ -- darkness dial beside the softness one.
+ shadowFilter = "Soft Shadow Edges",
+ shadowCasterReach = "Shadow Caster Range",
+ characterShadowRate = "Character Shadow Updates",
+ worldShadowRate = "World Shadow Updates",
+ torchShadowStrength = "Torch Shadow Darkness",
+ everyFrame = "Every frame",
+ hz = "%d per second",
interiorLight = "Dynamic Interior Lighting",
interiorShadows = "Indoor Torch Shadows",
exteriorShadows = "Outdoor Torch Shadows",
@@ -652,6 +679,36 @@
.. "bank between a fire and the slope behind it blocks the fire's light.\n\n"
.. "Adds the nearby terrain to each outdoor fire's shadow map when it is "
.. "rebuilt; the per-frame cost is small. Needs Outdoor Torch Shadows.",
+ SHADOW_FILTER =
+ "Softens the edge of every sun and moon shadow by taking nine samples of "
+ .. "the shadow map instead of the graphics card's own four.\n\n"
+ .. "Off is cheaper and leaves a harder, more stair-stepped edge, which is "
+ .. "most visible on a large shadow close to you.",
+ SHADOW_CASTER_RANGE =
+ "How far out objects are still collected to cast sun and moon shadows. "
+ .. "100% is the normal range.\n\n"
+ .. "Lower drops distant casters first, which costs frame time rather than "
+ .. "picture over most of the screen; higher keeps shadows further away at "
+ .. "the cost of collecting and drawing more of them every update.",
+ CHARACTER_SHADOW_UPDATES =
+ "How often a character's shadow is rebuilt from its current pose, in times "
+ .. "per second.\n\n"
+ .. "The shadow still moves with the character at any setting - this is how "
+ .. "often its SHAPE catches up with the animation. Lower is cheaper and "
+ .. "slightly behind in fast movement; Every frame is exact and costs the "
+ .. "most.",
+ WORLD_SHADOW_UPDATES =
+ "How often the world's own shadow casters - trees, buildings, props - are "
+ .. "re-collected, in times per second.\n\n"
+ .. "The world does not move, so this mostly matters while you do: lower "
+ .. "means newly visible scenery takes a moment to start casting. Every "
+ .. "frame is exact and costs the most.",
+ TORCH_SHADOW_DARKNESS =
+ "How much light a torch shadow takes away. At 100% the shadow is fully "
+ .. "black.\n\n"
+ .. "The default leaves some light standing, because this renderer has no "
+ .. "bounce light to fill a shadow back in - at full strength the shadows "
+ .. "read as scars cut into the floor rather than as shadows.",
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 "
@@ -786,6 +843,14 @@
shadowDistance = "Дальность теней",
shadowResolution = "Разрешение теней",
moonShadows = "Лунные тени",
+ -- GFX (shadow rig)
+ shadowFilter = "Мягкие края теней",
+ shadowCasterReach = "Дальность источников теней",
+ characterShadowRate = "Обновление теней персонажей",
+ worldShadowRate = "Обновление теней мира",
+ torchShadowStrength = "Темнота теней от огня",
+ everyFrame = "Каждый кадр",
+ hz = "%d в секунду",
interiorLight = "Динамическое освещение помещений",
interiorShadows = "Тени факелов в помещениях",
exteriorShadows = "Тени огней под открытым небом",
@@ -929,6 +994,34 @@
.. "Добавляет ближний рельеф в карту теней каждого уличного огня при её "
.. "перестройке; затраты на кадр невелики. Требует теней огней под "
.. "открытым небом.",
+ SHADOW_FILTER =
+ "Смягчает край каждой тени от солнца и луны: девять выборок карты теней "
+ .. "вместо четырёх, которые делает сама видеокарта.\n\n"
+ .. "«Выкл.» дешевле и оставляет более резкий, ступенчатый край — заметнее "
+ .. "всего на большой тени вблизи.",
+ SHADOW_CASTER_RANGE =
+ "Насколько далеко ещё собираются объекты, отбрасывающие тени от солнца и "
+ .. "луны. 100% — обычная дальность.\n\n"
+ .. "Меньше — первыми отсекаются далёкие источники теней: это экономит "
+ .. "время кадра, почти не меняя картинку. Больше — тени видны дальше, но "
+ .. "каждое обновление собирает и рисует их больше.",
+ CHARACTER_SHADOW_UPDATES =
+ "Сколько раз в секунду тень персонажа перестраивается по его текущей позе."
+ .. "\n\nПри любом значении тень движется вместе с персонажем — речь о "
+ .. "том, как часто её ФОРМА догоняет анимацию. Реже — дешевле и немного "
+ .. "отстаёт при быстром движении; «Каждый кадр» точнее всего и дороже "
+ .. "всего.",
+ WORLD_SHADOW_UPDATES =
+ "Сколько раз в секунду заново собираются источники теней самого мира — "
+ .. "деревья, здания, предметы.\n\n"
+ .. "Мир неподвижен, поэтому это важно в основном при вашем движении: реже "
+ .. "— и появившееся в поле зрения начинает отбрасывать тень не сразу. "
+ .. "«Каждый кадр» точнее всего и дороже всего.",
+ TORCH_SHADOW_DARKNESS =
+ "Сколько света убирает тень от огня. При 100% тень полностью чёрная.\n\n"
+ .. "По умолчанию часть света остаётся: этому движку нечем подсветить тень "
+ .. "отражённым светом, и на полной силе тени выглядят как шрамы на полу, "
+ .. "а не как тени.",
TORCH_SOFT =
"Насколько мягок край тени от огня там, где предмет касается пола. "
.. "Низкое значение — резкий край, высокое — размытый, как от "
@@ -1063,9 +1156,11 @@
},
Graphics = {
"RowUiScale", "RowFarclip", "RowWorldDetail", "RowBrightness",
- -- GFX (anisotropic): index 7 of OptionsFrameSliders, in that table's own order.
- "RowAnisotropic", "RowWeatherDensity",
- "RowDisplayMode", "RowResolution", "RowVerticalSync",
+ -- GFX (anisotropic): index 7 of OptionsFrameSliders, in that table's own order,
+ -- with the other half of the same filter policy under it.
+ "RowAnisotropic", "RowTrilinear", "RowWeatherDensity",
+ -- GFX (multisampling): with the other latched `gx*` rows it is committed beside.
+ "RowDisplayMode", "RowResolution", "RowVerticalSync", "RowMultisampling",
"RowRenderScale",
},
-- MONKEY (advanced graphics): the dynamic light + shadow system's own page. Order is
@@ -1113,6 +1208,14 @@
"RowZoneSkyboxes",
-- GFX (volumetric light) / (moonlight): appended so the other rows keep their indices.
"RowVolumetricLight", "RowVolumetricLightStrength", "RowMoonLight",
+ -- GFX (shadow rig): the five shadow rows a player could not reach — the sun/moon
+ -- filter, the caster reach, the two update-rate caps and the torch darkness.
+ -- Appended, as the two lanes above were, so the other rows keep their indices;
+ -- this list drives refresh, search and Restore Defaults, while the page's visible
+ -- order is the frames' own anchors, which seat each row beside what it belongs to.
+ "RowShadowFilter", "RowShadowCasterReach",
+ "RowCharacterShadowRate", "RowWorldShadowRate",
+ "RowTorchShadowDarkness",
},
-- Guild Names is a child of Player Names (UIOptionsFrame.lua:703-709).
Nameplates = {
@@ -1331,6 +1434,24 @@
row.entries = entries
end
+ -- The same shape as the resolution rebuild above, over the reference's own triple list:
+ -- `GetMultisampleFormats()` returns {colorBits, depthBits, samples} flat, three at a time
+ -- (`0x48c360`), and the row stores the 1-based index `SetMultisampleFormat` takes. Only the
+ -- sample count is named: the colour and depth halves of each triple describe OUR swapchain
+ -- and steer nothing (`cvars.rs`, the `gxColorBits`/`gxDepthBits` rows), so showing them
+ -- would offer a player a choice they do not have.
+ function OptionsMultisampleRow_Rebuild(row)
+ local v = { GetMultisampleFormats() }
+ local entries = {}
+ for i = 1, table.getn(v), 3 do
+ local samples = v[i + 2]
+ local text = BENILLA_ADVGFX.qOff
+ if samples and samples > 1 then text = samples .. "x" end
+ table.insert(entries, { text = text, value = tostring((i - 1) / 3 + 1) })
+ end
+ row.entries = entries
+ end
+
function OptionsDropdownRow_InitMenu()
local dd = getglobal(UIDROPDOWNMENU_INIT_MENU)
local row = dd:GetParent()
@@ -1451,6 +1572,14 @@
if row.fmt == "yd" then
return string.format(BENILLA_ADVGFX.yards, math.floor(value + 0.5))
end
+ -- GFX (shadow rig): an update RATE, where 0 is not "none" but the opposite — no cap
+ -- at all, every frame (`shadow_core::MAX_SHADOW_RATE`'s doc). "0 per second" would
+ -- read as the shadows being switched off, which is what the two toggles above are for.
+ if row.fmt == "hz" then
+ local n = math.floor(value + 0.5)
+ if n <= 0 then return BENILLA_ADVGFX.everyFrame end
+ return string.format(BENILLA_ADVGFX.hz, n)
+ end
-- The anisotropy groove. 1 is not "1x", it is the feature OFF (`tex_filter.rs`:
-- the reference writes filter mode 5 only at 2 and above), and a player reading "1x"
-- next to "2x" would take it for the weakest setting rather than for none.
@@ -1719,11 +1848,24 @@
or GetCVar("worldShadows") ~= "0"
OptionsRow_SetEnabled(getglobal(base .. "RowShadowDistance"), sun)
OptionsRow_SetEnabled(getglobal(base .. "RowShadowResolution"), sun)
+ -- GFX (shadow rig): the filter and the caster reach size and sample the same
+ -- cascade the two rows above do, so they ride the same master.
+ OptionsRow_SetEnabled(getglobal(base .. "RowShadowFilter"), sun)
+ OptionsRow_SetEnabled(getglobal(base .. "RowShadowCasterReach"), sun)
+ -- The two rate caps are per-LANE, not per-cascade: each is dead only while its
+ -- own lane is, which is a tighter rule than `sun` and the honest one.
+ OptionsRow_SetEnabled(getglobal(base .. "RowCharacterShadowRate"),
+ GetCVar("characterShadows") ~= "0")
+ OptionsRow_SetEnabled(getglobal(base .. "RowWorldShadowRate"),
+ GetCVar("worldShadows") ~= "0")
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)
+ -- GFX (shadow rig): the darkness dial is the softness dial's twin — same shadow,
+ -- same master.
+ OptionsRow_SetEnabled(getglobal(base .. "RowTorchShadowDarkness"), torch)
-- MONKEY (daylight: terrain torch casters): the ground only casts for outdoor fires.
OptionsRow_SetEnabled(getglobal(base .. "RowTorchTerrainShadows"),
GetCVar("exteriorShadows") ~= "0")
@@ -1732,6 +1874,22 @@
GetCVar("foliageWind") ~= "0")
return
end
+ if key == "Graphics" then
+ -- GFX (anisotropic): the filter policy's own rule, not an invented one. Aniso at 2
+ -- or more writes mode 5 and trilinear writes mode 4 only while that bit is clear
+ -- (`0x688d0d`, `tex_filter.rs`), so with anisotropy on the trilinear row changes
+ -- nothing. Greyed rather than hidden, and the CVar keeps its value throughout:
+ -- turning anisotropy back off finds it exactly where it was left.
+ --
+ -- Read off the STAGED value where there is one, since both rows are deferred — a
+ -- player who sets anisotropy and has not pressed Apply yet should see the row it
+ -- is about to gate go grey, not on the following visit.
+ local row = BenillaOptionsFrameContainerBodyGraphicsRowAnisotropic
+ local aniso = tonumber(row.pending or OptionsRow_Get(row) or "1") or 1
+ OptionsRow_SetEnabled(BenillaOptionsFrameContainerBodyGraphicsRowTrilinear,
+ aniso < 2)
+ return
+ end
if key == "Controls" then
-- UIOptionsFrame.lua:717-721: dead while the style is Never, "0" or a stray "3".
local style = GetCVar("cameraSmoothStyle")
@@ -2860,12 +3018,35 @@
self.deferred = 1
+
+
+
+
+
+
+ -- The other half of the filter policy
+ -- (`tex_filter.rs`), under the row that outranks
+ -- it: `anisotropic` at 2 or more writes mode 5 and
+ -- this writes mode 4 only while that bit is clear
+ -- (`0x688d0d`). So the row is greyed, not hidden,
+ -- when anisotropy is on — it still holds the value
+ -- a player set, and turning anisotropy off hands
+ -- it back.
+ OptionsRow_OnLoad(self, "trilinear", "Trilinear Filtering",
+ "BENILLA_TOOLTIP_TRILINEAR")
+ -- Deferred for the same reason its neighbour is:
+ -- the commit re-samples every resident texture,
+ -- and a player changing both rows should pay that
+ -- once rather than twice.
+ self.deferred = 1
+
+
-
-
+
+
OptionsRow_OnLoad(self, "weatherDensity", "Weather Intensity",
@@ -2952,11 +3133,45 @@
labels, same descriptions, same deferred commit on the
slider — because they are three rows of one rig whose
other fifteen a player could not reach at all. -->
-
+
+
+ -- 1.12 had this row and benilla did not, so the
+ -- only way to multisampling was `/console
+ -- gxMultisample 4`. Backed by the index verbs the
+ -- reference's own dropdown uses — `0x48c580` /
+ -- `0x48c640` — not by `gxMultisample` directly,
+ -- because picking a format writes the colour and
+ -- depth rows with it, as one record.
+ --
+ -- `rebuild` for the same reason the resolution row
+ -- has one: at OnLoad the engine has published no
+ -- formats yet, so a list baked now is empty for
+ -- the whole session.
+ self.rebuild = OptionsMultisampleRow_Rebuild
+ -- Latched (`cvars.rs`, flags 3), so a write only
+ -- stages host-side until `RestartGx` — which is
+ -- what Apply calls. Without this the row would
+ -- write through, no Apply button would appear, and
+ -- the setting would look like it did not persist:
+ -- exactly the bug Vertical Sync had.
+ self.deferred = 1
+ OptionsNumFuncRow_OnLoad(self,
+ GetCurrentMultisampleFormat, SetMultisampleFormat,
+ "1", "Multisampling",
+ "BENILLA_TOOLTIP_MULTISAMPLING")
+ OptionsMultisampleRow_Rebuild(self)
+ OptionsDropdownRow_Setup(self, self.entries)
+
+
+
+
+
+
+
-- Deviation: our own row (the reference draws to
-- the backbuffer), and no Multisampling, which
@@ -3239,11 +3454,81 @@
})
-
+
+
+ -- 0 is the card's own 2x2 comparison (one
+ -- sample), 1 the 9-tap gaussian — the whole
+ -- `shadowFilter` ladder (`MAX_SHADOW_FILTER` is 1),
+ -- which is why this is a checkbox and not the
+ -- dropdown Shadow Resolution gets. Bevy's third
+ -- mode, Temporal, is deliberately not offered: it
+ -- only resolves under TAA, which this camera does
+ -- not run, so it would read as noise.
+ OptionsRow_OnLoad(self, "shadowFilter",
+ BENILLA_ADVGFX.shadowFilter,
+ "BENILLA_TOOLTIP_SHADOW_FILTER")
+
+
+
+
+
+
+
+
+ -- `CASTER_REACH_RANGE`, 0.25..2. The floor is not
+ -- 0 on purpose: a 0 reach admits no caster at all
+ -- and would read as "shadows broke", which is what
+ -- the two toggles above are for. No `fmt`, so the
+ -- default percent formatting applies and the
+ -- untouched reach law reads as 100%.
+ OptionsRow_OnLoad(self, "shadowCasterReach",
+ BENILLA_ADVGFX.shadowCasterReach,
+ "BENILLA_TOOLTIP_SHADOW_CASTER_RANGE")
+ OptionsSliderRow_Setup(self, 0.25, 2.0, 0.05)
+ -- DEFERRED: a drag re-collects the whole caster
+ -- set on every step.
+ self.deferred = 1
+
+
+
+
+
+
+
+
+ -- `MAX_SHADOW_RATE`, 0..120, where 0 is "every
+ -- frame" and not "never" — see the `hz` format.
+ -- Step 10: the knob is a coarse budget dial, and
+ -- 121 stops of a 1 Hz grid is not a choice anyone
+ -- makes.
+ OptionsRow_OnLoad(self, "characterShadowRate",
+ BENILLA_ADVGFX.characterShadowRate,
+ "BENILLA_TOOLTIP_CHARACTER_SHADOW_UPDATES")
+ OptionsSliderRow_Setup(self, 0, 120, 10, "hz")
+
+
+
+
+
+
+
+
+ -- The world lane's twin of the row above.
+ OptionsRow_OnLoad(self, "worldShadowRate",
+ BENILLA_ADVGFX.worldShadowRate,
+ "BENILLA_TOOLTIP_WORLD_SHADOW_UPDATES")
+ OptionsSliderRow_Setup(self, 0, 120, 10, "hz")
+
+
+
+
+
+
+
-- 0..1, the CVar's own clamp, and 0 is the FAITHFUL
-- null rather than a floor: every consumer guards
@@ -3317,11 +3602,29 @@
OptionsSliderRow_Setup(self, 0.5, 3, 0.1)
-
+
+
+ -- Beside the softness dial, since the two shape
+ -- the same shadow: how wide its edge is, and how
+ -- much light it removes. 0..1, the CVar's own
+ -- clamp; the shipped 0.7 leaves 30% of the direct
+ -- term standing in place of the bounce light this
+ -- renderer does not have.
+ OptionsRow_OnLoad(self, "torchShadowStrength",
+ BENILLA_ADVGFX.torchShadowStrength,
+ "BENILLA_TOOLTIP_TORCH_SHADOW_DARKNESS")
+ OptionsSliderRow_Setup(self, 0, 1, 0.05)
+
+
+
+
+
+
+
-- Full 0..4 registry range, in five-percent steps.
OptionsRow_OnLoad(self, "fireLightGain",
diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs
index d751ae65..2a149201 100644
--- a/crates/benilla-app/src/ui_script/options_tests.rs
+++ b/crates/benilla-app/src/ui_script/options_tests.rs
@@ -1769,6 +1769,217 @@ fn the_anisotropic_row_reaches_sixteen_reads_off_at_one_and_defers_to_apply() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+/// GFX: the two video rows seated beside anisotropy. Trilinear is the policy's other half and is
+/// *outranked* by it (`0x688d0d`), so the row has to go grey while anisotropy is on and come back
+/// holding its value — a hidden row, or one that wrote 0, would both be wrong. Multisampling is
+/// backed by the reference's own index verbs, so it lists whatever formats the device published.
+#[test]
+fn trilinear_greys_under_anisotropy_and_multisampling_lists_the_devices_formats() {
+ benilla_formats::wow_data_or_skip!();
+ let base = "BenillaOptionsFrameContainerBodyGraphics";
+ let fmt = |samples| benilla_ui::script::MultisampleFormat {
+ color_bits: 32,
+ depth_bits: 32,
+ samples,
+ };
+ let mut s = audio_harness();
+ s.set_cvar_host("anisotropic", "1");
+ s.set_cvar_host("trilinear", "1");
+ let mut s = harness_on(s);
+ s.run("ShowUIPanel(BenillaOptionsFrame) BenillaOptionsFrameCategoryListRowGraphics:Click()")
+ .unwrap();
+
+ // ── Trilinear ──
+ assert_eq!(
+ s.eval::(&format!("return {base}RowTrilinearLabel:GetText()"))
+ .unwrap(),
+ "Trilinear Filtering"
+ );
+ // `IsEnabled()` answers 1 or 0 here, never nil, so a `~= nil` test would pass both ways.
+ let live = |s: &mut UiScript| {
+ s.eval::(&format!(
+ "return tostring({base}RowTrilinearCheck:IsEnabled())"
+ ))
+ .unwrap()
+ };
+ assert_eq!(live(&mut s), "1", "with anisotropy off the row is live");
+ // A STAGED anisotropy greys it at once: both rows defer, so waiting for Apply would show the
+ // row it is about to gate as live for the rest of the visit.
+ s.run(&format!("{base}RowAnisotropicControlSlider:SetValue(16)"))
+ .unwrap();
+ s.run("OptionsPage_Refresh(\"Graphics\")").unwrap();
+ assert_eq!(
+ live(&mut s),
+ "0",
+ "anisotropy outranks trilinear, so the row it gates must read as dead"
+ );
+ // Greyed, not rewritten: the CVar is untouched and comes back when anisotropy goes.
+ assert_eq!(
+ s.eval::("return GetCVar('trilinear')").unwrap(),
+ "1"
+ );
+ s.run(&format!("{base}RowAnisotropicControlSlider:SetValue(1)"))
+ .unwrap();
+ s.run("OptionsPage_Refresh(\"Graphics\")").unwrap();
+ assert_eq!(live(&mut s), "1", "and anisotropy off hands the row back");
+
+ // ── Multisampling ──
+ let row = format!("{base}RowMultisampling");
+ assert_eq!(
+ s.eval::(&format!("return {row}Label:GetText()"))
+ .unwrap(),
+ "Multisampling"
+ );
+ // Published after the UI is already up, as the engine really does it.
+ s.set_multisample_formats(vec![fmt(1), fmt(2), fmt(4)]);
+ s.run(&format!("{row}DropdownButton:Click()")).unwrap();
+ assert_eq!(
+ s.eval::("return DropDownList1.numButtons").unwrap(),
+ 3,
+ "one entry per format the device published"
+ );
+ // 1 is none, and reads as the localised Off rather than as "1x".
+ assert_eq!(
+ s.eval::("return DropDownList1Button1:GetText()")
+ .unwrap(),
+ "Off"
+ );
+ assert_eq!(
+ s.eval::("return DropDownList1Button3:GetText()")
+ .unwrap(),
+ "4x"
+ );
+ let _ = s.take_cvar_changes();
+ s.run("DropDownList1Button3:Click()").unwrap();
+ assert!(
+ s.take_cvar_changes().is_empty(),
+ "latched row: nothing may reach the host before Apply"
+ );
+ assert!(s
+ .eval::("return BenillaOptionsFrameApplyButton:IsVisible()")
+ .unwrap());
+ s.run("BenillaOptionsFrameApplyButton:Click()").unwrap();
+ // The whole record, as `SetMultisampleFormat` writes it (`0x48c640`) — not the sample count
+ // alone.
+ let wrote = s.take_cvar_changes();
+ assert!(
+ wrote.contains(&("gxMultisample".to_string(), "4".to_string())),
+ "Apply has to reach the sample count, got {wrote:?}"
+ );
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
+
+/// GFX (shadow rig): the five shadow rows that had no seat. The two that need watching are the
+/// update-rate caps, where `0` is **every frame** and not "off" — the opposite reading, and the one
+/// a "0 per second" readout would invite — and the gating, which is per-lane for the rates and
+/// per-cascade for the filter and the reach.
+#[test]
+fn the_shadow_rate_rows_name_every_frame_at_zero_and_gate_on_their_own_lane() {
+ benilla_formats::wow_data_or_skip!();
+ let base = "BenillaOptionsFrameContainerBodyAdvancedGraphics";
+ let mut s = audio_harness();
+ s.set_cvar_host("characterShadows", "1");
+ s.set_cvar_host("worldShadows", "1");
+ s.set_cvar_host("characterShadowRate", "30");
+ let mut s = harness_on(s);
+ s.run("ShowUIPanel(BenillaOptionsFrame) BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
+ .unwrap();
+
+ let readout = |s: &mut UiScript, row: &str| {
+ s.eval::(&format!("return {base}Row{row}ControlValue:GetText()"))
+ .unwrap()
+ };
+ assert_eq!(readout(&mut s, "CharacterShadowRate"), "30 per second");
+ s.run(&format!(
+ "{base}RowCharacterShadowRateControlSlider:SetValue(0)"
+ ))
+ .unwrap();
+ assert_eq!(
+ readout(&mut s, "CharacterShadowRate"),
+ "Every frame",
+ "0 is the cap removed, not the shadows switched off — the two toggles above do that"
+ );
+ // A live row: the cap costs nothing to move, so it writes on the drag.
+ assert_eq!(
+ s.eval::("return GetCVar('characterShadowRate')")
+ .unwrap(),
+ "0"
+ );
+ // The reach reads as a share of the untouched law, so the shipped value is 100%, not 43% of
+ // a 0.25..2 groove.
+ assert_eq!(readout(&mut s, "ShadowCasterReach"), "100%");
+
+ // ── Gating: per-lane for the rates ──
+ // A disabled slider row hides its thumb and greys its readout (`OptionsRow_SetEnabled`);
+ // the slider itself has no `IsEnabled`.
+ let live = |s: &mut UiScript, row: &str| {
+ s.eval::(&format!(
+ "return {base}Row{row}ControlSliderThumb:IsShown() and true or false"
+ ))
+ .unwrap()
+ };
+ s.run("SetCVar('characterShadows', '0') OptionsPage_Refresh(\"AdvancedGraphics\")")
+ .unwrap();
+ assert!(!live(&mut s, "CharacterShadowRate"));
+ assert!(
+ live(&mut s, "WorldShadowRate"),
+ "the world lane is still on, so its own cap stays live"
+ );
+ // ...and per-cascade for the filter and the reach: one lane left is enough to keep them.
+ assert!(live(&mut s, "ShadowCasterReach"));
+ s.run("SetCVar('worldShadows', '0') OptionsPage_Refresh(\"AdvancedGraphics\")")
+ .unwrap();
+ assert!(
+ !live(&mut s, "ShadowCasterReach"),
+ "with both sun lanes off there is no cascade to size"
+ );
+ assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
+}
+
+/// The five rows' labels in both locales, as every Advanced Graphics row carries them.
+#[test]
+fn the_shadow_rig_rows_are_localised() {
+ benilla_formats::wow_data_or_skip!();
+ let base = "BenillaOptionsFrameContainerBodyAdvancedGraphics";
+ for (locale, filter, darkness) in [
+ ("enUS", "Soft Shadow Edges", "Torch Shadow Darkness"),
+ ("ruRU", "Мягкие края теней", "Темнота теней от огня"),
+ ] {
+ 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::(&format!("return {base}RowShadowFilterLabel:GetText()"))
+ .unwrap(),
+ filter,
+ "{locale}"
+ );
+ assert_eq!(
+ s.eval::(&format!(
+ "return {base}RowTorchShadowDarknessLabel:GetText()"
+ ))
+ .unwrap(),
+ darkness,
+ "{locale}"
+ );
+ // The readout is localised too, which is the half a label-only check would miss.
+ s.run(&format!(
+ "{base}RowWorldShadowRateControlSlider:SetValue(0)"
+ ))
+ .unwrap();
+ assert!(!s
+ .eval::(&format!(
+ "return {base}RowWorldShadowRateControlValue:GetText()"
+ ))
+ .unwrap()
+ .is_empty());
+ assert!(s.errors().is_empty(), "{locale}: {:?}", s.errors());
+ }
+}
+
/// Pending edits are panel-wide, as the era's modified table: a category switch keeps them, and
/// hiding the window discards them.
#[test]
@@ -2170,10 +2381,12 @@ fn water_quality_writes_numeric_tiers_with_localised_labels() {
// MONKEY (integration): Graphics Preset + Render Distance lead the page, then every programme row (sky, post, dither, fog, wet, wind, ao, lamp fog, window split, zone skyboxes): 33.
// MONKEY (fix-wind / volumetric light / moonlight): sway strength and the three light
// rows append after those: 33 -> 37.
+ // GFX (shadow rig): the five shadow rows a player could not reach — the filter, the
+ // caster reach, the two update-rate caps and the torch darkness: 37 -> 42.
assert_eq!(
s.eval::("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)")
.unwrap(),
- 37
+ 42
);
assert_eq!(
s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[4]")
@@ -3189,10 +3402,37 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
"AdvancedGraphicsRowWaterQuality",
),
("BENILLA_TOOLTIP_LAVA_GLOW", "AdvancedGraphicsRowLavaGlow"),
+ // GFX (shadow rig): the five shadow rows a player could not reach. 1.12 has no text
+ // for any of them — it had none of the settings.
+ (
+ "BENILLA_TOOLTIP_SHADOW_FILTER",
+ "AdvancedGraphicsRowShadowFilter",
+ ),
+ (
+ "BENILLA_TOOLTIP_SHADOW_CASTER_RANGE",
+ "AdvancedGraphicsRowShadowCasterReach",
+ ),
+ (
+ "BENILLA_TOOLTIP_CHARACTER_SHADOW_UPDATES",
+ "AdvancedGraphicsRowCharacterShadowRate",
+ ),
+ (
+ "BENILLA_TOOLTIP_WORLD_SHADOW_UPDATES",
+ "AdvancedGraphicsRowWorldShadowRate",
+ ),
+ (
+ "BENILLA_TOOLTIP_TORCH_SHADOW_DARKNESS",
+ "AdvancedGraphicsRowTorchShadowDarkness",
+ ),
("BENILLA_TOOLTIP_RENDER_SCALE", "GraphicsRowRenderScale"),
// GFX (anisotropic): ours, because 1.12's own text describes a four-stop slider
// that needs a restart and this row is neither.
("BENILLA_TOOLTIP_ANISOTROPIC", "GraphicsRowAnisotropic"),
+ // GFX (anisotropic): the other half of the same policy, and the row it outranks.
+ ("BENILLA_TOOLTIP_TRILINEAR", "GraphicsRowTrilinear"),
+ // GFX (multisampling): 1.12's own text describes the colour/depth/sample triple its
+ // dropdown showed; ours names only the sample count.
+ ("BENILLA_TOOLTIP_MULTISAMPLING", "GraphicsRowMultisampling"),
("BENILLA_TOOLTIP_DISPLAY_MODE", "GraphicsRowDisplayMode"),
// 1.12 shipped the resolution dropdown with no tooltip at all, so there is no
// `OPTION_TOOLTIP_*` for it to borrow; benilla's own text explains the restart.
@@ -3330,7 +3570,10 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
// Graphics page's resolution dropdown one more. 98 -> 116.
// GFX (anisotropic): the Graphics page's own anisotropy slider, seated where the reference's
// slider table already declared it. 116 -> 117.
- assert_eq!(checked, 117, "every tipped row carries a live key");
+ // GFX: Trilinear Filtering and Multisampling, the two other video rows a player could not
+ // reach. 117 -> 119.
+ // GFX (shadow rig): the five Advanced Graphics shadow rows. 119 -> 124.
+ assert_eq!(checked, 124, "every tipped row carries a live key");
assert_eq!(
untipped,
vec![
@@ -3435,7 +3678,9 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
// MONKEY (graphics programme): the Advanced Graphics page's described rows add 19. 78 -> 97.
// MONKEY (post / dither / fog / wet / wind / volumetric light / resolution): 97 -> 116.
// GFX (anisotropic): the Graphics page's anisotropy slider. 116 -> 117.
- assert_eq!(raised, 117, "every row but Auto Loot raises a description");
+ // GFX: Trilinear Filtering and Multisampling. 117 -> 119.
+ // GFX (shadow rig): the five Advanced Graphics shadow rows. 119 -> 124.
+ assert_eq!(raised, 124, "every row but Auto Loot raises a description");
}
/// 1.12's AdvancedOptionsCombatText box as saved-global rows: a click writes the global, never a