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;
///
/// Typed bundle of startup-time configuration read from the process
/// environment. Built once in Program.cs and passed to
/// GameWindow so the rest of the app reads its config through
/// strongly-typed fields instead of scattered
/// Environment.GetEnvironmentVariable calls.
///
///
///
/// Scope: startup-time only — values that don't change
/// once the window is up. Runtime diagnostic toggles
/// (e.g. ACDREAM_DUMP_MOTION, ACDREAM_PROBE_*) belong in
/// diagnostic owner classes (see AcDream.Core.Physics.PhysicsDiagnostics
/// for the template), not here.
///
///
/// See docs/architecture/code-structure.md §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.
///
///
public sealed record RuntimeOptions(
string DatDir,
string PreparedAssetPath,
bool LiveMode,
string LiveHost,
int LivePort,
string? LiveUser,
string? LivePass,
bool DevTools,
bool UncappedRendering,
/// #435 kept deliberately: NOT a spent probe. The canonical
/// nine-stop soak (`tools/run-connected-r6-soak.ps1`) hard-gates on the
/// `move-truth OUT` records this emits — it is the automated proof that
/// production input produced outbound movement traffic.
bool DumpMoveTruth,
bool DumpSky,
bool NoAudio,
int HidePartIndex,
bool RetailCloseDegrades,
bool DumpSceneryZ,
int? LegacyStreamRadius,
bool RetailUi,
/// 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
/// (0x100003A0), which stays ghosted until CC7's closing move.
/// See CharacterCreationRuntimeBindings.OpenOnStart.
bool OpenCharacterCreationOnStart,
string? AcDir,
bool UiProbeDump,
string? UiProbeScript,
string? AutomationArtifactDirectory,
/// 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.
bool ExactAutomationFramebuffer,
int? ForcedDayGroupIndex,
float? PinnedWorldDayFraction,
float? SkyAnimationPhaseSeconds,
/// 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.
float? InitialOrbitDistanceMeters,
/// Diagnostic-only initial orbit heading in degrees. Null keeps
/// the normal camera default.
float? InitialOrbitYawDegrees,
/// Diagnostic-only initial orbit elevation in degrees. Null keeps
/// the normal camera default.
float? InitialOrbitPitchDegrees,
ResidencyBudgetOptions ResidencyBudgets,
StreamingWorkBudgetOptions StreamingWorkBudgets,
string? VulkanDeviceOverride,
string? VulkanForcedUnsupportedFeature,
bool VulkanCapabilityProbe,
int VulkanCapabilityProbeFrames,
/// Campaign LA slice LA1: the raw --session-config path,
/// or when the flag was not supplied (the env-var
/// dev flow). Kept for diagnostics/logging only.
string? SessionConfigPath,
/// Campaign LA slice LA1: the configured session's id, used as
/// the sessionId field on every status-stream event. Defaults to
/// "app" at every call site when unset (env-var flow).
string? SessionId,
/// Campaign LA slice LA1: the session-config character
/// selector, or 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).
LiveSessionCharacterSelector? LiveCharacterSelector,
/// Campaign LA slice LA1: absolute path for the status-event
/// JSONL stream. = no writer constructed.
string? StatusFilePath,
/// Campaign LA slice LA1: plugin ids to load.
/// = load every discovered plugin (today's
/// behavior). Consumed by the shared graphical plugin session.
IReadOnlyList? Plugins,
/// Campaign LA slice LA1: ordered chat-typed strings run once
/// entered-world through the shared Runtime parser/router.
IReadOnlyList LoginCommands,
/// Campaign LA slice LA1: inter-command delay for
/// , milliseconds.
int LoginCommandDelayMs)
{
public string? PreparedAssetOverlayPath { get; init; }
public uint? PreparedAssetBaseRecipeVersion { get; init; }
public uint? PreparedAssetEffectiveRecipeVersion { get; init; }
/// Optional machine-local peer tags advertised to other plugin
/// instances. Parsed once here so the live automation surface never reads
/// process configuration directly.
public IReadOnlyList PluginTags { get; init; } = [];
///
/// Build options from the process environment. Used by
/// Program.cs at startup.
///
public static RuntimeOptions FromEnvironment(string datDir)
=> Parse(datDir, Environment.GetEnvironmentVariable);
///
/// Build options from a custom environment getter. Used by tests to
/// inject controlled env values without touching the process
/// environment.
///
/// Resolved dat-file directory.
/// Function returning the value for an env-var
/// name, or null when unset.
public static RuntimeOptions Parse(string datDir, Func 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")),
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")),
// 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")),
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: 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?". (The former
// ACDREAM_RENDER_BACKEND=vulkan co-requisite died with the OpenGL
// backend at Campaign V; this flag alone gates the harness.)
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)
{
PluginTags = ParsePluginTags(env("ACDREAM_PLUGIN_TAGS")),
};
}
///
/// Campaign LA slice LA1: builds options for the --session-config
/// launch path. Starts from the same env-var parse as
/// (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.
/// is revealed into
/// exactly as wide as the existing env-var flow —
/// see that field's own doc.
///
internal static RuntimeOptions FromSessionConfig(
string datDir,
Func 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,
PreparedAssetOverlayPath =
NullIfEmpty(content?.PreparedAssetOverlayPath),
PreparedAssetBaseRecipeVersion =
content?.PreparedAssetBaseRecipeVersion,
PreparedAssetEffectiveRecipeVersion =
content?.PreparedAssetEffectiveRecipeVersion,
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?)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();
///
/// 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
/// path.
///
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
? ""
: property.GetValue(this));
}
return PrintableProperties.Length != 0;
}
/// True iff live-mode credentials are present and valid for connecting.
public bool HasLiveCredentials =>
LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass);
/// True when the opt-in retail UI automation probe should be constructed.
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 IReadOnlyList ParsePluginTags(string? value) =>
(value ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries
| StringSplitOptions.TrimEntries)
.Where(static tag => tag.Length <= 128)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(128)
.ToArray();
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;
}