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; }