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