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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue