diff --git a/StarterNG.sln.DotSettings.user b/StarterNG.sln.DotSettings.user index 680ff6c..e68f177 100644 --- a/StarterNG.sln.DotSettings.user +++ b/StarterNG.sln.DotSettings.user @@ -8,6 +8,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded diff --git a/StarterNG/App.axaml.cs b/StarterNG/App.axaml.cs index 5485232..53bdb39 100644 --- a/StarterNG/App.axaml.cs +++ b/StarterNG/App.axaml.cs @@ -22,13 +22,20 @@ public partial class App : Application { AvaloniaXamlLoader.Load(this); + // Load the persisted configuration up front so the saved language wins; + // fall back to the system culture only when the file has no lang entry. + Settings.Instance.Load(); + CultureInfo ci = CultureInfo.InstalledUICulture ; - - var langName = ci.Name switch - { - "pl-PL" => "Polski", - _ => "English" - }; + + var langName = Settings.Instance.LanguageWasSet + ? Settings.Instance.Language + : ci.Name switch + { + "pl-PL" => "Polski", + _ => "English" + }; + Settings.Instance.Language = langName; var langDict = new ResourceInclude(new Uri("avares://StarterNG/")) { @@ -61,6 +68,9 @@ public partial class App : Application // Code page 1250 is needed to parse scenery files (done during load). Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + // Persist settings when the launcher is closing. + desktop.ShutdownRequested += (_, _) => Settings.Instance.CaptureAndSave(); + var splash = new SplashWindow(); desktop.MainWindow = splash; splash.Show(); diff --git a/StarterNG/Assets/Langs/English.axaml b/StarterNG/Assets/Langs/English.axaml index e3ce747..3fe5713 100644 --- a/StarterNG/Assets/Langs/English.axaml +++ b/StarterNG/Assets/Langs/English.axaml @@ -171,5 +171,10 @@ Automatically close starter Large thumbnails Automatically expand scenery tree - + + + Save + Reset + Settings saved + diff --git a/StarterNG/Assets/Langs/Polski.axaml b/StarterNG/Assets/Langs/Polski.axaml index 322de8e..28545e2 100644 --- a/StarterNG/Assets/Langs/Polski.axaml +++ b/StarterNG/Assets/Langs/Polski.axaml @@ -171,8 +171,10 @@ Zamknij starter automatycznie Duże miniaturki Automatycznie rozwijanie drzewka scenerii - - - - + + + Zapisz + Przywróć + Zapisano ustawienia + diff --git a/StarterNG/Classes/ConfigFile.cs b/StarterNG/Classes/ConfigFile.cs new file mode 100644 index 0000000..95e645f --- /dev/null +++ b/StarterNG/Classes/ConfigFile.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace StarterNG.Classes; + +/// +/// A line-oriented reader/writer for the EU07 eu07.ini configuration +/// file. +/// +/// The file is not a classic INI: it is a flat list of key value [value…] +/// entries, one per line, with // comments (either trailing a line or +/// commenting the whole line out). The simulator parses it as a stream of +/// whitespace-separated tokens. +/// +/// This class keeps every line verbatim and only rewrites the value of a key +/// when is called. Comments, blank lines, commented-out +/// entries and — crucially — any keys the launcher does not understand are +/// preserved on save, so editing a handful of settings never drops the rest of +/// the user's configuration. +/// +public sealed class ConfigFile +{ + private readonly List _lines = new(); + + // Active (uncommented) setting key -> index into _lines. Last occurrence wins, + // matching the simulator which lets later entries override earlier ones. + private readonly Dictionary _index = + new(StringComparer.OrdinalIgnoreCase); + + /// Parses raw file text, preserving line breaks and content. + public static ConfigFile Parse(string text) + { + var cfg = new ConfigFile(); + // Normalise CRLF/CR to LF for splitting; output uses Environment.NewLine. + text = text.Replace("\r\n", "\n").Replace('\r', '\n'); + foreach (var line in text.Split('\n')) + cfg.AddLine(line); + return cfg; + } + + private void AddLine(string raw) + { + int idx = _lines.Count; + _lines.Add(raw); + if (TryParseKey(raw, out string key)) + _index[key] = idx; // later duplicates override earlier ones + } + + /// True if the key is present as an active (uncommented) entry. + public bool Has(string key) => _index.ContainsKey(key); + + /// Raw value (all tokens after the key, comment stripped), or null. + public string? GetRaw(string key) + { + if (!_index.TryGetValue(key, out int i)) + return null; + SplitLine(_lines[i], out _, out _, out string value, out _); + return value; + } + + public string GetString(string key, string fallback) => + GetRaw(key) is { Length: > 0 } v ? v : fallback; + + public bool GetBool(string key, bool fallback) + { + var v = FirstToken(GetRaw(key)); + if (v is null) return fallback; + return v.ToLowerInvariant() switch + { + "yes" or "true" or "1" or "tak" => true, + "no" or "false" or "0" or "nie" => false, + _ => fallback + }; + } + + public int GetInt(string key, int fallback) + { + var v = FirstToken(GetRaw(key)); + return v != null && int.TryParse(v, NumberStyles.Integer, + CultureInfo.InvariantCulture, out int n) ? n : fallback; + } + + public double GetDouble(string key, double fallback) + { + var v = FirstToken(GetRaw(key)); + return v != null && double.TryParse(v, NumberStyles.Float, + CultureInfo.InvariantCulture, out double d) ? d : fallback; + } + + /// All whitespace-separated value tokens for a key (never null). + public string[] GetTokens(string key) + { + var v = GetRaw(key); + return string.IsNullOrEmpty(v) + ? Array.Empty() + : v.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + } + + /// + /// Sets the value of . If the key already exists its + /// line is rewritten in place, preserving original indentation and any + /// trailing comment. Otherwise a new line is appended. + /// + public void Set(string key, string value) + { + value ??= string.Empty; + if (_index.TryGetValue(key, out int i)) + { + SplitLine(_lines[i], out string indent, out _, out _, out string comment); + var sb = new StringBuilder(); + sb.Append(indent).Append(key); + if (value.Length > 0) + sb.Append(' ').Append(value); + if (comment.Length > 0) + sb.Append('\t').Append(comment); + _lines[i] = sb.ToString(); + } + else + { + int idx = _lines.Count; + _lines.Add(value.Length > 0 ? $"{key} {value}" : key); + _index[key] = idx; + } + } + + public void SetBool(string key, bool value) => Set(key, value ? "yes" : "no"); + public void SetInt(string key, int value) => + Set(key, value.ToString(CultureInfo.InvariantCulture)); + public void SetDouble(string key, double value) => + Set(key, value.ToString("0.###", CultureInfo.InvariantCulture)); + + /// Serialises back to text using the platform newline. + public string ToText() => string.Join(Environment.NewLine, _lines); + + // --- line parsing helpers ------------------------------------------------- + + private static bool TryParseKey(string raw, out string key) + { + SplitLine(raw, out _, out key, out _, out _); + return key.Length > 0; + } + + /// + /// Decomposes a raw line into leading whitespace, key, value (tokens after + /// the key) and trailing comment (including the //). For blank or + /// fully commented-out lines, key is empty. + /// + private static void SplitLine(string raw, out string indent, out string key, + out string value, out string comment) + { + indent = key = value = comment = string.Empty; + + // leading whitespace + int start = 0; + while (start < raw.Length && char.IsWhiteSpace(raw[start])) + start++; + indent = raw.Substring(0, start); + + string body = raw.Substring(start); + + // A line whose first non-space content is "//" is a pure comment. + if (body.StartsWith("//")) + { + comment = body; + return; + } + if (body.Length == 0) + return; + + // Split off a trailing comment: the first "//" preceded by whitespace + // (so URLs such as tcp://… inside a value are not mistaken for one). + int commentAt = FindCommentStart(body); + string content = commentAt >= 0 ? body.Substring(0, commentAt) : body; + if (commentAt >= 0) + comment = body.Substring(commentAt).TrimEnd(); + + content = content.TrimEnd(); + if (content.Length == 0) + return; + + int sp = IndexOfWhitespace(content); + if (sp < 0) + { + key = content; + } + else + { + key = content.Substring(0, sp); + value = content.Substring(sp).Trim(); + } + } + + private static int FindCommentStart(string body) + { + for (int i = 0; i + 1 < body.Length; i++) + { + if (body[i] == '/' && body[i + 1] == '/' && + (i == 0 || char.IsWhiteSpace(body[i - 1]))) + return i; + } + return -1; + } + + private static int IndexOfWhitespace(string s) + { + for (int i = 0; i < s.Length; i++) + if (char.IsWhiteSpace(s[i])) + return i; + return -1; + } + + private static string? FirstToken(string? value) + { + if (string.IsNullOrEmpty(value)) + return null; + int sp = IndexOfWhitespace(value); + return sp < 0 ? value : value.Substring(0, sp); + } +} diff --git a/StarterNG/Classes/Settings.cs b/StarterNG/Classes/Settings.cs index 38cb906..b2c79be 100644 --- a/StarterNG/Classes/Settings.cs +++ b/StarterNG/Classes/Settings.cs @@ -1,51 +1,415 @@ -namespace StarterNG.Classes; +using System; +using System.Globalization; +using System.IO; +using System.Text; + +namespace StarterNG.Classes; /// -/// Application settings backing the Settings view. Load/Save are stubs for now; -/// the real persistence will be implemented later. +/// Application settings backing the Settings view, persisted to the simulator's +/// eu07.ini. +/// +/// Where it loads/saves. The active file lives next to the +/// simulator's own config: %APPDATA%\MaSzyna\eu07.ini on Windows or +/// ~/.config/MaSzyna/eu07.ini elsewhere. If that file does not exist yet, +/// defaults are read from an eu07.ini in the launcher's working directory +/// (the same fallback the simulator uses). Saving always writes to the per-user +/// path. +/// +/// Unknown keys are kept. The whole file is round-tripped through +/// ; only the keys the UI actually edits are rewritten, +/// so any extra settings the launcher does not model survive a save. +/// +/// Some mappings are best-effort where the wiki does not document an exact +/// key; those are marked with NOTE and can be adjusted against the running exe. /// public sealed class Settings { public static Settings Instance { get; } = new(); - // General - public string Language = "English"; - public bool Fullscreen; - public bool PauseWhenInactive; - public bool PauseOnStart; - public int CursorSensitivity = 3; - public bool InvertMouseHorizontal; - public bool InvertMouseVertical; + /// + /// Pulls the current values out of the Settings view into this instance. + /// The view assigns this when it is constructed; null if the view was never + /// opened (in which case Save just re-writes the loaded values). + /// + public Action? CaptureFromUi { get; set; } - // Other - public string ExecutablePath = "eu07.exe"; // game executable used to launch - public bool SelectExeAutomatically = true; - public bool DebugMode; - public bool VirtualShunting; + /// True if the loaded config explicitly contained a lang entry. + public bool LanguageWasSet { get; private set; } - // Graphics (subset) - public string Resolution = "1280x720"; - public bool VSync = true; - public bool RenderShadows = true; + // ── General ─────────────────────────────────────────────────────────── + public string Language = "English"; // lang (pl/en) + public bool Fullscreen; // fullscreen + public bool PauseWhenInactive = true; // inactivepause + public bool PauseOnStart; // pause + public int CursorSensitivity = 3; // |mousescale x| + public bool InvertMouseHorizontal; // mousescale x < 0 + public bool InvertMouseVertical; // mousescale y < 0 - // Sound - public bool SoundEnabled = true; - public int Volume = 100; + // ── Communication ───────────────────────────────────────────────────── + public bool IgnoreGamepad; // input.gamepad (inverted) + public int FeedbackMode; // feedbackmode (0-5) - // Starter - public bool AutoCloseStarter; - public bool LargeThumbnails; - public bool AutoExpandSceneryTree; + // ── Other ───────────────────────────────────────────────────────────── + public bool SelectExeAutomatically = true; // starter.exe.auto (launcher-only) + public string ExecutablePath = "eu07.exe"; // starter.exe.path (launcher-only) + public bool DebugMode; // debugmode + public bool VirtualShunting; // ai.trainman - /// Loads settings from disk. TODO: implement persistence. + // ── Graphics ────────────────────────────────────────────────────────── + public int RenderEngine; // gfxrenderer (index, see RenderEngines) + public int Width = 1280; // width + public int Height = 720; // height + public int BufferScalePercent = 100; // starter.bufferscale (NOTE: launcher-only) + public int MaxTextureSize = 4096; // maxtexturesize + public int MaxCabTextureSize = 4096; // maxcabtexturesize + public int TextureFiltering = 8; // anisotropicfiltering (1/2/4/8/16) + public int Multisampling = 2; // multisampling (0-3) + public double DrawRangeFactor = 1.0; // gfx.drawrange.factor.max + public bool VSync = true; // vsync + public bool Smoke = true; // gfx.smoke + public int SmokeFidelity = 1; // gfx.smoke.fidelity (1-4) + public int Tonemapping; // gfx.postfx.tonemapping (NOTE: 0=reinhard,1=aces) + public bool ChromaticAberration; // gfx.postfx.chromaticaberration.enabled + public bool MotionBlur = true; // gfx.postfx.motionblur.enabled + public bool ExtraEffects = true; // gfx.extraeffects + public bool EnvMap = true; // gfx.envmap.enabled + public bool UseVbo = true; // usevbo + public bool RenderShadows = true; // shadows + gfx.shadowmap.enabled + public int ReflectionsFramerate = 60; // gfx.reflections.framerate + public int ShadowMapResolution = 4096; // shadowtune[0] + public int ShadowProjectionRange = 250; // shadowtune[1] + public int CabShadowsRange = 30; // gfx.shadows.cab.range + public int ShadowRankCutoff = 3; // gfx.shadow.rank.cutoff (NOTE) + public int ReflectionsFidelity = 2; // gfx.reflections.fidelity (0-2) + public int FieldOfView = 45; // fieldofview (15-75) NOTE: 'fovSlider' in XAML + public bool PythonScreens = true; // python.enabled + public bool PythonThreadedUpload = true; // python.threadedupload + public int ScreenRendererPriority = 4; // pyscreenrendererpriority (1-5, see ScreenPriorities) + + // ── Physics ─────────────────────────────────────────────────────────── + public int SplineFidelity = 1; // splinefidelity (1-4) + public bool FullPhysics = true; // fullphysics + public bool EnableTraction = true; // enabletraction (pantograph can break) + public bool LiveTraction; // livetraction + public bool PhysicsLog; // physicslog (speedometer tapes) + public bool DebugLog = true; // debuglog (3 / 0) + public bool MultipleLogs; // multiplelogs + public bool DisplaySimulation = true; // gfx.skiprendering (inverted) + public bool CrashDamage = true; // crashdamage + + // ── Sound ───────────────────────────────────────────────────────────── + public bool SoundEnabled = true; // soundenabled + public int Volume = 100; // sound.volume (0-2 → 0-100) + public int RadioVolume = 80; // sound.volume.radio (0-1 → 0-100) + public int VehiclesVolume = 100; // sound.volume.vehicle + public int PositionalVolume = 100; // sound.volume.positional + public int AmbientVolume = 80; // sound.volume.ambient + public int PausedVolume; // sound.volume.paused + + // ── Starter (launcher-only, stored under starter.* keys) ─────────────── + public bool AutoCloseStarter; // starter.autoclose + public bool LargeThumbnails; // starter.largethumbnails + public bool AutoExpandSceneryTree; // starter.expandtree + + /// gfxrenderer tokens in the order shown by the render-engine combo. + public static readonly string[] RenderEngines = + { "full", "legacy", "simpleshader", "simple", "off", "experimental" }; + + /// Anisotropic-filtering steps for the texture-quality slider (1-5). + public static readonly int[] AnisotropySteps = { 1, 2, 4, 8, 16 }; + + /// pyscreenrendererpriority tokens indexed by the slider value (1-5). + public static readonly string[] ScreenPriorities = + { "off", "idle", "lowest", "lower", "normal" }; + + // Tokens of mousescale / shadowtune that the UI does not edit but must keep. + private double _mouseScaleYMagnitude = 0.5; + private string[] _shadowTuneExtra = { "400", "300" }; // tokens [2], [3] + + private ConfigFile _config = new(); + private string _savePath = string.Empty; + + /// Resolves the per-user eu07.ini path for the current OS. + public static string UserConfigPath() + { + if (OperatingSystem.IsWindows()) + { + string? appData = Environment.GetEnvironmentVariable("APPDATA"); + if (!string.IsNullOrEmpty(appData)) + return Path.Combine(appData, "MaSzyna", "eu07.ini"); + } + else + { + string? home = Environment.GetEnvironmentVariable("HOME"); + if (!string.IsNullOrEmpty(home)) + return Path.Combine(home, ".config", "MaSzyna", "eu07.ini"); + } + // Last resort: alongside the launcher. + return Path.Combine(AppContext.BaseDirectory, "eu07.ini"); + } + + /// Default config shipped in the launcher's working directory. + private static string DefaultConfigPath() => + Path.Combine(Directory.GetCurrentDirectory(), "eu07.ini"); + + private static readonly Encoding FileEncoding = ResolveEncoding(); + + private static Encoding ResolveEncoding() + { + // eu07.ini comments use Polish diacritics in Windows-1250; preserve them. + try + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + return Encoding.GetEncoding(1250); + } + catch + { + return Encoding.UTF8; + } + } + + /// Loads settings from the per-user file, or the working-dir default. public void Load() { - // intentionally empty for now + _savePath = UserConfigPath(); + string source = File.Exists(_savePath) ? _savePath : DefaultConfigPath(); + + try + { + _config = File.Exists(source) + ? ConfigFile.Parse(File.ReadAllText(source, FileEncoding)) + : new ConfigFile(); + } + catch + { + _config = new ConfigFile(); + } + + ReadFromConfig(); } - /// Persists settings to disk. TODO: implement persistence. + /// Captures the latest UI values (if the view is open) and saves. + public void CaptureAndSave() + { + CaptureFromUi?.Invoke(); + Save(); + } + + /// Persists settings to the per-user eu07.ini, keeping unknown keys. public void Save() { - // intentionally empty for now + if (string.IsNullOrEmpty(_savePath)) + _savePath = UserConfigPath(); + + WriteToConfig(); + + try + { + string? dir = Path.GetDirectoryName(_savePath); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + File.WriteAllText(_savePath, _config.ToText(), FileEncoding); + } + catch + { + // Could not write (permissions / path). Leave values in memory. + } + } + + // ── config → fields ─────────────────────────────────────────────────── + private void ReadFromConfig() + { + var c = _config; + + // General + LanguageWasSet = c.Has("lang"); + Language = c.GetString("lang", "en").ToLowerInvariant() == "pl" ? "Polski" : "English"; + Fullscreen = c.GetBool("fullscreen", false); + PauseWhenInactive = c.GetBool("inactivepause", true); + PauseOnStart = c.GetBool("pause", false); + + var mouse = c.GetTokens("mousescale"); + if (mouse.Length >= 1 && double.TryParse(mouse[0], NumberStyles.Float, CultureInfo.InvariantCulture, out double mx)) + { + InvertMouseHorizontal = mx < 0; + CursorSensitivity = Clamp((int)Math.Round(Math.Abs(mx)), 1, 5); + } + if (mouse.Length >= 2 && double.TryParse(mouse[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double my)) + { + InvertMouseVertical = my < 0; + _mouseScaleYMagnitude = Math.Abs(my); + } + + // Communication + IgnoreGamepad = !c.GetBool("input.gamepad", true); + FeedbackMode = Clamp(c.GetInt("feedbackmode", 0), 0, 5); + + // Other + SelectExeAutomatically = c.GetBool("starter.exe.auto", true); + ExecutablePath = c.GetString("starter.exe.path", "eu07.exe"); + DebugMode = c.GetBool("debugmode", false); + VirtualShunting = c.GetBool("ai.trainman", false); + + // Graphics + RenderEngine = IndexOf(RenderEngines, c.GetString("gfxrenderer", "full"), 0); + Width = c.GetInt("width", 1280); + Height = c.GetInt("height", 720); + BufferScalePercent = Clamp(c.GetInt("starter.bufferscale", 100), 50, 200); + MaxTextureSize = c.GetInt("maxtexturesize", 4096); + MaxCabTextureSize = c.GetInt("maxcabtexturesize", 4096); + TextureFiltering = c.GetInt("anisotropicfiltering", 8); + Multisampling = Clamp(c.GetInt("multisampling", 2), 0, 3); + DrawRangeFactor = c.GetDouble("gfx.drawrange.factor.max", 1.0); + VSync = c.GetBool("vsync", true); + Smoke = c.GetBool("gfx.smoke", true); + SmokeFidelity = Clamp(c.GetInt("gfx.smoke.fidelity", 1), 1, 4); + Tonemapping = c.GetString("gfx.postfx.tonemapping", "reinhard").ToLowerInvariant() == "aces" ? 1 : 0; + ChromaticAberration = c.GetBool("gfx.postfx.chromaticaberration.enabled", false); + MotionBlur = c.GetBool("gfx.postfx.motionblur.enabled", true); + ExtraEffects = c.GetBool("gfx.extraeffects", true); + EnvMap = c.GetBool("gfx.envmap.enabled", true); + UseVbo = c.GetBool("usevbo", true); + RenderShadows = c.GetBool("shadows", true); + ReflectionsFramerate = c.GetInt("gfx.reflections.framerate", 60); + + var shadowTune = c.GetTokens("shadowtune"); + if (shadowTune.Length >= 1) ShadowMapResolution = ParseInt(shadowTune[0], 4096); + if (shadowTune.Length >= 2) ShadowProjectionRange = ParseInt(shadowTune[1], 250); + if (shadowTune.Length >= 4) _shadowTuneExtra = new[] { shadowTune[2], shadowTune[3] }; + + CabShadowsRange = (int)Math.Round(c.GetDouble("gfx.shadows.cab.range", 30)); + ShadowRankCutoff = c.GetInt("gfx.shadow.rank.cutoff", 3); + ReflectionsFidelity = Clamp(c.GetInt("gfx.reflections.fidelity", 2), 0, 2); + FieldOfView = Clamp((int)Math.Round(c.GetDouble("fieldofview", 45)), 15, 75); + PythonScreens = c.GetBool("python.enabled", true); + PythonThreadedUpload = c.GetBool("python.threadedupload", true); + ScreenRendererPriority = IndexOf(ScreenPriorities, c.GetString("pyscreenrendererpriority", "lower"), 3) + 1; + + // Physics + SplineFidelity = Clamp(c.GetInt("splinefidelity", 1), 1, 4); + FullPhysics = c.GetBool("fullphysics", true); + EnableTraction = c.GetBool("enabletraction", true); + LiveTraction = c.GetBool("livetraction", false); + PhysicsLog = c.GetBool("physicslog", false); + DebugLog = c.GetInt("debuglog", 3) != 0; + MultipleLogs = c.GetBool("multiplelogs", false); + DisplaySimulation = !c.GetBool("gfx.skiprendering", false); + CrashDamage = c.GetBool("crashdamage", true); + + // Sound + SoundEnabled = c.GetBool("soundenabled", true); + Volume = Clamp((int)Math.Round(c.GetDouble("sound.volume", 1.5) * 50), 1, 100); + RadioVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.radio", 0.8) * 100), 1, 100); + VehiclesVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.vehicle", 1.0) * 100), 1, 100); + PositionalVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.positional", 1.0) * 100), 1, 100); + AmbientVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.ambient", 0.8) * 100), 1, 100); + PausedVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.paused", 0.0) * 100), 0, 100); + + // Starter + AutoCloseStarter = c.GetBool("starter.autoclose", false); + LargeThumbnails = c.GetBool("starter.largethumbnails", false); + AutoExpandSceneryTree = c.GetBool("starter.expandtree", false); + } + + // ── fields → config ─────────────────────────────────────────────────── + private void WriteToConfig() + { + var c = _config; + + // General + c.Set("lang", Language == "Polski" ? "pl" : "en"); + c.SetBool("fullscreen", Fullscreen); + c.SetBool("inactivepause", PauseWhenInactive); + c.SetBool("pause", PauseOnStart); + + double mx = CursorSensitivity * (InvertMouseHorizontal ? -1 : 1); + double my = _mouseScaleYMagnitude * (InvertMouseVertical ? -1 : 1); + c.Set("mousescale", + $"{mx.ToString("0.###", CultureInfo.InvariantCulture)} " + + $"{my.ToString("0.###", CultureInfo.InvariantCulture)}"); + + // Communication + c.SetBool("input.gamepad", !IgnoreGamepad); + c.SetInt("feedbackmode", FeedbackMode); + + // Other + c.SetBool("starter.exe.auto", SelectExeAutomatically); + c.Set("starter.exe.path", ExecutablePath); + c.SetBool("debugmode", DebugMode); + c.SetBool("ai.trainman", VirtualShunting); + + // Graphics + c.Set("gfxrenderer", RenderEngines[Clamp(RenderEngine, 0, RenderEngines.Length - 1)]); + c.SetInt("width", Width); + c.SetInt("height", Height); + c.SetInt("starter.bufferscale", BufferScalePercent); + c.SetInt("maxtexturesize", MaxTextureSize); + c.SetInt("maxcabtexturesize", MaxCabTextureSize); + c.SetInt("anisotropicfiltering", TextureFiltering); + c.SetInt("multisampling", Multisampling); + c.SetDouble("gfx.drawrange.factor.max", DrawRangeFactor); + c.SetBool("vsync", VSync); + c.SetBool("gfx.smoke", Smoke); + c.SetInt("gfx.smoke.fidelity", SmokeFidelity); + c.Set("gfx.postfx.tonemapping", Tonemapping == 1 ? "aces" : "reinhard"); + c.SetBool("gfx.postfx.chromaticaberration.enabled", ChromaticAberration); + c.SetBool("gfx.postfx.motionblur.enabled", MotionBlur); + c.SetBool("gfx.extraeffects", ExtraEffects); + c.SetBool("gfx.envmap.enabled", EnvMap); + c.SetBool("usevbo", UseVbo); + c.SetBool("shadows", RenderShadows); + c.SetBool("gfx.shadowmap.enabled", RenderShadows); + c.SetInt("gfx.reflections.framerate", ReflectionsFramerate); + c.Set("shadowtune", + $"{ShadowMapResolution} {ShadowProjectionRange} " + + $"{_shadowTuneExtra[0]} {_shadowTuneExtra[1]}"); + c.SetInt("gfx.shadows.cab.range", CabShadowsRange); + c.SetInt("gfx.shadow.rank.cutoff", ShadowRankCutoff); + c.SetInt("gfx.reflections.fidelity", ReflectionsFidelity); + c.SetInt("fieldofview", FieldOfView); + c.SetBool("python.enabled", PythonScreens); + c.SetBool("python.threadedupload", PythonThreadedUpload); + c.Set("pyscreenrendererpriority", + ScreenPriorities[Clamp(ScreenRendererPriority - 1, 0, ScreenPriorities.Length - 1)]); + + // Physics + c.SetInt("splinefidelity", SplineFidelity); + c.SetBool("fullphysics", FullPhysics); + c.SetBool("enabletraction", EnableTraction); + c.SetBool("livetraction", LiveTraction); + c.SetBool("physicslog", PhysicsLog); + c.SetInt("debuglog", DebugLog ? 3 : 0); + c.SetBool("multiplelogs", MultipleLogs); + c.SetBool("gfx.skiprendering", !DisplaySimulation); + c.SetBool("crashdamage", CrashDamage); + + // Sound + c.SetBool("soundenabled", SoundEnabled); + c.SetDouble("sound.volume", Volume / 50.0); + c.SetDouble("sound.volume.radio", RadioVolume / 100.0); + c.SetDouble("sound.volume.vehicle", VehiclesVolume / 100.0); + c.SetDouble("sound.volume.positional", PositionalVolume / 100.0); + c.SetDouble("sound.volume.ambient", AmbientVolume / 100.0); + c.SetDouble("sound.volume.paused", PausedVolume / 100.0); + + // Starter + c.SetBool("starter.autoclose", AutoCloseStarter); + c.SetBool("starter.largethumbnails", LargeThumbnails); + c.SetBool("starter.expandtree", AutoExpandSceneryTree); + } + + // ── small helpers ────────────────────────────────────────────────────── + private static int Clamp(int v, int lo, int hi) => v < lo ? lo : (v > hi ? hi : v); + + private static int ParseInt(string s, int fallback) => + int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int n) ? n : fallback; + + private static int IndexOf(string[] values, string token, int fallback) + { + for (int i = 0; i < values.Length; i++) + if (string.Equals(values[i], token, StringComparison.OrdinalIgnoreCase)) + return i; + return fallback; } } diff --git a/StarterNG/Views/Scenarios.axaml.cs b/StarterNG/Views/Scenarios.axaml.cs index 910a806..8445082 100644 --- a/StarterNG/Views/Scenarios.axaml.cs +++ b/StarterNG/Views/Scenarios.axaml.cs @@ -60,6 +60,10 @@ public partial class Scenarios : UserControl Tag = i }); } + + // refresh the consist preview when the view is shown again (e.g. after + // editing the consist in the depot) + AttachedToVisualTree += (_, _) => RefreshSelectedConsist(); } private void SceneryList_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) @@ -89,16 +93,14 @@ public partial class Scenarios : UserControl private void VehicleList_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) { - consistStack.Children.Clear(); - // get selected scenery var tItem = sceneryList.SelectedItem as TreeViewItem; if (tItem?.Tag is not int tTag) return; Scenery selectedScn = sceneries[tTag]; - + // get selected trainset - ListBoxItem vItem = vehicleList.SelectedItem as ListBoxItem; + ListBoxItem? vItem = vehicleList.SelectedItem as ListBoxItem; if (vItem?.Tag is not int vTag) return; Trainset selectedTrainset = selectedScn.Trainsets[vTag]; @@ -107,29 +109,41 @@ public partial class Scenarios : UserControl AppState.Instance.CurrentScenery = selectedScn; AppState.Instance.CurrentTrainset = selectedTrainset; + ShowConsist(selectedTrainset); + } + + // Renders the consist preview for a trainset (re-reads its current vehicles + // so depot edits are reflected). + private void ShowConsist(Trainset trainset) + { + consistStack.Children.Clear(); + var db = GameData.Instance.Vehicles; - foreach (var train in selectedTrainset.Vehicles) + foreach (var train in trainset.Vehicles) { // thumbnail name comes from the matching texture's texture_mini string miniName = db.MiniForSkin(train.SkinFile) ?? train.SkinFile; string? path = VehicleDatabase.MiniPath(miniName); if (path is null) - { - // fallback - continue; - } + continue; // fallback: no mini - var bitmap = new Bitmap(path); - var image = new Image + consistStack.Children.Add(new Image { - Source = bitmap, + Source = new Bitmap(path), Height = 45, Stretch = Avalonia.Media.Stretch.Uniform, Margin = new Thickness(0) - }; - consistStack.Children.Add(image); + }); } - missionDescription.Text = selectedTrainset.Description; + missionDescription.Text = trainset.Description; + } + + // When returning to this view, refresh the preview of the selected consist + // in case it was modified in the depot. + private void RefreshSelectedConsist() + { + if (AppState.Instance.CurrentTrainset is { } trainset) + ShowConsist(trainset); } // Exports the (possibly depot-modified) scenery to a $-prefixed copy and @@ -159,6 +173,9 @@ public partial class Scenarios : UserControl .FirstOrDefault(v => v.DriverType is eDriverType.Headdriver or eDriverType.Reardriver)?.Name ?? trainset?.Vehicles.FirstOrDefault()?.Name; + // make sure the latest settings are on disk before the sim reads them + StarterNG.Classes.Settings.Instance.CaptureAndSave(); + // launch the game: -s $.scn -v try { diff --git a/StarterNG/Views/Settings.axaml b/StarterNG/Views/Settings.axaml index 70fc150..0327740 100644 --- a/StarterNG/Views/Settings.axaml +++ b/StarterNG/Views/Settings.axaml @@ -1,4 +1,4 @@ - - - - - - - - - - -