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)
{

View file

@ -678,6 +678,24 @@ public sealed class OptionPage
_rows.Add(row);
}
/// <summary>
/// Detaches a dynamically-owned suffix while preserving the registered
/// prefix. Detached rows no longer participate in Apply/Reset/Defaults and
/// their page-notify callback is severed, so retained widgets removed from
/// the visual tree cannot keep this page alive or re-arm its buttons.
/// </summary>
public void RemoveTail(int retainedRowCount)
{
if (retainedRowCount < 0 || retainedRowCount > _rows.Count)
throw new ArgumentOutOfRangeException(nameof(retainedRowCount));
for (int i = _rows.Count - 1; i >= retainedRowCount; i--)
{
_rows[i].AttachPageNotify(static () => { });
_rows.RemoveAt(i);
}
OnOptionChanged?.Invoke();
}
/// <summary><c>OptionPage::Changed @0x004F2D60</c>: true if ANY
/// registered row's own <see cref="IOptionRow.Changed"/> is true.</summary>
public bool Changed => _rows.Any(static row => row.Changed);
@ -687,8 +705,9 @@ public sealed class OptionPage
/// <see cref="AfterApply"/>, then re-evaluates <see cref="OnOptionChanged"/>.</summary>
public void Apply()
{
foreach (IOptionRow row in _rows)
row.SaveCurrentValue();
foreach (IOptionRow row in _rows.ToArray())
if (_rows.Contains(row))
row.SaveCurrentValue();
AfterApply?.Invoke();
OnOptionChanged?.Invoke();
}
@ -702,7 +721,8 @@ public sealed class OptionPage
public void Reset()
{
foreach (IOptionRow row in _rows.Where(static row => row.Changed).ToArray())
row.RestoreSavedValue();
if (_rows.Contains(row))
row.RestoreSavedValue();
OnOptionChanged?.Invoke();
}
@ -711,8 +731,9 @@ public sealed class OptionPage
/// committing, then re-evaluates <see cref="OnOptionChanged"/>.</summary>
public void Defaults()
{
foreach (IOptionRow row in _rows)
row.RestoreDefaultValue();
foreach (IOptionRow row in _rows.ToArray())
if (_rows.Contains(row))
row.RestoreDefaultValue();
OnOptionChanged?.Invoke();
}
@ -735,8 +756,9 @@ public sealed class OptionPage
/// </summary>
public void ReloadFromLive()
{
foreach (IOptionRow row in _rows)
row.SaveCurrentValue();
foreach (IOptionRow row in _rows.ToArray())
if (_rows.Contains(row))
row.SaveCurrentValue();
OnOptionChanged?.Invoke();
}

View file

@ -224,7 +224,11 @@ public sealed record OptionsRuntimeBindings(
Func<DisplaySettings> LoadDisplay,
Action<DisplaySettings> SaveDisplay,
Func<AudioSettings> LoadAudio,
Action<AudioSettings> SaveAudio);
Action<AudioSettings> SaveAudio,
Func<IReadOnlyList<Layout.ConfigOptionsPageController.RenderPackChoice>>?
LoadRenderPackChoices = null,
Func<long>? LoadRenderPackCatalogRevision = null,
Func<string?>? LoadRenderPackFailureNotice = null);
/// <summary>
/// Campaign FA slice FA3: the social panel's (Friends/Allegiance/
@ -2884,7 +2888,18 @@ public sealed class RetailUiRuntime : IDisposable
// silently clobber CH6's filter/opacity edits with a
// stale snapshot the next time either surface saves.
LoadChat: () => _bindings.Chat.Store?.LoadChat() ?? ChatSettings.Default,
SaveChat: chat => _bindings.Chat.Store?.SaveChat(chat)),
SaveChat: chat => _bindings.Chat.Store?.SaveChat(chat))
{
RenderPacks = _bindings.Options.LoadRenderPackChoices is { } load
? new Layout.ConfigOptionsPageController.RenderPackBindings(load)
{
LoadRevision =
_bindings.Options.LoadRenderPackCatalogRevision,
LoadFailureNotice =
_bindings.Options.LoadRenderPackFailureNotice,
}
: null,
},
// #378: the eight Config-tab dropdown menus need the SAME
// sprite/font resolvers every other retail-menu consumer
// (ChatWindowController's channel menu, VendorUiController's

View file

@ -23,6 +23,29 @@ public interface IRetailUiAutomationCheckpoint
string? Error { get; }
}
public enum RetailUiAutomationRenderPackState
{
Retail,
CandidatePending,
Active,
FailedToRetail,
}
public readonly record struct RetailUiAutomationRenderPackStatus(
RetailUiAutomationRenderPackState State,
string PackId,
string PresetId,
long ActivationGeneration,
string? FailureReason)
{
public static RetailUiAutomationRenderPackStatus Retail { get; } = new(
RetailUiAutomationRenderPackState.Retail,
"retail",
"off",
ActivationGeneration: 0,
FailureReason: null);
}
/// <summary>
/// Narrow bridge from retained-UI scripts to render/world lifecycle
/// diagnostics. Implementations run on the same update/render thread as the
@ -33,6 +56,42 @@ public interface IRetailUiAutomationRuntime
bool IsWorldReady { get; }
bool IsWorldViewportVisible { get; }
int PortalMaterializationCount { get; }
int RenderPackPerformanceSampleCount => 0;
bool RenderPackFailedToRetail => false;
RetailUiAutomationRenderPackStatus RenderPackStatus =>
RetailUiAutomationRenderPackStatus.Retail;
int FramebufferWidth => 0;
int FramebufferHeight => 0;
bool TrySelectRenderPack(string presetId, out string error)
{
error = "render-pack selection automation is unavailable";
return false;
}
bool TryDisableRenderPack(out string error)
{
error = "render-pack selection automation is unavailable";
return false;
}
bool TryReenableRenderPack(out string error)
{
error = "render-pack selection automation is unavailable";
return false;
}
bool TryResizeFramebuffer(int width, int height, out string error)
{
error = "framebuffer resize automation is unavailable";
return false;
}
bool TryResetRenderPackPerformance(out string error)
{
error = "render-pack performance automation is unavailable";
return false;
}
bool TryRequestClientClose(out string error)
{
error = "client-close automation is unavailable";
return false;
}
bool TryRequestCheckpoint(
string name,
out IRetailUiAutomationCheckpoint? checkpoint,
@ -205,7 +264,10 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
"command" => DoCommand(command),
"input" => DoInput(command),
"checkpoint" => DoCheckpoint(command),
"renderpack" => DoRenderPack(command),
"resize" => DoResize(command),
"screenshot" => DoScreenshot(command),
"close-client" => DoCloseClient(command),
_ => Stop(command, $"unknown command '{p[0]}'"),
};
}
@ -333,7 +395,7 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
private bool DoWait(ScriptCommand command)
{
var p = command.Parts;
if (p.Length < 2) return Stop(command, "usage: wait item|element|ms|world-ready|world-visible|materialized ...");
if (p.Length < 2) return Stop(command, "usage: wait item|element|ms|world-ready|world-visible|materialized|render-pack|render-pack-samples|framebuffer ...");
string target = p[1].ToLowerInvariant();
if (target == "item")
@ -376,7 +438,168 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
return WaitOrTimeout(command, TimeoutMs(p, 3, 60000), $"portal materialization {occurrence}");
}
return Stop(command, "usage: wait item|element|ms|world-ready|world-visible|materialized ...");
if (target == "render-pack-samples")
{
if (_runtime is null)
return Stop(command, "render-pack performance automation is unavailable");
if (p.Length < 3
|| !TryParseInt(p[2], out int required)
|| required <= 0)
{
return Stop(
command,
"usage: wait render-pack-samples <count> [timeoutMs]");
}
if (_runtime.RenderPackPerformanceSampleCount >= required)
return true;
// An unavailable preset cannot ever fill an enhanced performance
// window. Finish this wait immediately so the following screenshot
// captures the controller's fully resource-free default fallback;
// the harness validates the exact failure class and zero-work
// metadata instead of turning every fallback into a five-minute
// timeout.
if (_runtime.RenderPackFailedToRetail)
return true;
return WaitOrTimeout(
command,
TimeoutMs(p, 3, 300000),
$"{required} render-pack performance samples");
}
if (target == "render-pack")
{
if (_runtime is null)
return Stop(command, "render-pack selection automation is unavailable");
if (p.Length < 3 || !TryNormalizeRenderPackPreset(p[2], out string preset))
{
return Stop(
command,
"usage: wait render-pack retail|low|medium|high|auto [timeoutMs]");
}
RetailUiAutomationRenderPackStatus status = _runtime.RenderPackStatus;
bool expectRetail = string.Equals(
preset,
"retail",
StringComparison.Ordinal);
if (expectRetail
&& status.State == RetailUiAutomationRenderPackState.Retail
&& string.Equals(status.PackId, "retail", StringComparison.OrdinalIgnoreCase)
&& string.Equals(status.PresetId, "off", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (!expectRetail
&& status.State == RetailUiAutomationRenderPackState.Active
&& string.Equals(
status.PackId,
"acdream.atmospheric",
StringComparison.OrdinalIgnoreCase)
&& string.Equals(status.PresetId, preset, StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (status.State == RetailUiAutomationRenderPackState.FailedToRetail)
{
return Stop(
command,
$"render pack '{preset}' failed to retail: "
+ (status.FailureReason ?? "no failure reason was published"));
}
return WaitOrTimeout(
command,
TimeoutMs(p, 3, 90000),
$"render pack '{preset}' activation");
}
if (target == "framebuffer")
{
if (_runtime is null)
return Stop(command, "framebuffer resize automation is unavailable");
if (p.Length < 4
|| !TryParseInt(p[2], out int width)
|| !TryParseInt(p[3], out int height)
|| width <= 0
|| height <= 0)
{
return Stop(
command,
"usage: wait framebuffer <width> <height> [timeoutMs]");
}
if (_runtime.FramebufferWidth == width
&& _runtime.FramebufferHeight == height)
{
return true;
}
return WaitOrTimeout(
command,
TimeoutMs(p, 4, 30000),
$"framebuffer {width}x{height}");
}
return Stop(command, "usage: wait item|element|ms|world-ready|world-visible|materialized|render-pack|render-pack-samples|framebuffer ...");
}
private bool DoRenderPack(ScriptCommand command)
{
if (_runtime is null)
return Stop(command, "render-pack automation is unavailable");
var p = command.Parts;
if (p.Length == 2
&& string.Equals(p[1], "reset-performance", StringComparison.OrdinalIgnoreCase))
{
return _runtime.TryResetRenderPackPerformance(out string error)
|| Stop(command, error);
}
if (p.Length == 3
&& string.Equals(p[1], "select", StringComparison.OrdinalIgnoreCase)
&& TryNormalizeRenderPackPreset(p[2], out string preset))
{
return _runtime.TrySelectRenderPack(preset, out string error)
|| Stop(command, error);
}
if (p.Length == 2
&& string.Equals(p[1], "disable", StringComparison.OrdinalIgnoreCase))
{
return _runtime.TryDisableRenderPack(out string error)
|| Stop(command, error);
}
if (p.Length == 2
&& string.Equals(p[1], "reenable", StringComparison.OrdinalIgnoreCase))
{
return _runtime.TryReenableRenderPack(out string error)
|| Stop(command, error);
}
return Stop(
command,
"usage: renderpack reset-performance | renderpack select retail|low|medium|high|auto | renderpack disable | renderpack reenable");
}
private bool DoResize(ScriptCommand command)
{
if (_runtime is null)
return Stop(command, "framebuffer resize automation is unavailable");
var p = command.Parts;
if (p.Length != 3
|| !TryParseInt(p[1], out int width)
|| !TryParseInt(p[2], out int height)
|| width <= 0
|| height <= 0)
{
return Stop(command, "usage: resize <width> <height>");
}
return _runtime.TryResizeFramebuffer(width, height, out string error)
|| Stop(command, error);
}
private static bool TryNormalizeRenderPackPreset(
string value,
out string preset)
{
preset = value.ToLowerInvariant();
if (preset == "off")
preset = "retail";
return preset is "retail" or "low" or "medium" or "high" or "auto";
}
private bool DoSleep(ScriptCommand command)
@ -540,6 +763,16 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
return WaitOrTimeout(command, TimeoutMs(command.Parts, 2, 10000), $"screenshot '{name}'");
}
private bool DoCloseClient(ScriptCommand command)
{
if (command.Parts.Length != 1)
return Stop(command, "usage: close-client");
if (_runtime is null)
return Stop(command, "client-close automation is unavailable");
return _runtime.TryRequestClientClose(out string error)
|| Stop(command, error);
}
private bool WaitOrTimeout(ScriptCommand command, int timeoutMs, string label)
{
if (!HasExceeded(timeoutMs)) return false;

View file

@ -39,6 +39,13 @@ public sealed class UiMenu : UiElement
/// </remarks>
public Action? OnOpen { get; set; }
/// <summary>
/// Optional interaction-boundary refresh invoked immediately before a
/// closed popup opens. Retained settings menus use it to consume an
/// event-driven catalog revision without polling during draw/update.
/// </summary>
public Action? BeforeOpen { get; set; }
/// <summary>Per-payload enabled gate (disabled rows render greyed + are inert). Null ⇒ all enabled.</summary>
public Func<object?, bool>? EnabledProvider { get; set; }
@ -55,9 +62,20 @@ public sealed class UiMenu : UiElement
/// surface for the row.</summary>
public string? TooltipText { get; set; }
/// <summary>Optional live tooltip source. Controllers use this when the
/// reason or cost behind a menu selection can change while the panel stays
/// open. A non-blank provider value takes precedence over
/// <see cref="TooltipText"/>.</summary>
public Func<string?>? TooltipTextProvider { get; set; }
/// <inheritdoc />
public override string? GetTooltipText() =>
string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText;
public override string? GetTooltipText()
{
string? live = TooltipTextProvider?.Invoke();
if (!string.IsNullOrWhiteSpace(live))
return live;
return string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText;
}
public int RowsPerColumn { get; set; } = 7; // items per column (dat item template);
// ALSO the visible-row window height when Scrollable
@ -271,7 +289,11 @@ public sealed class UiMenu : UiElement
if (_open == value) return;
// Before _open flips, so a handler that replaces Items is reflected in
// the very first measure/draw of this opening.
if (value) OnOpen?.Invoke();
if (value)
{
BeforeOpen?.Invoke();
OnOpen?.Invoke();
}
_open = value;
if (FindRoot() is not { } root) return;
if (value) root.SetActivePopup(this, () => SetOpen(false));

View file

@ -272,6 +272,29 @@ public sealed class UiTemplateListBox : UiDatElement
return row;
}
/// <summary>
/// Removes the dynamically-owned suffix after <paramref name="retainedItemCount"/>
/// without disturbing the retained prefix. This is the structural seam used by
/// optional Config-page extensions whose schema can change at runtime: retail's
/// authored prefix remains mounted, while only extension rows are detached.
/// Existing scroll is preserved and clamped by <see cref="UiScrollablePanel"/>.
/// </summary>
public void RemoveTail(int retainedItemCount)
{
int count = _viewport?.Children.Count ?? 0;
if (retainedItemCount < 0 || retainedItemCount > count)
throw new ArgumentOutOfRangeException(nameof(retainedItemCount));
if (_viewport is null || retainedItemCount == count)
return;
for (int i = count - 1; i >= retainedItemCount; i--)
_viewport.RemoveChild(_viewport.Children[i]);
}
/// <summary>Current number of materialized row widgets. Reading this does
/// not wake a dormant list box.</summary>
public int ItemCount => _viewport?.Children.Count ?? 0;
/// <summary>
/// Campaign FA slice FA3: removes every row previously added via
/// <see cref="AddItemFromTemplateList"/>/<see cref="AddPrebuiltRow"/>, resetting