394 lines
19 KiB
C#
394 lines
19 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,
|
|
/// <summary>Diagnostic-only request for an exact automation framebuffer.
|
|
/// The graphical host uses the persisted display resolution as the initial
|
|
/// size of a borderless window so the OS cannot clamp a decorated window to
|
|
/// the desktop work area. False for every ordinary launch.</summary>
|
|
bool ExactAutomationFramebuffer,
|
|
int? ForcedDayGroupIndex,
|
|
float? PinnedWorldDayFraction,
|
|
float? SkyAnimationPhaseSeconds,
|
|
/// <summary>Diagnostic initial distance for the offline orbit camera, in
|
|
/// metres. Null for every ordinary launch; used by deterministic renderer
|
|
/// acceptance captures that need receivers inside a finite shadow reach.</summary>
|
|
float? InitialOrbitDistanceMeters,
|
|
/// <summary>Diagnostic-only initial orbit heading in degrees. Null keeps
|
|
/// the normal camera default.</summary>
|
|
float? InitialOrbitYawDegrees,
|
|
/// <summary>Diagnostic-only initial orbit elevation in degrees. Null keeps
|
|
/// the normal camera default.</summary>
|
|
float? InitialOrbitPitchDegrees,
|
|
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")),
|
|
ExactAutomationFramebuffer:
|
|
IsExactlyOne(env("ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER")),
|
|
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")),
|
|
InitialOrbitDistanceMeters:
|
|
TryParsePositiveFiniteFloat(
|
|
env("ACDREAM_ORBIT_DISTANCE_METERS")),
|
|
InitialOrbitYawDegrees:
|
|
TryParseFiniteFloat(env("ACDREAM_ORBIT_YAW_DEGREES")),
|
|
InitialOrbitPitchDegrees:
|
|
TryParseOrbitPitchDegrees(
|
|
env("ACDREAM_ORBIT_PITCH_DEGREES")),
|
|
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;
|
|
|
|
private static float? TryParsePositiveFiniteFloat(string? s)
|
|
=> TryParseFloat(s) is { } value
|
|
&& float.IsFinite(value)
|
|
&& value > 0f
|
|
? value
|
|
: null;
|
|
|
|
private static float? TryParseFiniteFloat(string? s)
|
|
=> TryParseFloat(s) is { } value && float.IsFinite(value)
|
|
? value
|
|
: null;
|
|
|
|
private static float? TryParseOrbitPitchDegrees(string? s)
|
|
=> TryParseFiniteFloat(s) is { } value
|
|
&& value is >= -89f and <= 89f
|
|
? value
|
|
: null;
|
|
}
|