diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index b55e05b9..90009c46 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -601,6 +601,8 @@
rainSurfaces = "Wet Surfaces in Rain",
-- MONKEY (wind)
foliageWind = "Foliage Wind",
+ -- GFX (foliage distance)
+ foliageDistance = "Foliage Distance",
windGrass = "Grass",
windTrees = "Grass + Trees",
foliageWindStrength = "Foliage Wind Strength", -- MONKEY (fix-wind)
@@ -927,6 +929,17 @@
.. "is calmer, higher is a blustery day. Storms swing everything harder on top. "
.. "Grass still parts around characters the same way. Needs Foliage Wind on.",
-- MONKEY (ao): what the contact shadows touch and what they spare.
+ -- GFX (foliage distance): the ground-clutter horizon.
+ FOLIAGE_DISTANCE =
+ "How far grass, reeds and small bushes are drawn. 70 yd is the original "
+ .. "client's own limit, which is also where you can watch them appear as "
+ .. "you walk; pushing it out moves that edge far enough away that the "
+ .. "tufts arriving are too small to notice.\n\n"
+ .. "Doubling it to 140 yd measured free. Past that it costs a little, and "
+ .. "it costs it on the processor rather than the graphics card, which is "
+ .. "the side this client has less of.\n\n"
+ .. "Applies on Apply, because every patch of grass already drawn has to be "
+ .. "rebuilt to the new distance.",
AMBIENT_OCCLUSION =
"Soft contact shadows where surfaces meet: under props and furniture, in the corners "
.. "between walls and floors, and around characters' feet. Low uses fewer samples; "
@@ -1053,6 +1066,8 @@
rainSurfaces = "Мокрые поверхности в дождь",
-- MONKEY (wind)
foliageWind = "Ветер листвы",
+ -- GFX (foliage distance)
+ foliageDistance = "Дальность травы",
windGrass = "Трава",
windTrees = "Трава + деревья",
foliageWindStrength = "Сила ветра листвы", -- MONKEY (fix-wind)
@@ -1386,6 +1401,17 @@
.. "спокойнее, больше — ветреный день. В грозу всё качается ещё сильнее. "
.. "Трава по-прежнему расступается вокруг персонажей. Работает, если включён ветер листвы.",
-- MONKEY (ao): Russian contact-shadow explanation.
+ -- GFX (foliage distance)
+ FOLIAGE_DISTANCE =
+ "Насколько далеко рисуются трава, тростник и мелкие кусты. 70 ярдов — "
+ .. "собственный предел оригинального клиента, и на этом же расстоянии "
+ .. "видно, как они появляются при ходьбе; если отодвинуть край дальше, "
+ .. "появляющиеся кустики станут слишком мелкими, чтобы это замечать.\n\n"
+ .. "Удвоение до 140 ярдов по измерениям не стоит ничего. Дальше цена "
+ .. "появляется, и платит её процессор, а не видеокарта — а именно "
+ .. "процессора этому клиенту не хватает.\n\n"
+ .. "Применяется по кнопке «Применить»: всю уже нарисованную траву нужно "
+ .. "перестроить под новое расстояние.",
AMBIENT_OCCLUSION =
"Мягкие контактные тени там, где поверхности соприкасаются: под предметами и мебелью, "
.. "в углах между стенами и полом, у ног персонажей. «Низкое» использует меньше выборок; "
@@ -1505,6 +1531,7 @@
"RowFoliageWind",
-- MONKEY (fix-wind): the sway strength, right under its tier.
"RowFoliageWindStrength",
+ "RowFoliageDistance",
-- MONKEY (ao): appended so the other rows keep their indices.
"RowAmbientOcclusion",
-- MONKEY (lampfog)
@@ -4379,11 +4406,29 @@
-
+
+
+
+ OptionsRow_OnLoad(self, "foliageDistance",
+ BENILLA_ADVGFX.foliageDistance,
+ "BENILLA_TOOLTIP_FOLIAGE_DISTANCE")
+ -- `"yd"`: the stored value already IS a distance in
+ -- yards, like Shadow Distance beside it.
+ OptionsSliderRow_Setup(self, 70, 300, 10, "yd")
+ self.deferred = 1
+
+
+
+
+
+
+
OptionsRow_OnLoad(self, "ambientOcclusion", BENILLA_ADVGFX.ambientOcclusion,
"BENILLA_TOOLTIP_AMBIENT_OCCLUSION")
diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs
index 4127e4ae..c15e8148 100644
--- a/crates/benilla-app/src/cvars.rs
+++ b/crates/benilla-app/src/cvars.rs
@@ -706,6 +706,17 @@ pub(crate) const REGISTERED: &[Registered] = &[
/ Ultra, or Custom when the rows match none of them; the reference's options have no \
such preset",
),
+ // GFX (foliage distance): the ground-clutter horizon in yards. The registered default IS the
+ // reference's own hardcoded 70 (`clutter::DETAIL_DOODAD_FADE_FAR`, `[0x867958]`), so a fresh
+ // install and every preset render exactly what they rendered before this row existed; the
+ // player opts in. Deliberately NOT in any preset ladder for the same reason — it trades main
+ // thread for grass, and that is a judgement about a machine, not a quality tier.
+ ours(
+ "foliageDistance",
+ "70",
+ "benilla's own: how far ground clutter (grass, reeds, small bushes) is drawn, 70..300 yd \
+ (70 = the original client's own horizon)",
+ ),
// MONKEY (volumetric fog): saved live tier; capture override stays session-only.
ours(
"volumetricFog",
@@ -4457,6 +4468,30 @@ mod tests {
// `0` is not clutter-off: the callback pins it to 1.
apply(&mut app, "frillDensity", "9000");
assert_eq!(res::(&app).density, 16.0);
+ // GFX (foliage distance): the horizon, in yards, on the SAME resource and clamped to its
+ // own range. The floor is the reference's own 70 — a write below it is the setting trying
+ // to undo the client, which `WorldDetail` above is the knob for.
+ let shipped = *benilla_world::clutter::FOLIAGE_DISTANCE_RANGE.start();
+ assert_eq!(shipped, benilla_world::clutter::DETAIL_DOODAD_FADE_FAR);
+ apply(&mut app, "foliageDistance", "140");
+ assert_eq!(res::(&app).fade_far, 140.0);
+ apply(&mut app, "foliagedistance", "9000");
+ assert_eq!(
+ res::(&app).fade_far,
+ *benilla_world::clutter::FOLIAGE_DISTANCE_RANGE.end()
+ );
+ apply(&mut app, "foliageDistance", "0");
+ assert_eq!(
+ res::(&app).fade_far,
+ shipped,
+ "below the reference's own horizon clamps back to it"
+ );
+ // The registered default IS that floor, so an untouched install renders what it always did.
+ assert_eq!(
+ fresh_registry().get("foliageDistance"),
+ Some(shipped.to_string().trim_end_matches(".0")),
+ "the shipped default is the reference's own 70 yd"
+ );
apply(&mut app, "frillDensity", "0");
assert_eq!(res::(&app).density, 1.0 / 16.0);
apply(&mut app, "WorldDetail", "1");
diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs
index 2f4a7006..92061682 100644
--- a/crates/benilla-app/src/ui_script/options_tests.rs
+++ b/crates/benilla-app/src/ui_script/options_tests.rs
@@ -2619,10 +2619,11 @@ fn water_quality_writes_numeric_tiers_with_localised_labels() {
// GFX (auto-exposure): the tier and its blend: 52 -> 54.
// GFX (sharpening): the RCAS tier: 54 -> 55.
// GFX (froxel volumetrics): the shadowed-air dial beside the haze tier: 55 -> 56.
+ // GFX (foliage distance): the ground-clutter horizon, under the two wind rows: 56 -> 57.
assert_eq!(
s.eval::("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)")
.unwrap(),
- 56
+ 57
);
assert_eq!(
s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[4]")
@@ -3615,6 +3616,11 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
"BENILLA_TOOLTIP_VOLUMETRIC_FOG_SHADOW",
"AdvancedGraphicsRowVolumetricFogShadow",
),
+ // GFX (foliage distance): the ground-clutter horizon.
+ (
+ "BENILLA_TOOLTIP_FOLIAGE_DISTANCE",
+ "AdvancedGraphicsRowFoliageDistance",
+ ),
// MONKEY (sky): the sky tier row owns its translated tooltip.
(
"BENILLA_TOOLTIP_SKY_QUALITY",
@@ -3887,7 +3893,8 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
// GFX (auto-exposure): the tier and its blend. 134 -> 136.
// GFX (sharpening) / (field of view): one row each, on the two different pages. 136 -> 138.
// GFX (froxel volumetrics): the shadowed-air dial beside the haze tier. 138 -> 139.
- assert_eq!(checked, 139, "every tipped row carries a live key");
+ // GFX (foliage distance): the ground-clutter horizon. 139 -> 140.
+ assert_eq!(checked, 140, "every tipped row carries a live key");
assert_eq!(
untipped,
vec![
@@ -3996,7 +4003,7 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
// GFX (shadow rig): the five Advanced Graphics shadow rows. 119 -> 124.
// GFX (interior rig): the ten interior-lighting rows. 124 -> 134.
// GFX (auto-exposure): the tier and its blend. 134 -> 136.
- assert_eq!(raised, 139, "every row but Auto Loot raises a description");
+ assert_eq!(raised, 140, "every row but Auto Loot raises a description");
}
/// 1.12's AdvancedOptionsCombatText box as saved-global rows: a click writes the global, never a
diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs
index 5655e90f..af51de8d 100644
--- a/crates/benilla-app/src/video.rs
+++ b/crates/benilla-app/src/video.rs
@@ -705,6 +705,17 @@ pub(crate) fn on_cvar(
&clutter.frill_density().to_string(),
);
}
+ // GFX (foliage distance): the ground-clutter horizon in yards. Writes the world resource
+ // directly, like the two density spellings below it — `ClutterConfig` is the lane's own
+ // resource and `VideoConfig` holds no copy, so there is nothing here to keep in step.
+ // `clutter::remesh_on_material_change` re-cuts the built chunks off this write, which is
+ // why the Options row commits on Apply rather than on the drag.
+ "foliagedistance" => {
+ clutter.fade_far = v.clamp(
+ *benilla_world::clutter::FOLIAGE_DISTANCE_RANGE.start(),
+ *benilla_world::clutter::FOLIAGE_DISTANCE_RANGE.end(),
+ )
+ }
// The same knob in the reference's cells per chunk, clamped to `[1, 256]`
// (`ClutterConfig::set_frill_density`); the loaded tiles re-scatter off the change.
"frilldensity" => {
diff --git a/crates/benilla-world/src/clutter.rs b/crates/benilla-world/src/clutter.rs
index b43113a8..1a16a51e 100644
--- a/crates/benilla-world/src/clutter.rs
+++ b/crates/benilla-world/src/clutter.rs
@@ -33,7 +33,7 @@ impl Plugin for ClutterPlugin {
.add_systems(
Update,
(
- remesh_on_cutout_change,
+ remesh_on_material_change,
stream_chunk_clutter,
evict_clutter_geometry,
scope_clutter_geometry,
@@ -43,19 +43,26 @@ impl Plugin for ClutterPlugin {
}
}
-/// Drop the built meshes when the cutout value moves (a density write must not re-mesh): the ref is
-/// baked into the material. The reference's `detailDoodadAlpha` is a console command (`0x6739a0`,
-/// registered at `0x63f9e0`), not a CVar, so it never persists.
-fn remesh_on_cutout_change(
+/// Drop the built meshes when anything BAKED INTO THE MATERIAL moves (a density write must not
+/// re-mesh, since that re-scatters on its own): the alpha-test ref and the distance fade are both
+/// part of the material key, so a built mesh keeps the old one until it is re-cut.
+///
+/// The reference's `detailDoodadAlpha` is a console command (`0x6739a0`, registered at `0x63f9e0`),
+/// not a CVar, so it never persists. GFX (foliage distance): `fade_far` IS a CVar
+/// (`foliageDistance`), and it rides here for the same reason — `world_assets` keys the material on
+/// `fade_far.round() as u16`, so without this a live change builds NEW chunks at the new horizon
+/// while every chunk already standing keeps fading at the old one.
+fn remesh_on_material_change(
mut commands: Commands,
cfg: Res,
mut chunks: Query<&mut ClutterChunk>,
- mut last: Local