diff --git a/LIGHTING.md b/LIGHTING.md index c1ccefe1..976f6921 100644 --- a/LIGHTING.md +++ b/LIGHTING.md @@ -34,9 +34,53 @@ behind each constant. | Foliage wind | one weather-fed gust/veer field drives grass and classified tree/bush foliage; grass also parts around the player and nearby units | Advanced Graphics → Foliage Wind, cvar `foliageWind` 0 Off / 1 Grass / 2 Grass + Trees (High = 2), capture override `WOW_FOLIAGE_WIND`; field and benders in `benilla-world/src/wind/`. The wave phase is the CPU-integrated travel (speed integrated over time, wrapped seamlessly), and the waves' spatial term uses the fixed profile heading; the veer turns only the bend. Tree exile copies sway through material marker bit 14 (`FOLIAGE_WIND_MARKER`). Capture offset `WOW_CAPTURE_WIND_T` (s). Known: shadow casters (world-shadow proxies, `torch_depth.wgsl`) do not apply the sway, so leaves sample a static shadow; a future world-camera DepthPrepass must call the same offsets | | Night and interior level | global dimming of the night sky term and of interior ambient | `nightGain`, `interiorGain`, `interiorBakeFloor` | -Players reach all of it from **Options -> Advanced Graphics** (a Lighting Quality preset Off / Low / Medium / High plus the individual rows; Off is the original client look). The dev build has a panel for all of it: **Ctrl+Shift+D → Lighting & shadows**, with Dim / Default / +Players reach all of it from **Options -> Advanced Graphics** (a Graphics Preset over everything, a Render Distance slider, a Lighting Quality preset Off / Low / Medium / High / Ultra plus the individual rows; Classic / Off is the original client look). The dev build has a panel for all of it: **Ctrl+Shift+D → Lighting & shadows**, with Dim / Default / Bright presets. +## Graphics Preset + +MONKEY (presets). The top row of Advanced Graphics, cvar `graphicsQuality`: Classic / Low / Medium / +High / Ultra, or Custom when the rows match none of them. The table is `GRAPHICS_PRESETS` in +`benilla-app/src/cvars.rs` (one line per governed row); choosing a rung writes each row through +the ordinary cvar path, and the label is re-derived every frame, so editing any governed row +(or any Lighting Quality member) shows Custom. `lightingQuality` is itself a governed row, so +every row the lighting ladder owns (`LIGHTING_PRESETS`: both sun lanes, shadow resolution, the +room and torch lanes, moon shadows, `waterQuality`, `volumetricFog`, `bloom`, `sunShafts`, +`colorGrading`, `foliageWind`, the fire/spell/lava gains, `nightGain`, `interiorGain`) follows it. + +| row | Classic | Low | Medium | High | Ultra | +|---|---|---|---|---|---| +| `lightingQuality` | Off | Low | Medium | High | Ultra | +| `farclip` (Render Distance, yd) | 350 | 350 | 477 | 777 | 1497 | +| `skyQuality` | 0 | 1 | 1 | 2 | 2 | +| `skyDither` | 0 | 1 | 1 | 1 | 1 | +| `fogModel` | 0 | 1 | 1 | 1 | 1 | +| `rainSurfaces` | 0 | 1 | 1 | 1 | 1 | +| `torchTerrainShadows` | 0 | 0 | 0 | 1 | 1 | +| `daylightWindowSplit` | 0 | 1 | 1 | 1 | 1 | +| `ambientOcclusion` (TODO, not yet registered) | 0 | 0 | 1 | 2 | 2 | +| `zoneSkyboxes` (TODO, not yet registered) | 0 | 1 | 1 | 1 | 1 | +| `lampFog` (TODO, not yet registered) | 0 | 0 | 1 | 2 | 2 | + +What the lighting rungs set, beyond Off (everything off, gains 1.0) and High (the registered +defaults): Low = character shadows only at a 1024 map, no torch or moon shadows, bloom Low; +Medium = both sun lanes, indoor torch shadows (6 resident / 2 moving), moon shadows, bloom Low, +no sun shafts or colour grading; Ultra = High with a 4096 sun map, 16 / 8 torch maps, High water +and High volumetric fog. No rung decides `shadowDistance` or `interiorShadowSoft`. + +**Default: High.** A player whose `config.toml` names no `graphicsQuality` is seeded to the High +column at startup (`Cvars::seed_graphics_preset`) on every row the file does not carry; the rows' +registered defaults stay their per-lane ones (`farclip` the reference's 350), and captures and +tests, which never seed, keep them. The Advanced page's Defaults button lands on High too. + +**Render Distance** is `farclip`, the same cvar as the Graphics page's Terrain Distance (which +keeps the stock 177–777 slider). Its clamp (`view::FARCLIP_RANGE`) now reaches `FARCLIP_MAX` = +1497 yd, the 1.12 grid of 60 carried on. Terrain residency (`terrain_stream/window.rs`), the +model/WMO and particle walls (`view::within_farclip`) and the art-cache sweep radius (now 7 tiles, +`art_scope.rs`) all follow it; at 1497 the residency window is at most 7×7 ADT tiles against +777's 5×5, which is the memory bound. Past 777 only Modern fog (`fog_model::modern_fog_end`) +stretches the fog end with it; under Classic fog the zone's fog end still hides the extra reach. + The optional enhanced water (Water Quality, refraction, caustics, High reflections, lava glow) is its own module, documented in `WATER.md`. diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index e824d122..3f81a4dd 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -492,6 +492,11 @@ enUS = { page = "Advanced Graphics", lightingQuality = "Lighting Quality", + -- MONKEY (presets): the Graphics Preset and Render Distance rows. + graphicsQuality = "Graphics Preset", + renderDistance = "Render Distance", + pClassic = "Classic", + qUltra = "Ultra", -- MONKEY (volumetric fog): localised atmospheric quality row. volumetricFog = "Volumetric Fog", -- MONKEY (post): HDR excess + halo quality. @@ -538,6 +543,23 @@ windTrees = "Grass + Trees", yards = "%d yd", tips = { + -- MONKEY (presets) + GRAPHICS_QUALITY = + "Sets every graphics setting at once: Lighting Quality and everything it sets, " + .. "Render Distance, Sky Quality, Sky Dithering, Modern Fog, Wet Surfaces in Rain, " + .. "Terrain Blocks Torchlight and the daylight through city windows.\n\n" + .. "Classic is the original client: every enhancement off and the original 350 yd " + .. "view distance. Low and Medium add the enhancements that cost almost nothing; High " + .. "is the recommended look; Ultra raises every quality to its maximum and the view " + .. "distance to 1497 yd, and costs the most.\n\n" + .. "Shadow Distance and Torch Shadow Softness stay as you set them. Changing any " + .. "setting the preset sets shows Custom.", + RENDER_DISTANCE = + "How far the detailed world is drawn, in yards: the same setting as Terrain " + .. "Distance on the Graphics page, with a longer reach. Past 777 yd, the original " + .. "client's limit, the extra distance shows through Modern Fog; with the original " + .. "fog the zone's own fog hides it.\n\n" + .. "Farther costs more memory, loading and GPU time.", LIGHTING_QUALITY = "Sets character, world, indoor torch and outdoor torch shadows; dynamic " .. "interior lighting; moon shadows; volumetric fog; water quality; lava glow; fire and spell brightness; fire flicker; " @@ -547,6 +569,8 @@ .. "night gain 1.0 and interior gain 1.0. All other settings, including " .. "Shadow Distance and Torch Shadow Softness, stay as you set them. " .. "Low, Medium and High use Enhanced water; mirror reflections stay opt-in. " + .. "Ultra also raises shadow resolution to 4096 and the torch shadow counts to 16 and 8, " + .. "and uses High water and High volumetric fog. " .. "Changing a preset member shows Custom when no preset matches.", -- MONKEY (volumetric fog): explain cost and the shadow-map dependency. -- MONKEY (post): Off guarantees the pre-HDR image. @@ -673,6 +697,11 @@ ruRU = { page = "Расширенная графика", lightingQuality = "Качество освещения", + -- MONKEY (presets): the Graphics Preset and Render Distance rows. + graphicsQuality = "Набор графики", + renderDistance = "Дальность прорисовки", + pClassic = "Классика", + qUltra = "Ультра", -- MONKEY (post): Russian HDR excess + halo quality row. bloom = "Свечение", sunShafts = "Солнечные лучи", @@ -719,6 +748,23 @@ windTrees = "Трава + деревья", yards = "%d ярд.", tips = { + -- MONKEY (presets) + GRAPHICS_QUALITY = + "Задаёт все настройки графики сразу: качество освещения и всё, что оно задаёт, " + .. "дальность прорисовки, качество неба, дизеринг неба, современный туман, мокрые " + .. "поверхности в дождь, заслонение огней рельефом и дневной свет в окнах городов.\n\n" + .. "«Классика» — оригинальный клиент: все улучшения выключены, дальность обзора " + .. "прежние 350 ярдов. «Низкое» и «Среднее» добавляют почти бесплатные улучшения; " + .. "«Высокое» — рекомендуемый вид; «Ультра» поднимает всё качество до максимума, а " + .. "дальность — до 1497 ярдов, и стоит дороже всего.\n\n" + .. "Дальность теней и мягкость теней от огня сохраняют ваши значения. Изменение " + .. "любой настройки набора показывает «Своё».", + RENDER_DISTANCE = + "Как далеко рисуется подробный мир, в ярдах: та же настройка, что «Дальность " + .. "обзора» на странице «Графика», но с большим пределом. Дальше 777 ярдов, предела " + .. "оригинального клиента, дополнительное расстояние видно с современным туманом; " + .. "с оригинальным туманом его скрывает туман зоны.\n\n" + .. "Чем дальше, тем больше памяти, загрузки и работы видеокарты.", LIGHTING_QUALITY = "Задаёт тени персонажей, мира и огней внутри и снаружи помещений; " .. "динамическое освещение помещений; лунные тени; объёмный туман; качество воды; свечение лавы; яркость огня и заклинаний; " @@ -729,7 +775,9 @@ .. "источников света, с коэффициентами яркости ночи и помещений 1.0. " .. "Все остальные настройки, включая дальность теней и мягкость теней от огня, " .. "сохраняют ваши значения. «Низкое», «Среднее» и «Высокое» используют улучшенную воду; " - .. "зеркальные отражения включаются отдельно. Изменение настройки набора показывает «Своё», " + .. "зеркальные отражения включаются отдельно. «Ультра» также поднимает разрешение теней " + .. "до 4096 и число теней от огней до 16 и 8, а вода и объёмный туман — «Высокое». " + .. "Изменение настройки набора показывает «Своё», " .. "если ни один набор больше не совпадает.", -- MONKEY (post): localised bloom effect and Off contract. BLOOM = @@ -912,6 +960,8 @@ -- the rig they share, and the Graphics page had become the place where a player found -- three of eighteen shadow settings and no hint that the other fifteen existed. AdvancedGraphics = { + -- MONKEY (presets): the ladder over every row below, then the view distance. + "RowGraphicsQuality", "RowRenderDistance", -- MONKEY (volumetric fog): include in refresh, defaults and search. "RowLightingQuality", "RowWaterQuality", "RowVolumetricFog", -- MONKEY (sky): the sky tier row. @@ -1543,7 +1593,20 @@ for _, rkey in ipairs(OPTIONS_PAGE_ROWS[key]) do local row = getglobal(base .. rkey) row.pending = nil - OptionsRow_Set(row, OptionsRow_Default(row)) + if not row.preset then + OptionsRow_Set(row, OptionsRow_Default(row)) + end + end + -- MONKEY (presets): a preset row goes last, so its default rung (the Graphics + -- Preset's High) is what the page lands on rather than its members' own defaults. + -- `SetCVar` queues only a moved value, so "Custom" (which the host applies as + -- nothing) goes first: the rung is then always a queued write, after the members. + for _, rkey in ipairs(OPTIONS_PAGE_ROWS[key]) do + local row = getglobal(base .. rkey) + if row.preset then + OptionsRow_Set(row, "Custom") + OptionsRow_Set(row, OptionsRow_Default(row)) + end end OptionsPage_Refresh(key) end @@ -2672,11 +2735,51 @@ - + + + + OptionsRow_OnLoad(self, "graphicsQuality", + BENILLA_ADVGFX.graphicsQuality, + "BENILLA_TOOLTIP_GRAPHICS_QUALITY") + self.preset = 1 + OptionsDropdownRow_Setup(self, { + { text = BENILLA_ADVGFX.pClassic, value = "Classic" }, + { text = BENILLA_ADVGFX.qLow, value = "Low" }, + { text = BENILLA_ADVGFX.qMedium, value = "Medium" }, + { text = BENILLA_ADVGFX.qHigh, value = "High" }, + { text = BENILLA_ADVGFX.qUltra, value = "Ultra" }, + { text = BENILLA_ADVGFX.qCustom, value = "Custom" }, + }) + + + + + + + + + + OptionsRow_OnLoad(self, "farclip", + BENILLA_ADVGFX.renderDistance, + "BENILLA_TOOLTIP_RENDER_DISTANCE") + OptionsSliderRow_Setup(self, 177, 1497, 60, "yd") + + + + + + + -- The preset ladder. This row writes ONE CVar and -- nothing else: the host's own table @@ -2701,6 +2804,7 @@ { text = BENILLA_ADVGFX.qLow, value = "Low" }, { text = BENILLA_ADVGFX.qMedium, value = "Medium" }, { text = BENILLA_ADVGFX.qHigh, value = "High" }, + { text = BENILLA_ADVGFX.qUltra, value = "Ultra" }, { text = BENILLA_ADVGFX.qCustom, value = "Custom" }, }) diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 51fde75e..2131ce86 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -916,9 +916,22 @@ pub(crate) const REGISTERED: &[Registered] = &[ "lightingQuality", "High", "benilla's own: the preset ladder over the dynamic light + shadow rows — Off / Low / \ - Medium / High, or Custom when the members match none of them; the reference has no \ + Medium / High / Ultra, or Custom when the members match none of them; the reference has no \ realtime light or shadow system to preset", ), + // MONKEY (presets): the Graphics Preset — `lightingQuality`'s posture one level up, a NAME + // for every graphics row at once ([`GRAPHICS_PRESETS`]), re-derived every frame. "High" is + // what a player with no saved preset boots into: [`Cvars::seed_graphics_preset`] writes the + // High column over every row their `config.toml` does not carry, so the rows themselves keep + // their per-lane defaults (Classic-leaning, and `farclip` the reference's 350) and a capture + // or a test, which never seeds, keeps them too. + ours( + "graphicsQuality", + "High", + "benilla's own: the preset ladder over every graphics row — Classic / Low / Medium / High \ + / Ultra, or Custom when the rows match none of them; the reference's options have no \ + such preset", + ), // MONKEY (volumetric fog): saved live tier; capture override stays session-only. ours("volumetricFog", "1", "benilla's own: near-field volumetric fog, 0 Off / 1 Low / 2 High"), // MONKEY (p0 skyDither): the FFXGlow combine's deband dither (was env WOW_DITHER only). @@ -1733,6 +1746,9 @@ impl Cvars { if name.eq_ignore_ascii_case("lightingQuality") { apply_lighting_preset(self, value); } + if name.eq_ignore_ascii_case("graphicsQuality") { + apply_graphics_preset(self, value); + } outcome } @@ -1744,9 +1760,38 @@ impl Cvars { if name.eq_ignore_ascii_case("lightingQuality") { apply_lighting_preset(self, value); } + if name.eq_ignore_ascii_case("graphicsQuality") { + apply_graphics_preset(self, value); + } outcome } + /// MONKEY (presets): **the first boot's Graphics Preset** — a player whose `config.toml` + /// names no `graphicsQuality` (a new player, or one from before the ladder) gets the + /// [`GRAPHICS_DEFAULT`] column written over every governed row the file does not carry and the + /// session does not own. Rows the file carries are the player's and stay; the lighting rung + /// is left alone, because High on that ladder IS the registered defaults + /// (`the_high_preset_is_the_registered_defaults`). Returns how many rows moved. + pub(crate) fn seed_graphics_preset(&mut self) -> usize { + let carried = |cvars: &Self, k: &str| { + cvars.file.keys().any(|f| f.eq_ignore_ascii_case(k)) || cvars.is_session_owned(k) + }; + if carried(self, "graphicsQuality") { + return 0; + } + let col = graphics_column(GRAPHICS_DEFAULT).expect("the default is a rung"); + let mut moved = 0; + for (k, values) in GRAPHICS_PRESETS { + if k.eq_ignore_ascii_case("lightingQuality") || carried(self, k) { + continue; + } + if self.set(k, values[col]) == SetOutcome::Changed { + moved += 1; + } + } + moved + } + /// **The table follows a value the engine already applied** — a mirror, not a write: the /// applied value moves, the config dirties, the VM's mirror learns it, and **no observer /// fires**, because the knob is already there. For a second registered spelling of one knob @@ -2166,6 +2211,35 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ ("foliageWind", "2"), ], ), + // MONKEY (presets): High plus every lane at its maximum — the 4096 sun map, all sixteen + // resident torch maps with eight of them tracking movers, mirror-reflection water and the + // High fog march. Disjoint from High on `shadowMapSize`, so derivation never confuses them. + ( + "Ultra", + &[ + ("characterShadows", "1"), + ("worldShadows", "1"), + ("shadowMapSize", "4096"), + ("interiorLight", "1"), + ("interiorShadows", "1"), + ("interiorShadowCasters", "16"), + ("interiorShadowDynamic", "8"), + ("exteriorShadows", "1"), + ("spellLightGain", "1"), + ("waterQuality", "2"), + ("volumetricFog", "2"), + ("bloom", "2"), + ("sunShafts", "1"), + ("colorGrading", "1"), + ("lavaLightGain", "1"), + ("fireLightGain", "1"), + ("nightGain", "0.45"), + ("interiorGain", "0.5"), + ("moonShadowStrength", "0.35"), + ("fireFlicker", "1"), + ("foliageWind", "2"), + ], + ), ]; /// What the ladder shows when the members match no preset. Not a preset: selecting it writes @@ -2219,6 +2293,82 @@ fn lighting_quality(cvars: &mut Cvars) { let derived = derive_lighting_quality(cvars); // A derived label is a mirror, never another request to apply a preset. cvars.mirror("lightingQuality", derived); + // MONKEY (presets): after the lighting label, which the graphics ladder reads as a member. + let graphics = derive_graphics_quality(cvars); + cvars.mirror("graphicsQuality", graphics); +} + +// ─── MONKEY (presets): the Graphics Preset ladder ───────────────────────────────────────────── + +/// The Graphics Preset's rungs, in the order [`derive_graphics_quality`] tries them and the +/// column order of [`GRAPHICS_PRESETS`]. +pub(crate) const GRAPHICS_PRESET_NAMES: [&str; 5] = ["Classic", "Low", "Medium", "High", "Ultra"]; + +/// The rung a player with no saved preset boots into ([`Cvars::seed_graphics_preset`]). +pub(crate) const GRAPHICS_DEFAULT: &str = "High"; + +/// **The one place the Graphics Preset is written down**: one line per governed row, one column +/// per rung of [`GRAPHICS_PRESET_NAMES`]. `lightingQuality` is itself a row — writing its rung +/// writes [`LIGHTING_PRESETS`]' members — so every lighting, water, fog, post and wind row the +/// lighting ladder owns is governed here without a second copy. The other rows are disjoint from +/// that ladder, which is what lets both labels be right at once. +/// +/// Classic is the reference client: every lane off, `farclip` at its registered 350. High is +/// each lane's documented High (LIGHTING.md, WATER.md); Ultra is High at every maximum, and the +/// only rung past the reference's 777 yd (`FARCLIP_RANGE`); Low and Medium keep the practically +/// free lanes (sky, dither, modern fog, wet surfaces) and leave the costly ones to High. +/// +/// A row that lands later is ONE line here; the three pending ones sit commented with their +/// intended columns until the rows they name are registered (an unregistered member would never +/// match, and the ladder would read Custom forever). +#[rustfmt::skip] +pub(crate) const GRAPHICS_PRESETS: &[(&str, [&str; 5])] = &[ + // Classic Low Medium High Ultra + ("lightingQuality", ["Off", "Low", "Medium", "High", "Ultra"]), + ("farclip", ["350", "350", "477", "777", "1497"]), + ("skyQuality", ["0", "1", "1", "2", "2"]), + ("skyDither", ["0", "1", "1", "1", "1"]), + ("fogModel", ["0", "1", "1", "1", "1"]), + ("rainSurfaces", ["0", "1", "1", "1", "1"]), + ("torchTerrainShadows", ["0", "0", "0", "1", "1"]), + ("daylightWindowSplit", ["0", "1", "1", "1", "1"]), + // TODO(presets): uncomment each as its row lands from the parallel merge. + // ("ambientOcclusion", ["0", "0", "1", "2", "2"]), + // ("zoneSkyboxes", ["0", "1", "1", "1", "1"]), + // ("lampFog", ["0", "0", "1", "2", "2"]), +]; + +/// The column of a rung, matched case-insensitively; `None` for `Custom` or anything else. +fn graphics_column(name: &str) -> Option { + GRAPHICS_PRESET_NAMES + .iter() + .position(|n| n.eq_ignore_ascii_case(name)) +} + +/// The rung every governed row currently spells, or [`LIGHTING_CUSTOM`] (the one "Custom" both +/// ladders show). Reads `lightingQuality`'s DERIVED label, so run it after that is mirrored. +pub(crate) fn derive_graphics_quality(cvars: &Cvars) -> &'static str { + for (col, name) in GRAPHICS_PRESET_NAMES.iter().enumerate() { + let matched = GRAPHICS_PRESETS + .iter() + .all(|(k, v)| cvars.get(k).is_some_and(|live| same_value(live, v[col]))); + if matched { + return name; + } + } + LIGHTING_CUSTOM +} + +/// Write one rung's column through [`Cvars::set`]; `false` (and nothing written) for an unknown +/// name, `Custom` included. +pub(crate) fn apply_graphics_preset(cvars: &mut Cvars, name: &str) -> bool { + let Some(col) = graphics_column(name) else { + return false; + }; + for (k, values) in GRAPHICS_PRESETS { + cvars.set(k, values[col]); + } + true } /// **What the environment took for this session, and what it set it to** — read off the knobs @@ -2313,6 +2463,11 @@ fn session_values(world: &World) -> Vec<(&'static str, Option)> { fn load_config(world: &mut World) { let session = session_values(world); let stored = stored_config(); + let stored_kind = match &stored { + StoredConfig::Absent => StoredKind::Absent, + StoredConfig::Table(_) => StoredKind::Table, + StoredConfig::Bad(_) => StoredKind::Bad, + }; let events = { let mut cvars = world.resource_mut::(); for (name, value) in session { @@ -2334,6 +2489,11 @@ fn load_config(world: &mut World) { } StoredConfig::Table(table) => cvars.load_file(table), } + // MONKEY (presets): a player's own run (never a capture, never a malformed file) boots + // into the default Graphics Preset over whatever its file leaves unsaid. + if seeds_graphics_preset(&stored_kind) { + cvars.seed_graphics_preset(); + } cvars.take_events() }; for event in events { @@ -2341,6 +2501,22 @@ fn load_config(world: &mut World) { } } +/// MONKEY (presets): which [`StoredConfig`] arm a boot took, kept past the table's move. +enum StoredKind { + Absent, + Table, + Bad, +} + +/// MONKEY (presets): whether this boot seeds the default Graphics Preset — a run that reads and +/// saves a player's `config.toml` and found it absent or well-formed. A capture (hermetic, or on +/// an explicit fixture) keeps the registered defaults so its A/B stays exact, and a malformed +/// file is left for the player rather than papered over. +fn seeds_graphics_preset(kind: &StoredKind) -> bool { + let players_file = std::env::var_os("WOW_CAPTURE").is_none() && config_read_path().is_some(); + players_file && matches!(kind, StoredKind::Absent | StoredKind::Table) +} + /// What the one read of `config.toml` found. enum StoredConfig { /// No file, no install, or a capture without an explicit fixture — use registered defaults. @@ -3108,6 +3284,8 @@ mod tests { for member in ["fireLightGain", "waterQuality", "volumetricFog", "lavaLightGain", "nightGain", "interiorGain"] { let expected = if *name == "Off" { if matches!(member, "fireLightGain" | "waterQuality" | "volumetricFog" | "lavaLightGain") { "0" } else { "1.0" } + } else if *name == "Ultra" && matches!(member, "waterQuality" | "volumetricFog") { + "2" // MONKEY (presets): Ultra's two maxima over the defaults. } else { cvars.default_of(member).unwrap() }; @@ -3192,10 +3370,212 @@ mod tests { let mut cvars = fresh_registry(); apply_lighting_preset(&mut cvars, "Medium"); assert!(!apply_lighting_preset(&mut cvars, LIGHTING_CUSTOM)); - assert!(!apply_lighting_preset(&mut cvars, "Ultra")); + assert!(!apply_lighting_preset(&mut cvars, "Epic")); assert_eq!(derive_lighting_quality(&cvars), "Medium"); } + // ── MONKEY (presets): the Graphics Preset ladder ────────────────────────────────────────── + + /// Every governed row is registered and names no row the lighting ladder already owns — the + /// disjointness that lets both labels be right at once. + #[test] + fn graphics_rows_are_registered_and_disjoint_from_the_lighting_ladder() { + assert_eq!(GRAPHICS_PRESETS.len(), 8, "add the row count with the row"); + let cvars = fresh_registry(); + for (k, values) in GRAPHICS_PRESETS { + assert!(cvars.get(k).is_some(), "{k}: not registered"); + for (rung, members) in LIGHTING_PRESETS { + assert!( + members.iter().all(|(m, _)| !m.eq_ignore_ascii_case(k)), + "{k} is also a member of lighting {rung}" + ); + } + if *k == "lightingQuality" { + for v in values { + assert!( + LIGHTING_PRESETS.iter().any(|(n, _)| n == v), + "{v}: a lighting rung" + ); + } + } + } + // The rungs are distinct columns, so no two can derive to the same name. + for a in 0..GRAPHICS_PRESET_NAMES.len() { + for b in a + 1..GRAPHICS_PRESET_NAMES.len() { + assert!( + GRAPHICS_PRESETS + .iter() + .any(|(_, v)| !same_value(v[a], v[b])), + "{} and {} are the same column", + GRAPHICS_PRESET_NAMES[a], + GRAPHICS_PRESET_NAMES[b] + ); + } + } + } + + /// **Preset → rows → the same preset**, from every rung to every rung, through the ordinary + /// `set` path — with the two rows no preset decides left where the player put them. + #[test] + fn every_graphics_preset_derives_back_to_its_own_name() { + for from in GRAPHICS_PRESET_NAMES { + for name in GRAPHICS_PRESET_NAMES { + let mut cvars = fresh_registry(); + cvars.set("graphicsQuality", from); + cvars.set("shadowDistance", "120"); + cvars.set("interiorShadowSoft", "2.5"); + cvars.set("graphicsQuality", name); + lighting_quality(&mut cvars); + assert_eq!(derive_graphics_quality(&cvars), name, "{from} -> {name}"); + assert_eq!(cvars.get("graphicsQuality"), Some(name)); + let col = graphics_column(name).unwrap(); + assert_eq!( + cvars.get("lightingQuality"), + Some(GRAPHICS_PRESETS[0].1[col]) + ); + assert_eq!(cvars.get("shadowDistance"), Some("120")); + assert_eq!(cvars.get("interiorShadowSoft"), Some("2.5")); + } + } + } + + /// Classic is the reference client: the lighting ladder's Off, every programme lane off and + /// `farclip` at the reference's registered 350. + #[test] + fn the_classic_preset_is_every_lane_off_at_the_reference_view_distance() { + let mut cvars = fresh_registry(); + assert!(apply_graphics_preset(&mut cvars, "Classic")); + assert_eq!(cvars.get("lightingQuality"), Some("Off")); + assert_eq!(cvars.get("farclip"), cvars.default_of("farclip")); + for (k, values) in GRAPHICS_PRESETS { + if !matches!(*k, "lightingQuality" | "farclip") { + assert!(same_value(values[0], "0"), "{k}: Classic leaves it on"); + } + } + // Ultra is the one rung past the reference's 777, and inside the extended clamp. + let ultra = GRAPHICS_PRESETS + .iter() + .find(|(k, _)| *k == "farclip") + .unwrap() + .1[4]; + let ultra: f32 = ultra.parse().unwrap(); + assert!(ultra > 777.0 && FARCLIP_RANGE.contains(&ultra)); + } + + /// **Editing one governed row is Custom** — for every rung and every row of it, the lighting + /// rung included (moved by one of ITS members, as the page's lighting rows would). + #[test] + fn one_changed_graphics_row_derives_custom() { + for name in GRAPHICS_PRESET_NAMES { + let col = graphics_column(name).unwrap(); + for (k, values) in GRAPHICS_PRESETS { + let mut cvars = fresh_registry(); + apply_graphics_preset(&mut cvars, name); + let (row, moved) = if *k == "lightingQuality" { + let to = if values[col] == "Off" { "1" } else { "0" }; + ("characterShadows", to.to_string()) + } else { + let v: f32 = values[col].parse().unwrap(); + let to = if *k == "farclip" { + v + 60.0 + } else if v == 0.0 { + 1.0 + } else { + 0.0 + }; + (*k, to.to_string()) + }; + assert_eq!(cvars.set(row, &moved), SetOutcome::Changed, "{name}/{row}"); + lighting_quality(&mut cvars); + assert_eq!( + cvars.get("graphicsQuality"), + Some(LIGHTING_CUSTOM), + "{name}/{row}" + ); + // …and picking the rung again puts every row back. + assert!(apply_graphics_preset(&mut cvars, name)); + lighting_quality(&mut cvars); + assert_eq!( + cvars.get("graphicsQuality"), + Some(name), + "{name}/{row} re-pick" + ); + } + } + let mut cvars = fresh_registry(); + assert!(!apply_graphics_preset(&mut cvars, LIGHTING_CUSTOM)); + assert!(!apply_graphics_preset(&mut cvars, "Epic")); + } + + /// **A new player boots into High**: the seed writes the High column over a registry with no + /// file, and the registered default of the label agrees, so it never reaches `config.toml`. + #[test] + fn a_fresh_player_is_seeded_to_the_high_graphics_preset() { + assert_eq!( + fresh_registry().default_of("graphicsQuality"), + Some(GRAPHICS_DEFAULT) + ); + let mut cvars = fresh_registry(); + assert!(cvars.seed_graphics_preset() > 0); + lighting_quality(&mut cvars); + assert_eq!(cvars.get("graphicsQuality"), Some("High")); + assert_eq!(cvars.get("lightingQuality"), Some("High")); + assert_eq!(cvars.get("farclip"), Some("777")); + assert!( + !cvars.compose().contains_key("graphicsQuality"), + "the default label is not saved" + ); + // A second boot over what the first one saved seeds nothing. + let mut again = fresh_registry(); + again.load_file(cvars.compose()); + assert_eq!(again.seed_graphics_preset(), 0); + lighting_quality(&mut again); + assert_eq!(again.get("graphicsQuality"), Some("High")); + } + + /// The seed never overrides the player: a saved preset stops it outright, a saved row keeps + /// its value, and a session-owned row (an env lever) is left to the session. + #[test] + fn the_graphics_seed_leaves_saved_and_session_rows_alone() { + let mut saved = fresh_registry(); + saved.load_file(BTreeMap::from([( + "graphicsQuality".into(), + "Classic".into(), + )])); + assert_eq!(saved.seed_graphics_preset(), 0); + assert_eq!(saved.get("skyQuality"), saved.default_of("skyQuality")); + + let mut row = fresh_registry(); + row.load_file(BTreeMap::from([("skyQuality".into(), "1".into())])); + row.own_for_session("farclip", Some("500")); + row.seed_graphics_preset(); + lighting_quality(&mut row); + assert_eq!(row.get("skyQuality"), Some("1")); + assert_eq!(row.get("farclip"), Some("500")); + assert_eq!(row.get("fogModel"), Some("1")); + assert_eq!(row.get("graphicsQuality"), Some(LIGHTING_CUSTOM)); + } + + /// Ultra's `farclip` survives the real observer (the extended clamp), and every governed + /// row's rung values do too. + #[test] + fn graphics_rows_pass_their_observers_unclamped() { + let mut app = cvar_app(); + for (col, name) in GRAPHICS_PRESET_NAMES.iter().enumerate() { + let farclip = GRAPHICS_PRESETS + .iter() + .find(|(k, _)| *k == "farclip") + .unwrap() + .1[col]; + apply(&mut app, "farclip", farclip); + assert_eq!( + res::(&app).farclip, + farclip.parse::().unwrap(), + "{name}" + ); + } + } + /// Every member every rung names is a REGISTERED row — a typo'd member would otherwise make /// its whole preset underivable (a `cvars.get` that answers `None` never matches), and the /// only symptom would be a dropdown stuck on `Custom`. @@ -3722,7 +4102,16 @@ mod tests { assert!(text.contains("MusicVolume = \"0.75\""), "{text}"); assert!(!text.contains("MasterVolume"), "defaults stay out:\n{text}"); let back: LocalConfig = toml::from_str(&text).unwrap(); - assert_eq!(back.cvars.len(), 1, "a diff, not a dump: {text}"); + // MONKEY (presets): the file named no `graphicsQuality`, so the first boot seeded the + // High rungs over the governed rows; those are the only other entries, and the label + // itself, at its default, stays out. + let player: Vec<&String> = back + .cvars + .keys() + .filter(|k| !GRAPHICS_PRESETS.iter().any(|(g, _)| g.eq_ignore_ascii_case(k))) + .collect(); + assert_eq!(player.len(), 1, "a diff, not a dump: {text}"); + assert!(!text.contains("graphicsQuality"), "{text}"); std::fs::remove_dir_all(&tmp).ok(); } @@ -4096,7 +4485,14 @@ mod tests { strings.sort_unstable(); // the list is the claim, not where the rows sit in the table assert_eq!( strings, - vec!["gxApi", "gxResolution", "lightingQuality", "realmList", "realmName"] + vec![ + "graphicsQuality", + "gxApi", + "gxResolution", + "lightingQuality", + "realmList", + "realmName" + ] ); let default_of = |name: &str| { REGISTERED @@ -4413,6 +4809,11 @@ mod tests { "GameTooltip's binding-line gate, stock and pfUI (1316)", ), ("gxApi", "pfUI's system tooltip names the backend (2151)"), + ( + "graphicsQuality", + "MONKEY (presets): the Advanced Graphics page's Graphics Preset row; the host \ + writes and derives it here in cvars.rs", + ), ( "useUiScale", "UIOptionsFrame.lua and OptionsFrame.lua branch on it to gate the uiScale slider", diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index 41062c64..8c2863c2 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -2042,7 +2042,7 @@ fn volumetric_fog_dropdown_is_localised_and_live() { s.run(&format!("function GetLocale() return '{locale}' end")).unwrap(); let mut s = harness_on(s); s.run("ShowUIPanel(BenillaOptionsFrame) BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()").unwrap(); - assert_eq!(s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[3]").unwrap(), "RowVolumetricFog"); + assert_eq!(s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[5]").unwrap(), "RowVolumetricFog"); assert_eq!(s.eval::(&format!("return {ADVGFX}RowVolumetricFogLabel:GetText()")).unwrap(), title); assert_eq!(s.eval::(&format!("return {ADVGFX}RowVolumetricFogDropdownText:GetText()")).unwrap(), labels[1]); let _ = s.take_cvar_changes(); @@ -2067,9 +2067,10 @@ fn water_quality_writes_numeric_tiers_with_localised_labels() { let mut s = harness_on(s); s.run("ShowUIPanel(BenillaOptionsFrame) BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()").unwrap(); // MONKEY (volumetric fog): account for the atmosphere row after water. - assert_eq!(s.eval::("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)").unwrap(), 18); - assert_eq!(s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[2]").unwrap(), "RowWaterQuality"); - assert_eq!(s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[16]").unwrap(), "RowLavaGlow"); + // MONKEY (presets): Graphics Preset and Render Distance lead the page: 27 -> 29. + assert_eq!(s.eval::("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)").unwrap(), 29); + assert_eq!(s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[4]").unwrap(), "RowWaterQuality"); + assert_eq!(s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[23]").unwrap(), "RowLavaGlow"); assert_eq!(s.eval::(&format!("return {ADVGFX}RowWaterQualityDropdownText:GetText()")).unwrap(), labels[1]); assert_eq!(s.eval::(&format!("return {ADVGFX}RowLavaGlowLabel:GetText()")).unwrap(), lava_label); assert!(s.eval::("return BENILLA_TOOLTIP_WATER_QUALITY == BENILLA_ADVGFX.tips.WATER_QUALITY and BENILLA_TOOLTIP_LAVA_GLOW == BENILLA_ADVGFX.tips.LAVA_GLOW").unwrap()); @@ -3033,6 +3034,15 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() { "BENILLA_TOOLTIP_LIGHTING_QUALITY", "AdvancedGraphicsRowLightingQuality", ), + // MONKEY (presets) + ( + "BENILLA_TOOLTIP_GRAPHICS_QUALITY", + "AdvancedGraphicsRowGraphicsQuality", + ), + ( + "BENILLA_TOOLTIP_RENDER_DISTANCE", + "AdvancedGraphicsRowRenderDistance", + ), ( "BENILLA_TOOLTIP_SHADOW_RESOLUTION", "AdvancedGraphicsRowShadowResolution", @@ -3143,7 +3153,8 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() { // changing pages does not change this count, only its entry in BENILLA_OWNED. 81 -> 93. // Water Quality and Lava Glow add two more: 93 -> 95. // MONKEY (daylight: terrain torch casters): Terrain Blocks Torchlight. 95 -> 96. - assert_eq!(checked, 96, "every tipped row carries a live key"); + // MONKEY (presets): Graphics Preset and Render Distance. +2. + assert_eq!(checked, 98, "every tipped row carries a live key"); assert_eq!( untipped, vec![