feat(render): implement Campaign AR and terrain fidelity
This commit is contained in:
parent
99cf26e00c
commit
7a5f96ede5
368 changed files with 50611 additions and 950 deletions
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue