feat(render): implement Campaign AR and terrain fidelity

This commit is contained in:
Erik 2026-08-22 13:13:29 +02:00
parent 99cf26e00c
commit 7a5f96ede5
368 changed files with 50611 additions and 950 deletions

View file

@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.Plugin.Abstractions.Rendering;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.UI.Layout;
@ -118,8 +120,9 @@ namespace AcDream.App.UI.Layout;
/// multiplier, a different unit system with its own live legacy-panel
/// consumer; no gamma-correction render pass exists for either); Automatic
/// Degrades/Graphics Performance/Degrade Distance/the four texture-detail-
/// family menus/Building Detail Textures/Multi-Pass Alpha (the renderer is
/// Vulkan + one aggregate <c>QualityPreset</c>, no per-feature knobs);
/// family menus/Multi-Pass Alpha (the renderer is Vulkan + one aggregate
/// <c>QualityPreset</c>, no per-feature knobs). Building Detail Textures is
/// LIVE: #226 consumes it directly in the retail building/EnvCell detail pass;
/// Camera Stiffness/Adjustment Speed/Align To Slope/Mouse Look
/// Sensitivity/Invert Mouselook Y Axis/Use Mouse Turning (TS-74 — no
/// persistent mouse-turning camera mode exists for ANY of these six to
@ -135,8 +138,8 @@ namespace AcDream.App.UI.Layout;
///
/// <para>
/// <b>Caption dimming (AD-78, user-directed, 2026-08-11, gate 2).</b> Every
/// STORE-ONLY row above (the 21 rows named in the paragraph before this one
/// -- AP-198's ten, AP-199's three, and TS-74's six Camera/Input rows plus
/// STORE-ONLY row above (the 20 rows named in the paragraph before this one
/// -- AP-198's nine, AP-199's three, and TS-74's six Camera/Input rows plus
/// AP-200's two Chat-font rows) renders its caption in
/// <see cref="UiRenderContext.StoreOnlyCaptionColor"/> instead of the normal
/// white/DAT-authored color. The row stays fully interactive -- it still
@ -343,7 +346,47 @@ public static class ConfigOptionsPageController
Func<CameraTurningSettings> LoadCameraTurning,
Action<CameraTurningSettings> SaveCameraTurning,
Func<ChatSettings> LoadChat,
Action<ChatSettings> SaveChat);
Action<ChatSettings> SaveChat)
{
/// <summary>
/// Optional modern extension appended after retail's exact 39 authored
/// rows. Null preserves the byte-verified retail Config-page shape.
/// </summary>
public RenderPackBindings? RenderPacks { get; init; }
}
public sealed record RenderPackBindings(
Func<IReadOnlyList<RenderPackChoice>> LoadChoices)
{
public Func<long>? LoadRevision { get; init; }
public Func<string?>? LoadFailureNotice { get; init; }
}
public sealed record RenderPackChoice(
string Id,
string DisplayName,
string? Version,
bool Selectable,
string? UnavailableReason,
IReadOnlyList<RenderPackPresetChoice> Presets)
{
public string FeatureSummary { get; init; } = string.Empty;
public IReadOnlyList<RenderSettingDeclaration> Settings { get; init; } = [];
}
public sealed record RenderPackPresetChoice(
string Id,
string DisplayName,
bool Selectable,
string? UnavailableReason)
{
public IReadOnlyList<RenderQualitySettingOverride> SettingOverrides { get; init; } = [];
public long MaxResidentGpuBytes { get; init; }
public double MaxIncrementalGpuMillisecondsP50 { get; init; }
public double MaxIncrementalGpuMillisecondsP99 { get; init; }
public double MaxIncrementalCpuMillisecondsP50 { get; init; }
public double MaxIncrementalCpuMillisecondsP99 { get; init; }
}
/// <summary>
/// Builds the six authored sections (Sound/Camera/Graphics/Rendering
@ -455,9 +498,518 @@ public static class ConfigOptionsPageController
// built 38 — five interior separators, no trailing one).
BuildSeparatorRow(listBox);
if (bindings.RenderPacks is not null)
BindRenderPackSection(
listBox,
page,
bindings,
bindings.RenderPacks,
resolveSprite,
datFont,
debugFont);
return true;
}
private static void BindRenderPackSection(
UiTemplateListBox listBox,
OptionPage page,
Bindings bindings,
RenderPackBindings renderPacks,
Func<uint, (uint tex, int w, int h)>? resolveSprite,
UiDatFont? datFont,
BitmapFont? debugFont)
{
List<RenderPackChoice> choices = LoadPackChoices(renderPacks);
long observedRevision = renderPacks.LoadRevision?.Invoke() ?? 0;
var menuChoices = new ExplicitMenuChoiceSource(
choices.Select(static value =>
new ExplicitMenuChoice(
value.Id,
value.DisplayName,
value.Selectable,
PackTooltip(value))).ToArray());
BuildExplicitHeaderRow(listBox, "Graphics Enhancements");
RenderPackTailOwner? tail = null;
StringOptionRow? packOption = null;
UiMenu? packMenu = BuildExplicitStringMenuRow(
listBox,
"Shader pack",
menuChoices.Choices,
page,
read: () => NormalizePackId(bindings.LoadDisplay().RenderPack, choices),
apply: selectedId =>
{
RenderPackChoice? selected = choices.FirstOrDefault(value =>
value.Selectable && string.Equals(
value.Id,
selectedId,
StringComparison.OrdinalIgnoreCase));
RenderPackPresetChoice? preset = selected?.Presets.FirstOrDefault(
static value => value.Selectable);
if (selected is null || preset is null)
return;
bindings.SaveDisplay(bindings.LoadDisplay() with
{
RenderPack = new RenderPackSelectionSettings(
selected.Id,
selected.Version,
preset.Id),
});
tail?.RebuildPackTail(selected);
},
defaultValue: RenderPackSelectionSettings.RetailPackId,
resolveSprite,
datFont,
debugFont,
menuChoices,
option => packOption = option);
string initialPackId = NormalizePackId(bindings.LoadDisplay().RenderPack, choices);
RenderPackChoice initialPack = choices.First(value =>
string.Equals(value.Id, initialPackId, StringComparison.OrdinalIgnoreCase));
tail = new RenderPackTailOwner(
listBox,
page,
bindings,
retainedItemCount: listBox.ItemCount,
retainedOptionCount: page.Rows.Count,
resolveSprite,
datFont,
debugFont);
tail.RebuildPackTail(initialPack);
if (packMenu is not null)
{
packMenu.BeforeOpen = () =>
{
if (renderPacks.LoadRevision is not { } loadRevision)
return;
long revision = loadRevision();
if (revision == observedRevision)
return;
observedRevision = revision;
choices = LoadPackChoices(renderPacks);
menuChoices.Choices = choices.Select(static value =>
new ExplicitMenuChoice(
value.Id,
value.DisplayName,
value.Selectable,
PackTooltip(value))).ToArray();
packMenu.Items = menuChoices.Choices
.Select(static choice => new UiMenu.MenuItem(
choice.Label,
choice.Id))
.ToArray();
string selectedId = NormalizePackId(
bindings.LoadDisplay().RenderPack,
choices);
packMenu.Selected = selectedId;
packMenu.TooltipText = menuChoices.Choices.FirstOrDefault(choice =>
string.Equals(
choice.Id,
selectedId,
StringComparison.OrdinalIgnoreCase))
.Tooltip;
RenderPackChoice selected = choices.First(value => string.Equals(
value.Id,
selectedId,
StringComparison.OrdinalIgnoreCase));
tail.RebuildPackTail(selected);
// Catalog withdrawal/update is an external state change, not
// an unsaved user edit. Rebase Reset to the now-valid live
// selection so it cannot resurrect a withdrawn pack id.
packOption?.SaveCurrentValue();
};
packMenu.TooltipTextProvider = () => CombineTooltip(
renderPacks.LoadFailureNotice?.Invoke(),
PackTooltip(choices.FirstOrDefault(value => string.Equals(
value.Id,
packMenu.Selected as string,
StringComparison.OrdinalIgnoreCase))));
}
}
private static List<RenderPackChoice> LoadPackChoices(
RenderPackBindings renderPacks)
{
IReadOnlyList<RenderPackChoice> discovered = renderPacks.LoadChoices();
var choices = new List<RenderPackChoice>(discovered.Count + 1)
{
new(
RenderPackSelectionSettings.RetailPackId,
"acdream default (retail-faithful)",
Version: null,
Selectable: true,
UnavailableReason: null,
[new RenderPackPresetChoice(
RenderPackSelectionSettings.RetailPresetId,
"Off",
Selectable: true,
UnavailableReason: null)]),
};
choices.AddRange(discovered.Where(static choice =>
!string.Equals(
choice.Id,
RenderPackSelectionSettings.RetailPackId,
StringComparison.OrdinalIgnoreCase)));
return choices;
}
private static string NormalizePackId(
RenderPackSelectionSettings selection,
IReadOnlyList<RenderPackChoice> choices) =>
choices.Any(value => string.Equals(
value.Id,
selection.PackId,
StringComparison.OrdinalIgnoreCase) && value.Selectable)
? selection.PackId
: RenderPackSelectionSettings.RetailPackId;
private static string NormalizePresetId(
string presetId,
RenderPackChoice pack) =>
pack.Presets.Any(value => value.Selectable && string.Equals(
value.Id,
presetId,
StringComparison.OrdinalIgnoreCase))
? presetId
: pack.Presets.FirstOrDefault(static value => value.Selectable)?.Id
?? RenderPackSelectionSettings.RetailPresetId;
private static string PackTooltip(RenderPackChoice? pack)
{
if (pack is null)
return "acdream's default retail-faithful renderer remains authoritative unless an enhancement pack is explicitly selected.";
string summary = string.IsNullOrWhiteSpace(pack.FeatureSummary)
? "acdream's default retail-faithful renderer remains authoritative unless an enhancement pack is explicitly selected."
: pack.FeatureSummary.Trim();
return CombineTooltip(pack.UnavailableReason, summary) ?? summary;
}
private static string PresetTooltip(RenderPackPresetChoice preset)
{
double residentMiB = preset.MaxResidentGpuBytes / (1024d * 1024d);
string estimate = string.Format(
CultureInfo.InvariantCulture,
"Estimated ceiling — GPU p50/p99 ≤ {0:0.###}/{1:0.###} ms; "
+ "render CPU p50/p99 ≤ {2:0.###}/{3:0.###} ms; pack VRAM ≤ {4:0.##} MiB.",
preset.MaxIncrementalGpuMillisecondsP50,
preset.MaxIncrementalGpuMillisecondsP99,
preset.MaxIncrementalCpuMillisecondsP50,
preset.MaxIncrementalCpuMillisecondsP99,
residentMiB);
return CombineTooltip(preset.UnavailableReason, estimate) ?? estimate;
}
private static string? CombineTooltip(string? first, string? second)
{
bool hasFirst = !string.IsNullOrWhiteSpace(first);
bool hasSecond = !string.IsNullOrWhiteSpace(second);
if (!hasFirst)
return hasSecond ? second!.Trim() : null;
if (!hasSecond)
return first!.Trim();
return first!.Trim() + Environment.NewLine + second!.Trim();
}
private readonly record struct ExplicitMenuChoice(
string Id,
string Label,
bool Enabled,
string? Tooltip);
private sealed class ExplicitMenuChoiceSource(
IReadOnlyList<ExplicitMenuChoice> choices)
{
internal IReadOnlyList<ExplicitMenuChoice> Choices { get; set; } = choices;
}
/// <summary>
/// Owns only the modern extension suffix after the persistent pack picker.
/// Pack changes replace preset + settings; preset changes replace settings
/// only. Generation guards make externally-retained stale widgets inert,
/// while the ListBox and OptionPage tail APIs remove their visual and verb
/// ownership synchronously.
/// </summary>
private sealed class RenderPackTailOwner(
UiTemplateListBox listBox,
OptionPage page,
Bindings bindings,
int retainedItemCount,
int retainedOptionCount,
Func<uint, (uint tex, int w, int h)>? resolveSprite,
UiDatFont? datFont,
BitmapFont? debugFont)
{
private readonly int _retainedItemCount = retainedItemCount;
private readonly int _retainedOptionCount = retainedOptionCount;
private int _packGeneration;
private int _settingsGeneration;
private int _settingsItemCount;
private int _settingsOptionCount;
internal void RebuildPackTail(RenderPackChoice pack)
{
int generation = ++_packGeneration;
++_settingsGeneration;
listBox.RemoveTail(_retainedItemCount);
page.RemoveTail(_retainedOptionCount);
DisplaySettings display = bindings.LoadDisplay();
string presetId = NormalizePresetId(display.RenderPack.PresetId, pack);
RenderPackPresetChoice preset = pack.Presets.First(value =>
string.Equals(value.Id, presetId, StringComparison.OrdinalIgnoreCase));
BuildExplicitStringMenuRow(
listBox,
"Quality preset",
pack.Presets.Select(static value =>
new ExplicitMenuChoice(
value.Id,
value.DisplayName,
value.Selectable,
PresetTooltip(value))).ToArray(),
page,
read: () => NormalizePresetId(
bindings.LoadDisplay().RenderPack.PresetId,
pack),
apply: selectedPresetId =>
{
if (generation != _packGeneration)
return;
RenderPackPresetChoice? selected = pack.Presets.FirstOrDefault(value =>
value.Selectable && string.Equals(
value.Id,
selectedPresetId,
StringComparison.OrdinalIgnoreCase));
DisplaySettings current = bindings.LoadDisplay();
if (selected is null || !SamePack(current.RenderPack, pack))
return;
bindings.SaveDisplay(current with
{
RenderPack = current.RenderPack with
{
PresetId = selected.Id,
SettingOverrides = SanitizeOverrides(
pack,
current.RenderPack.SettingOverrides),
},
});
RebuildSettingsTail(pack, selected);
},
defaultValue: preset.Id,
resolveSprite,
datFont,
debugFont);
_settingsItemCount = listBox.ItemCount;
_settingsOptionCount = page.Rows.Count;
RebuildSettingsTail(pack, preset);
}
private void RebuildSettingsTail(
RenderPackChoice pack,
RenderPackPresetChoice preset)
{
int generation = ++_settingsGeneration;
listBox.RemoveTail(_settingsItemCount);
page.RemoveTail(_settingsOptionCount);
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (RenderSettingDeclaration setting in pack.Settings)
{
if (!ids.Add(setting.Id) || !CanBuildSetting(setting))
continue;
BuildSetting(pack, preset, setting, generation);
}
BuildSeparatorRow(listBox);
}
private void BuildSetting(
RenderPackChoice pack,
RenderPackPresetChoice preset,
RenderSettingDeclaration setting,
int generation)
{
bool IsCurrent() => generation == _settingsGeneration
&& SamePack(bindings.LoadDisplay().RenderPack, pack)
&& string.Equals(
bindings.LoadDisplay().RenderPack.PresetId,
preset.Id,
StringComparison.OrdinalIgnoreCase);
string Read() => ResolveSettingValue(
pack,
preset,
setting,
bindings.LoadDisplay().RenderPack.SettingOverrides);
string defaultValue = ResolveSettingValue(
pack,
preset,
setting,
RenderPackSettingOverrides.Empty);
void Apply(string value)
{
if (!IsCurrent()
|| !RenderPackSettingValueCodec.TryEncode(setting, value, out _))
return;
DisplaySettings current = bindings.LoadDisplay();
RenderPackSettingOverrides clean = SanitizeOverrides(
pack,
current.RenderPack.SettingOverrides);
bindings.SaveDisplay(current with
{
RenderPack = current.RenderPack with
{
SettingOverrides = clean.Set(setting.Id, value),
},
});
}
switch (setting.Kind)
{
case RenderSettingKind.Boolean:
BuildExplicitToggleRow(
listBox,
setting.DisplayName,
bool.Parse(defaultValue),
page,
read: () => bool.Parse(Read()),
apply: value => Apply(value ? "true" : "false"),
IsCurrent);
break;
case RenderSettingKind.Float:
case RenderSettingKind.Integer:
double min = setting.Minimum!.Value;
double max = setting.Maximum!.Value;
double step = setting.Step
?? (setting.Kind == RenderSettingKind.Integer ? 1d : 0d);
BuildExplicitNumericSliderRow(
listBox,
setting.DisplayName,
min,
max,
step,
setting.Kind == RenderSettingKind.Integer,
double.Parse(defaultValue, CultureInfo.InvariantCulture),
page,
read: () => double.Parse(Read(), CultureInfo.InvariantCulture),
apply: value => Apply(FormatNumeric(value, setting.Kind)),
IsCurrent);
break;
case RenderSettingKind.Choice:
BuildExplicitStringMenuRow(
listBox,
setting.DisplayName,
setting.Choices.Select(static value =>
new ExplicitMenuChoice(value, value, true, null)).ToArray(),
page,
read: Read,
apply: value =>
{
if (IsCurrent()) Apply(value);
},
defaultValue,
resolveSprite,
datFont,
debugFont);
break;
}
}
private static bool CanBuildSetting(RenderSettingDeclaration setting)
{
if (string.IsNullOrWhiteSpace(setting.Id)
|| string.IsNullOrWhiteSpace(setting.DisplayName)
|| !RenderPackSettingValueCodec.TryEncode(
setting,
setting.DefaultValue,
out _))
return false;
if (setting.Kind is RenderSettingKind.Float or RenderSettingKind.Integer)
{
return setting.Minimum is { } min
&& setting.Maximum is { } max
&& double.IsFinite(min)
&& double.IsFinite(max)
&& min >= -float.MaxValue
&& max <= float.MaxValue
&& max > min
&& (setting.Step is null
|| double.IsFinite(setting.Step.Value) && setting.Step.Value > 0);
}
return setting.Kind is RenderSettingKind.Boolean
|| setting.Kind == RenderSettingKind.Choice && setting.Choices.Count > 0;
}
private static string ResolveSettingValue(
RenderPackChoice pack,
RenderPackPresetChoice preset,
RenderSettingDeclaration setting,
IReadOnlyDictionary<string, string> userOverrides)
{
if (TryGet(userOverrides, setting.Id, out string? user)
&& RenderPackSettingValueCodec.TryEncode(setting, user, out _))
return user;
RenderQualitySettingOverride? presetValue = preset.SettingOverrides
.FirstOrDefault(value => string.Equals(
value.SettingId,
setting.Id,
StringComparison.OrdinalIgnoreCase));
if (presetValue is not null
&& RenderPackSettingValueCodec.TryEncode(setting, presetValue.Value, out _))
return presetValue.Value;
return setting.DefaultValue;
}
private static RenderPackSettingOverrides SanitizeOverrides(
RenderPackChoice pack,
IReadOnlyDictionary<string, string> overrides)
{
var valid = new List<KeyValuePair<string, string>>();
foreach ((string id, string value) in overrides)
{
RenderSettingDeclaration? setting = pack.Settings.FirstOrDefault(candidate =>
string.Equals(candidate.Id, id, StringComparison.OrdinalIgnoreCase));
if (setting is not null
&& RenderPackSettingValueCodec.TryEncode(setting, value, out _))
valid.Add(new KeyValuePair<string, string>(setting.Id, value));
}
return new RenderPackSettingOverrides(valid);
}
private static bool TryGet(
IReadOnlyDictionary<string, string> values,
string id,
out string value)
{
if (values.TryGetValue(id, out value!))
return true;
foreach ((string key, string candidate) in values)
{
if (string.Equals(key, id, StringComparison.OrdinalIgnoreCase))
{
value = candidate;
return true;
}
}
value = string.Empty;
return false;
}
private static bool SamePack(
RenderPackSelectionSettings selection,
RenderPackChoice pack) =>
string.Equals(selection.PackId, pack.Id, StringComparison.OrdinalIgnoreCase)
&& string.Equals(selection.PackVersion, pack.Version, StringComparison.Ordinal);
private static string FormatNumeric(double value, RenderSettingKind kind) =>
kind == RenderSettingKind.Integer
? checked((long)Math.Round(value)).ToString(CultureInfo.InvariantCulture)
: value.ToString("R", CultureInfo.InvariantCulture);
}
// ── Section 1: Sound Options ────────────────────────────────────────
private static void BindSoundSection(
@ -729,9 +1281,9 @@ public static class ConfigOptionsPageController
{
BuildHeaderRow(listBox, "ID_Graphics_TextureSection", resolveString);
// The whole section is store-only: the world renderer is
// Vulkan + one aggregate QualityPreset, not per-feature knobs
// (register row, OP6).
// Every row except Building Detail Textures is still store-only:
// #226 consumes that existing preference in the retail building and
// EnvCell detail replay. The other rows remain aggregate-preset gaps.
BuildMenuRow(
listBox, "ID_Graphics_LandscapeTextureDetail", TextureDetailChoices, page, resolveString,
read: () => bindings.LoadDisplay().LandscapeTextureDetail,
@ -772,7 +1324,7 @@ public static class ConfigOptionsPageController
listBox, "ID_Graphics_BuildingDetailTextures", defaultValue: true, page, resolveString,
read: () => bindings.LoadDisplay().BuildingDetailTextures,
apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { BuildingDetailTextures = value }),
storeOnly: true); // AP-198
storeOnly: false); // LIVE — #226 retail building/EnvCell detail pass
BuildToggleRow(
listBox, "ID_Graphics_MultiPassAlpha", defaultValue: false, page, resolveString,
@ -916,6 +1468,23 @@ public static class ConfigOptionsPageController
header.LinesProvider = () => new[] { new UiText.Line(label, header.DefaultColor) };
}
private static void BuildExplicitHeaderRow(
UiTemplateListBox listBox,
string label)
{
if (listBox.AddItemFromTemplateList(HeaderTemplateIndex) is not UiText header)
{
Console.WriteLine(
"[render-pack] Config header template did not build as UiText.");
return;
}
header.LinesProvider = () => new[]
{
new UiText.Line(label, header.DefaultColor),
};
}
private static void BuildSeparatorRow(UiTemplateListBox listBox)
{
if (listBox.AddItemFromTemplateList(SeparatorTemplateIndex) is null)
@ -1314,6 +1883,231 @@ public static class ConfigOptionsPageController
};
}
private static UiMenu? BuildExplicitStringMenuRow(
UiTemplateListBox listBox,
string labelText,
IReadOnlyList<ExplicitMenuChoice> choices,
OptionPage page,
Func<string> read,
Action<string> apply,
string defaultValue,
Func<uint, (uint tex, int w, int h)>? resolveSprite,
UiDatFont? datFont,
BitmapFont? debugFont,
ExplicitMenuChoiceSource? dynamicChoices = null,
Action<StringOptionRow>? captureOption = null)
{
UiElement? row = listBox.AddItemFromTemplateList(MenuTemplateIndex);
if (row is null)
{
Console.WriteLine(
$"[render-pack] Config menu template did not build for '{labelText}'.");
return null;
}
if (UiElement.FindDescendant(row, MenuLabelElementId) is UiText label)
{
label.LinesProvider = () => new[]
{
new UiText.Line(labelText, label.DefaultColor),
};
}
if (UiElement.FindDescendant(row, MenuElementId) is not UiMenu menu)
{
Console.WriteLine(
$"[render-pack] No UiMenu leaf found for '{labelText}'.");
return null;
}
var choiceSource = dynamicChoices ?? new ExplicitMenuChoiceSource(choices);
ApplyMenuChrome(menu, resolveSprite, datFont, debugFont);
menu.Items = choiceSource.Choices
.Select(static choice => new UiMenu.MenuItem(choice.Label, choice.Id))
.ToArray();
menu.EnabledProvider = payload => choiceSource.Choices.Any(choice =>
choice.Enabled && Equals(choice.Id, payload));
string initial = read();
menu.Selected = initial;
menu.ButtonLabelProvider = () =>
{
string current = menu.Selected as string ?? initial;
return choiceSource.Choices.FirstOrDefault(choice => string.Equals(
choice.Id,
current,
StringComparison.OrdinalIgnoreCase))
.Label ?? current;
};
menu.TooltipText = choiceSource.Choices.FirstOrDefault(choice => string.Equals(
choice.Id,
initial,
StringComparison.OrdinalIgnoreCase))
.Tooltip;
var option = new StringOptionRow(
initial,
defaultValue,
apply: value =>
{
menu.Selected = value;
menu.TooltipText = choiceSource.Choices.FirstOrDefault(choice => string.Equals(
choice.Id,
value,
StringComparison.OrdinalIgnoreCase))
.Tooltip;
apply(value);
},
read,
refresh: value => menu.Selected = value);
page.Register(option);
captureOption?.Invoke(option);
menu.OnSelect = payload =>
{
if (payload is string value && choiceSource.Choices.Any(choice =>
choice.Enabled && string.Equals(
choice.Id,
value,
StringComparison.OrdinalIgnoreCase)))
option.SetCurrentValue(value);
};
return menu;
}
private static UiButton? BuildExplicitToggleRow(
UiTemplateListBox listBox,
string labelText,
bool defaultValue,
OptionPage page,
Func<bool> read,
Action<bool> apply,
Func<bool> isCurrent)
{
UiElement? row = listBox.AddItemFromTemplateList(ToggleTemplateIndex);
UiButton? checkbox = row is null ? null : FindCheckbox(row);
if (checkbox is null)
{
Console.WriteLine(
$"[render-pack] Toggle template did not build for '{labelText}'.");
return null;
}
checkbox.Label = labelText;
checkbox.LabelColor = Vector4.One;
bool initial = read();
checkbox.Selected = initial;
var option = new BoolOptionRow(
initial,
defaultValue,
apply: value =>
{
checkbox.Selected = value;
if (isCurrent()) apply(value);
},
read: () => isCurrent() ? read() : initial,
refresh: value => checkbox.Selected = value);
page.Register(option);
checkbox.OnClick = () =>
{
if (isCurrent()) option.SetCurrentValue(checkbox.Selected);
};
return checkbox;
}
private static UiScrollbar? BuildExplicitNumericSliderRow(
UiTemplateListBox listBox,
string labelText,
double min,
double max,
double step,
bool integer,
double defaultValue,
OptionPage page,
Func<double> read,
Action<double> apply,
Func<bool> isCurrent)
{
UiElement? row = listBox.AddItemFromTemplateList(RangedSliderTemplateIndex);
if (row is null)
{
Console.WriteLine(
$"[render-pack] Slider template did not build for '{labelText}'.");
return null;
}
if (UiElement.FindDescendant(row, SliderLabelElementId) is UiText label)
{
label.LinesProvider = () =>
[
new UiText.Line(labelText, label.DefaultColor),
];
}
if (UiElement.FindDescendant(row, SliderRangeMinElementId) is UiText low)
{
string text = FormatExplicitNumber(min, integer);
low.LinesProvider = () => [new UiText.Line(text, low.DefaultColor)];
}
if (UiElement.FindDescendant(row, SliderRangeMaxElementId) is UiText high)
{
string text = FormatExplicitNumber(max, integer);
high.LinesProvider = () => [new UiText.Line(text, high.DefaultColor)];
}
if (UiElement.FindDescendant(row, SliderElementId) is not UiScrollbar slider)
{
Console.WriteLine(
$"[render-pack] No slider leaf found for '{labelText}'.");
return null;
}
double initialValue = SnapExplicitNumber(read(), min, max, step, integer);
float initial = (float)initialValue;
slider.SetScalarPosition((float)((initialValue - min) / (max - min)));
var option = new FloatOptionRow(
initial,
(float)SnapExplicitNumber(defaultValue, min, max, step, integer),
apply: value =>
{
double snapped = SnapExplicitNumber(value, min, max, step, integer);
slider.SetScalarPosition((float)((snapped - min) / (max - min)));
if (isCurrent()) apply(snapped);
},
read: () => isCurrent()
? (float)SnapExplicitNumber(read(), min, max, step, integer)
: initial,
refresh: value =>
{
double snapped = SnapExplicitNumber(value, min, max, step, integer);
slider.SetScalarPosition((float)((snapped - min) / (max - min)));
});
page.Register(option);
slider.ScalarChanged = normalized =>
{
if (!isCurrent()) return;
double raw = min + normalized * (max - min);
option.SetCurrentValue((float)SnapExplicitNumber(raw, min, max, step, integer));
};
return slider;
}
private static double SnapExplicitNumber(
double value,
double min,
double max,
double step,
bool integer)
{
double clamped = Math.Clamp(value, min, max);
if (step > 0)
clamped = min + Math.Round((clamped - min) / step) * step;
if (integer)
clamped = Math.Round(clamped);
return Math.Clamp(clamped, min, max);
}
private static string FormatExplicitNumber(double value, bool integer) =>
integer
? checked((long)Math.Round(value)).ToString(CultureInfo.InvariantCulture)
: value.ToString("R", CultureInfo.InvariantCulture);
private static void ApplyLabelAndTooltip(
UiButton checkbox, string labelKey, Func<uint, uint, string?> resolveString, bool storeOnly)
{