Mounts gmCharGenMainUI (enum 0x10000039, root 0x100003CC) via CharacterCreationUiController/CharacterCreationUiMountCoordinator, cloning CharacterManagementUiController's recipe. Master shell ports SetProgressState @0x004e7a10 (Olthoi tab-hide + redirect) and ListenToElementMessage @0x004e9450 (Back/Next/Finish/Help/Exit/Random nav) verbatim, with free tab navigation over all six pages. Heritage, Profession, Skills, and Town pages bind to CC3's RuntimeCharacterCreationState commands; Appearance and Summary mount as content-inert placeholders for CC6b/CC5. Live-DAT probing (CharacterCreationLiveDatTests) found two widget- mapping surprises the decomp's DynamicCast hints don't predict: the Profession slider's value field imports as an editable UiField (wired for direct numeric entry), and the avail/health/stamina/mana/credits displays author as UIElement_Button hosts whose Type-12 value child is swallowed by UiButton.ConsumesDatChildren — substituted with the button's own Label. No new DatWidgetFactory widget types were needed. Threads the installed DAT's real ChargenOptions into Runtime via the new RuntimeCharacterCreationState.InstallOptions, called from ContentEffectsAudioCompositionPhase.Compose (mirrors InstallSpellMetadata's pattern); headless keeps ChargenOptions.Empty unchanged. Wires CC3's F14 status-hook gap (ApplyCharacterCreated/ ApplyCreationFailed) to SessionStatusWriter for both graphical and headless hosts, and adds the CharacterCreation view/command seam through CurrentGameRuntimeAdapter and DeferredGameRuntimeStateCommands alongside CharacterSelection's existing shape. Register: AD-101/102/103, AP-212/213, TS-82 filed for the auto-gender- select interim default, the omitted ToD-account gate, the button-Label widget substitution, the Random-button approximation, the flat-listbox Skills simplification, and the Appearance/Summary placeholders. Runtime 1713/0 (was 1707), App 5117/13 skips (was 5101/6), Headless 165/0 unaffected, full solution Release build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
351 lines
17 KiB
C#
351 lines
17 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Reflection;
|
|
using System.Text;
|
|
using AcDream.App.Configuration;
|
|
using AcDream.App.Rendering.Residency;
|
|
using AcDream.App.Streaming;
|
|
using AcDream.Runtime.Session;
|
|
|
|
namespace AcDream.App;
|
|
|
|
/// <summary>
|
|
/// Typed bundle of startup-time configuration read from the process
|
|
/// environment. Built once in <c>Program.cs</c> and passed to
|
|
/// <c>GameWindow</c> so the rest of the app reads its config through
|
|
/// strongly-typed fields instead of scattered
|
|
/// <c>Environment.GetEnvironmentVariable</c> calls.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <strong>Scope:</strong> startup-time only — values that don't change
|
|
/// once the window is up. Runtime diagnostic toggles
|
|
/// (e.g. <c>ACDREAM_DUMP_MOTION</c>, <c>ACDREAM_PROBE_*</c>) belong in
|
|
/// diagnostic owner classes (see <c>AcDream.Core.Physics.PhysicsDiagnostics</c>
|
|
/// for the template), not here.
|
|
/// </para>
|
|
/// <para>
|
|
/// See <c>docs/architecture/code-structure.md</c> §2 Rule 4 for the
|
|
/// rule that drove this extraction, and §4 Step 1 for the broader
|
|
/// extraction sequence this is the first cut of.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed record RuntimeOptions(
|
|
string DatDir,
|
|
string PreparedAssetPath,
|
|
bool LiveMode,
|
|
string LiveHost,
|
|
int LivePort,
|
|
string? LiveUser,
|
|
string? LivePass,
|
|
bool DevTools,
|
|
bool UncappedRendering,
|
|
bool DumpMoveTruth,
|
|
bool DumpSky,
|
|
bool NoAudio,
|
|
bool EnableSkyPesDebug,
|
|
int HidePartIndex,
|
|
bool RetailCloseDegrades,
|
|
bool DumpSceneryZ,
|
|
bool DumpLiveSpawns,
|
|
bool DumpClothing,
|
|
int? LegacyStreamRadius,
|
|
bool RetailUi,
|
|
/// <summary>Campaign CC slice CC4: interim env/test-only seam that opens
|
|
/// the character-creation screen once Runtime's chargen view goes
|
|
/// active — the real transition is retail's Create Character button
|
|
/// (<c>0x100003A0</c>), which stays ghosted until CC7's closing move.
|
|
/// See <c>CharacterCreationRuntimeBindings.OpenOnStart</c>.</summary>
|
|
bool OpenCharacterCreationOnStart,
|
|
string? AcDir,
|
|
bool UiProbeDump,
|
|
string? UiProbeScript,
|
|
string? AutomationArtifactDirectory,
|
|
int? ForcedDayGroupIndex,
|
|
float? PinnedWorldDayFraction,
|
|
float? SkyAnimationPhaseSeconds,
|
|
float FogStartMultiplier,
|
|
float FogEndMultiplier,
|
|
ResidencyBudgetOptions ResidencyBudgets,
|
|
StreamingWorkBudgetOptions StreamingWorkBudgets,
|
|
string? VulkanDeviceOverride,
|
|
string? VulkanForcedUnsupportedFeature,
|
|
bool VulkanCapabilityProbe,
|
|
int VulkanCapabilityProbeFrames,
|
|
/// <summary>Campaign LA slice LA1: the raw <c>--session-config</c> path,
|
|
/// or <see langword="null"/> when the flag was not supplied (the env-var
|
|
/// dev flow). Kept for diagnostics/logging only.</summary>
|
|
string? SessionConfigPath,
|
|
/// <summary>Campaign LA slice LA1: the configured session's id, used as
|
|
/// the <c>sessionId</c> field on every status-stream event. Defaults to
|
|
/// <c>"app"</c> at every call site when unset (env-var flow).</summary>
|
|
string? SessionId,
|
|
/// <summary>Campaign LA slice LA1: the session-config character
|
|
/// selector, or <see langword="null"/> for today's existing
|
|
/// first-available fallback (absent selector = LA7's char-select screen
|
|
/// stop point once that slice lands; this slice does not build the
|
|
/// screen).</summary>
|
|
LiveSessionCharacterSelector? LiveCharacterSelector,
|
|
/// <summary>Campaign LA slice LA1: absolute path for the status-event
|
|
/// JSONL stream. <see langword="null"/> = no writer constructed.</summary>
|
|
string? StatusFilePath,
|
|
/// <summary>Campaign LA slice LA1: plugin ids to load.
|
|
/// <see langword="null"/> = load every discovered plugin (today's
|
|
/// behavior). Consumed by the shared graphical plugin session.</summary>
|
|
IReadOnlyList<string>? Plugins,
|
|
/// <summary>Campaign LA slice LA1: ordered chat-typed strings run once
|
|
/// entered-world through the shared Runtime parser/router.</summary>
|
|
IReadOnlyList<string> LoginCommands,
|
|
/// <summary>Campaign LA slice LA1: inter-command delay for
|
|
/// <see cref="LoginCommands"/>, milliseconds.</summary>
|
|
int LoginCommandDelayMs)
|
|
{
|
|
/// <summary>
|
|
/// Build options from the process environment. Used by
|
|
/// <c>Program.cs</c> at startup.
|
|
/// </summary>
|
|
public static RuntimeOptions FromEnvironment(string datDir)
|
|
=> Parse(datDir, Environment.GetEnvironmentVariable);
|
|
|
|
/// <summary>
|
|
/// Build options from a custom environment getter. Used by tests to
|
|
/// inject controlled env values without touching the process
|
|
/// environment.
|
|
/// </summary>
|
|
/// <param name="datDir">Resolved dat-file directory.</param>
|
|
/// <param name="env">Function returning the value for an env-var
|
|
/// name, or <c>null</c> when unset.</param>
|
|
public static RuntimeOptions Parse(string datDir, Func<string, string?> env)
|
|
{
|
|
if (datDir is null) throw new ArgumentNullException(nameof(datDir));
|
|
if (env is null) throw new ArgumentNullException(nameof(env));
|
|
|
|
return new RuntimeOptions(
|
|
DatDir: datDir,
|
|
PreparedAssetPath: NullIfEmpty(env("ACDREAM_PAK_PATH"))
|
|
?? Path.Combine(datDir, "acdream.pak"),
|
|
LiveMode: IsExactlyOne(env("ACDREAM_LIVE")),
|
|
LiveHost: env("ACDREAM_TEST_HOST") ?? "127.0.0.1",
|
|
LivePort: TryParseInt(env("ACDREAM_TEST_PORT")) ?? 9000,
|
|
LiveUser: NullIfEmpty(env("ACDREAM_TEST_USER")),
|
|
LivePass: NullIfEmpty(env("ACDREAM_TEST_PASS")),
|
|
DevTools: IsExactlyOne(env("ACDREAM_DEVTOOLS")),
|
|
// Normal presentation is always bounded by VSync or a
|
|
// refresh-rate software pacer. This explicit diagnostic is the
|
|
// sole way to measure truly uncapped renderer throughput.
|
|
UncappedRendering: IsExactlyOne(env("ACDREAM_UNCAPPED_RENDER")),
|
|
DumpMoveTruth: IsExactlyOne(env("ACDREAM_DUMP_MOVE_TRUTH")),
|
|
DumpSky: IsExactlyOne(env("ACDREAM_DUMP_SKY")),
|
|
NoAudio: IsExactlyOne(env("ACDREAM_NO_AUDIO")),
|
|
EnableSkyPesDebug: IsExactlyOne(env("ACDREAM_ENABLE_SKY_PES")),
|
|
HidePartIndex: TryParseInt(env("ACDREAM_HIDE_PART")) ?? -1,
|
|
// Default-on: any value other than the literal string "0" enables
|
|
// retail close-detail degrades. Set ACDREAM_RETAIL_CLOSE_DEGRADES=0
|
|
// only for before/after diagnostic comparisons.
|
|
RetailCloseDegrades: !string.Equals(env("ACDREAM_RETAIL_CLOSE_DEGRADES"), "0", StringComparison.Ordinal),
|
|
DumpSceneryZ: IsExactlyOne(env("ACDREAM_DUMP_SCENERY_Z")),
|
|
DumpLiveSpawns: IsExactlyOne(env("ACDREAM_DUMP_LIVE_SPAWNS")),
|
|
DumpClothing: IsExactlyOne(env("ACDREAM_DUMP_CLOTHING")),
|
|
// Legacy override for ACDREAM_STREAM_RADIUS. Caller applies it on
|
|
// top of the quality preset's radii. Null when unset or invalid.
|
|
LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")),
|
|
RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")),
|
|
OpenCharacterCreationOnStart:
|
|
IsExactlyOne(env("ACDREAM_OPEN_CHARGEN")),
|
|
AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")),
|
|
UiProbeDump: IsExactlyOne(env("ACDREAM_UI_PROBE_DUMP")),
|
|
UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT")),
|
|
AutomationArtifactDirectory:
|
|
NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR")),
|
|
ForcedDayGroupIndex:
|
|
TryParseNonNegativeInt(env("ACDREAM_DAY_GROUP")),
|
|
// Campaign V slice V7 instrument determinism: pins the Dereth day
|
|
// fraction, and therefore the sun direction, the sky keyframe and
|
|
// every lit surface. Distinct from the /time slash command, which is
|
|
// deliberately transient (the next TimeSync clears it) and so cannot
|
|
// hold a connected route still. Accepted only inside [0, 1);
|
|
// anything else -- unset, unparseable, negative, >= 1 -- leaves the
|
|
// server clock alone, which is every ordinary run.
|
|
PinnedWorldDayFraction:
|
|
TryParseDayFraction(env("ACDREAM_WORLD_TIME")),
|
|
// Campaign V slice V7 instrument determinism: pins the sky's UV
|
|
// scroll phase — the cloud sheet — to a fixed elapsed-seconds value
|
|
// instead of the wall clock, so two launches of the differential
|
|
// gate agree about where the clouds are. ACDREAM_DAY_GROUP and the
|
|
// AcdreamCycleTimeOfDay override pin the OTHER sky clock (day group,
|
|
// keyframe, sun angle); this one is independent of both by design,
|
|
// because retail's clouds drift with real time rather than with the
|
|
// date. Unset — the default and every ordinary run — keeps the wall
|
|
// clock. Negative values are accepted: the offset is taken modulo 1
|
|
// per axis, so any finite number is a valid phase.
|
|
SkyAnimationPhaseSeconds:
|
|
TryParseFloat(env("ACDREAM_SKY_PHASE_SECONDS")),
|
|
FogStartMultiplier: TryParseFloat(env("ACDREAM_FOG_START_MULT")) ?? 0.7f,
|
|
FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f,
|
|
ResidencyBudgets: ResidencyBudgetOptions.Parse(env),
|
|
StreamingWorkBudgets: StreamingWorkBudgetOptions.Parse(env),
|
|
// Physical-device override, matched as a decimal index first and then
|
|
// as a case-insensitive device-name substring. Recorded verbatim in
|
|
// graphical-capabilities-vulkan.json whether or not it matched.
|
|
VulkanDeviceOverride:
|
|
NullIfEmpty(env("ACDREAM_VULKAN_DEVICE")),
|
|
// Slice V5 gate knob: names one required capability to report as
|
|
// absent so the NotSupportedException -> exit-code-4 -> report path
|
|
// can be exercised on hardware that actually supports everything.
|
|
VulkanForcedUnsupportedFeature:
|
|
NullIfEmpty(env("ACDREAM_VULKAN_FORCE_UNSUPPORTED")),
|
|
// Campaign V slice V6h: with ACDREAM_RENDER_BACKEND=vulkan, run the
|
|
// V5/V6c bring-up harness — capability gate plus the synthetic
|
|
// verification scenes — instead of the real composition host. A
|
|
// diagnostic for "does this machine pass the Vulkan gate, and does
|
|
// the backend draw?"; ignored on OpenGL.
|
|
VulkanCapabilityProbe:
|
|
IsExactlyOne(env("ACDREAM_VULKAN_PROBE")),
|
|
// Campaign V slice V9: bound the probe harness to a frame budget so
|
|
// it can run unattended. The harness otherwise presents until its
|
|
// window closes, which is right for a developer answering "does this
|
|
// machine pass?" at a desk and impossible for CI, where nothing ever
|
|
// closes the window. Zero -- unset, unparseable, or an explicit 0 --
|
|
// keeps the interactive behaviour, so no existing invocation changes.
|
|
VulkanCapabilityProbeFrames:
|
|
TryParseNonNegativeInt(env("ACDREAM_VULKAN_PROBE_FRAMES")) ?? 0,
|
|
// Campaign LA slice LA1: the env-var dev flow never carries a
|
|
// session-config document — every new field below stays at its
|
|
// "nothing configured" default. RuntimeOptions.FromSessionConfig
|
|
// overlays the real values on top of this base.
|
|
SessionConfigPath: null,
|
|
SessionId: null,
|
|
LiveCharacterSelector: null,
|
|
StatusFilePath: null,
|
|
Plugins: null,
|
|
LoginCommands: [],
|
|
LoginCommandDelayMs: 500);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign LA slice LA1: builds options for the <c>--session-config</c>
|
|
/// launch path. Starts from the same env-var parse as
|
|
/// <see cref="FromEnvironment"/> (diagnostic/dev flags are still
|
|
/// env-controlled — only the LIVE session settings and the five new LA1
|
|
/// fields come from the document) and overlays the resolved session.
|
|
/// <paramref name="resolvedPassword"/> is revealed into
|
|
/// <see cref="LivePass"/> exactly as wide as the existing env-var flow —
|
|
/// see that field's own doc.
|
|
/// </summary>
|
|
internal static RuntimeOptions FromSessionConfig(
|
|
string datDir,
|
|
Func<string, string?> env,
|
|
string sessionConfigPath,
|
|
SessionConfiguration config,
|
|
SessionDescriptor session,
|
|
string? resolvedPassword)
|
|
{
|
|
if (config is null) throw new ArgumentNullException(nameof(config));
|
|
if (session is null) throw new ArgumentNullException(nameof(session));
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(sessionConfigPath);
|
|
|
|
RuntimeOptions baseOptions = Parse(datDir, env);
|
|
SessionContentDescriptor? content = config.Process?.Content;
|
|
return baseOptions with
|
|
{
|
|
PreparedAssetPath = NullIfEmpty(content?.PreparedAssetPath)
|
|
?? baseOptions.PreparedAssetPath,
|
|
LiveMode = true,
|
|
// Campaign LA gate round 2: a session-config launch IS a product
|
|
// launch — the retail UI is the shipped UI, not a dev option.
|
|
// ACDREAM_RETAIL_UI remains the opt-in for env-var dev launches,
|
|
// but the launcher strips ACDREAM_* from children (LA11 isolation),
|
|
// so inheriting the env default here shipped a client with world
|
|
// rendering and NO interface at all — the guiSelect flow's
|
|
// character screen included.
|
|
RetailUi = true,
|
|
LiveHost = session.Endpoint.Host,
|
|
LivePort = session.Endpoint.Port,
|
|
LiveUser = session.Account,
|
|
LivePass = resolvedPassword,
|
|
SessionConfigPath = sessionConfigPath,
|
|
SessionId = session.Id,
|
|
LiveCharacterSelector = MapCharacterSelector(session.Character),
|
|
StatusFilePath = NullIfEmpty(session.StatusFile),
|
|
Plugins = session.Plugins,
|
|
LoginCommands = (IReadOnlyList<string>?)session.LoginCommands ?? [],
|
|
LoginCommandDelayMs = session.LoginCommandDelayMs,
|
|
};
|
|
}
|
|
|
|
private static LiveSessionCharacterSelector? MapCharacterSelector(
|
|
SessionCharacterSelectorDescriptor? selector) =>
|
|
selector is null
|
|
? null
|
|
: new LiveSessionCharacterSelector(
|
|
selector.Index,
|
|
selector.Id,
|
|
selector.Name);
|
|
|
|
private static readonly PropertyInfo[] PrintableProperties =
|
|
typeof(RuntimeOptions)
|
|
.GetProperties(
|
|
BindingFlags.Instance
|
|
| BindingFlags.Public
|
|
| BindingFlags.DeclaredOnly)
|
|
.Where(static property =>
|
|
property.GetMethod is not null
|
|
&& property.GetIndexParameters().Length == 0)
|
|
.OrderBy(static property => property.MetadataToken)
|
|
.ToArray();
|
|
|
|
/// <summary>
|
|
/// Campaign LA LA1 defense in depth: positional records normally print
|
|
/// every public property, including the live password. Preserve that
|
|
/// ordinary diagnostic property set while substituting the one sensitive
|
|
/// value before it can reach a log, debugger display, or exception.
|
|
/// Reflection is cached once and runs only on the diagnostic
|
|
/// <see cref="object.ToString"/> path.
|
|
/// </summary>
|
|
private bool PrintMembers(StringBuilder builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
for (int index = 0; index < PrintableProperties.Length; index++)
|
|
{
|
|
PropertyInfo property = PrintableProperties[index];
|
|
if (index != 0)
|
|
builder.Append(", ");
|
|
builder.Append(property.Name);
|
|
builder.Append(" = ");
|
|
builder.Append(
|
|
property.Name == nameof(LivePass) && LivePass is not null
|
|
? "<redacted>"
|
|
: property.GetValue(this));
|
|
}
|
|
return PrintableProperties.Length != 0;
|
|
}
|
|
|
|
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
|
|
public bool HasLiveCredentials =>
|
|
LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass);
|
|
|
|
/// <summary>True when the opt-in retail UI automation probe should be constructed.</summary>
|
|
public bool UiProbeEnabled => UiProbeDump || !string.IsNullOrEmpty(UiProbeScript);
|
|
|
|
private static bool IsExactlyOne(string? s)
|
|
=> string.Equals(s, "1", StringComparison.Ordinal);
|
|
|
|
private static string? NullIfEmpty(string? s)
|
|
=> string.IsNullOrEmpty(s) ? null : s;
|
|
|
|
private static int? TryParseInt(string? s)
|
|
=> int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : null;
|
|
|
|
private static int? TryParseNonNegativeInt(string? s)
|
|
=> TryParseInt(s) is { } v && v >= 0 ? v : null;
|
|
|
|
private static float? TryParseDayFraction(string? s)
|
|
=> TryParseFloat(s) is { } value && value >= 0f && value < 1f ? value : null;
|
|
|
|
private static float? TryParseFloat(string? s)
|
|
=> float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out float value)
|
|
? value
|
|
: null;
|
|
}
|