From 2d1dbd56396c6a37d85d0ee7a31c425067585a2d Mon Sep 17 00:00:00 2001 From: grom Date: Mon, 26 Oct 2026 16:22:20 -0600 Subject: [PATCH] Seven more graphics settings get a seat: the filter pair, MSAA, the shadow rig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep behind this: every CVar `video::on_cvar` answers, against every CVar the Options window names. Twenty of fifty-five had no seat at all, so the only way to them was `/console` — which is not a setting. Seven of them here; the ten interior-lighting rows follow. **Graphics page.** Trilinear Filtering under Anisotropic, because they are one policy: aniso at 2 or more writes filter mode 5 and trilinear writes mode 4 only while that bit is clear (`0x688d0d`), so the row is GREYED while anisotropy is on — not hidden, and not rewritten. It keeps the value a player set, and turning anisotropy off hands it straight back. The gating reads the STAGED anisotropy where there is one, since both rows defer: otherwise a player who moves one and has not pressed Apply sees the row it is about to gate stay live for the rest of the visit. Multisampling beside Vertical Sync and Resolution, the latched `gx*` rows it commits with. 1.12 had this row and we did not. Backed by the index verbs its own dropdown used (`0x48c580` / `0x48c640`) rather than by `gxMultisample`, because picking a format writes the colour and depth rows with it as one record — and it rebuilds its list on open, like Resolution, since at OnLoad the engine has published no formats yet. Only the sample count is named: the colour and depth halves of each triple describe our swapchain and steer nothing, so offering them would offer a choice that does not exist. Its description says plainly that multisampling does less here than in a modern game, because it misses the alpha-tested cutouts most of this game's foliage and fencing is made of. **Advanced Graphics.** The shadow rig's four unreachable rows — Soft Shadow Edges (the 9-tap gaussian against the card's own 2x2; Bevy's third mode is deliberately not offered, since it only resolves under TAA this camera does not run), Shadow Caster Range, and the two update-rate caps — plus Torch Shadow Darkness beside the softness dial it shares a shadow with. The rate caps needed their own readout. `0` there is **every frame**, the opposite of what it looks like, so a bare "0 per second" would read as the shadows being switched off — which is what the two toggles above them are for. They now say "Every frame", in both locales. Gating is per-lane where the lane is what matters: each rate cap is dead only while its own lane is, while the filter and the reach ride the cascade and need just one sun lane left. Russian for all five rows and their descriptions is written to match the register of the column already there; it would still benefit from a native reader. One thing worth recording for the next row: `OPTIONS_PAGE_ROWS` has positional contracts in its tests, and the list's own comments say "appended so the other rows keep their indices". It drives refresh, search and Restore Defaults — never layout, which is the frames' own anchors — so appending costs nothing and seats each row where it belongs. Verified: ALL GATES GREEN (322 s); `benilla-app --lib` against the install at the exact 269-failure baseline, set-identical by `comm`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/assets/ui/OptionsFrame.xml | 319 +++++++++++++++++- .../src/ui_script/options_tests.rs | 251 +++++++++++++- 2 files changed, 559 insertions(+), 11 deletions(-) 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