Strip all comments from the C# sources

Removes every // and /// comment from the 28 .cs files, -1331 lines.

Done with a lexer rather than a regex: the sources carry plenty of // inside
string literals (@"//\$decor\b", the //$o trainset directives, sb.Append("// "),
"https://wiki.eu07.pl/") and those must survive. It tracks regular, verbatim and
interpolated strings, interpolation holes and char literals. Every file was
checked by comparing the sequence of string-literal characters before and after
the strip; all 28 match. BOMs preserved, blank-line runs collapsed to one.

Claude-Session: https://claude.ai/code/session_01HamVNWE9mwmz1vs2iEpNQq
This commit is contained in:
maj00r
2026-08-29 16:41:22 +02:00
parent 969a5b9494
commit 3b7a8135ea
28 changed files with 274 additions and 1288 deletions

View File

@@ -22,11 +22,8 @@ public partial class App : Application
{ {
AvaloniaXamlLoader.Load(this); 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(); Settings.Instance.Load();
// Key bindings live in eu07_input-keyboard.ini next to eu07.ini.
KeyboardConfig.Instance.Load(); KeyboardConfig.Instance.Load();
CultureInfo ci = CultureInfo.InstalledUICulture ; CultureInfo ci = CultureInfo.InstalledUICulture ;
@@ -43,17 +40,9 @@ public partial class App : Application
ApplyLanguage(langName); ApplyLanguage(langName);
} }
/// <summary>
/// Swaps the active language by loading the matching XML file from the
/// <c>lang/</c> folder next to the executable (see <see cref="LocalizationService"/>).
/// The translations are no longer compiled into the assembly, so they can be
/// edited or added without rebuilding the launcher. Accepts either a display
/// name ("English", "Polski") or a short code ("en", "pl").
/// </summary>
public static void ApplyLanguage(string langNameOrCode) public static void ApplyLanguage(string langNameOrCode)
{ {
// Loc.Load resolves the file and returns the canonical display name,
// which we mirror back into the settings so the stored value stays valid.
var resolvedName = Loc.Load(langNameOrCode); var resolvedName = Loc.Load(langNameOrCode);
Settings.Instance.Language = resolvedName; Settings.Instance.Language = resolvedName;
} }
@@ -62,11 +51,9 @@ public partial class App : Application
{ {
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
// Code page 1250 is needed to parse scenery files (done during load).
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
// Persist settings when the launcher is closing. Key bindings are only
// rewritten when actually edited, so an untouched file keeps its layout.
desktop.ShutdownRequested += (_, _) => desktop.ShutdownRequested += (_, _) =>
{ {
Settings.Instance.CaptureAndSave(); Settings.Instance.CaptureAndSave();
@@ -84,7 +71,7 @@ public partial class App : Application
{ {
Dispatcher.UIThread.Post(() => Dispatcher.UIThread.Post(() =>
{ {
// Show splash only if loading takes more than 300ms
if (sw.ElapsedMilliseconds > 300 && splash == null) if (sw.ElapsedMilliseconds > 300 && splash == null)
{ {
splash = new SplashWindow(); splash = new SplashWindow();

View File

@@ -2,14 +2,9 @@ using System.Reflection;
namespace StarterNG; namespace StarterNG;
/// <summary>Static, display-ready information about the running launcher build.</summary>
public static class AppInfo public static class AppInfo
{ {
/// <summary>
/// The launcher's assembly version (e.g. "1.2.3.4"), with a " (DEBUG)" suffix
/// appended for Debug builds so debug and release binaries are easy to tell
/// apart. Computed once; safe to bind with {x:Static}.
/// </summary>
public static string Version { get; } = BuildVersion(); public static string Version { get; } = BuildVersion();
private static string BuildVersion() private static string BuildVersion()

View File

@@ -2,12 +2,6 @@
namespace StarterNG.Classes; namespace StarterNG.Classes;
/// <summary>
/// Shared selection state across views: the scenery and trainset currently in
/// focus. The depot edits this trainset in place, so changes made in the depot
/// are reflected on the scenery (both views share the same Scenery/Trainset
/// objects from <see cref="GameData"/>).
/// </summary>
public sealed class AppState public sealed class AppState
{ {
public static AppState Instance { get; } = new(); public static AppState Instance { get; } = new();
@@ -40,11 +34,6 @@ public sealed class AppState
} }
} }
/// <summary>
/// Node name of the vehicle the player starts in (<c>-v</c>), like Pascal
/// <c>SelVehicle</c>. Updated when a consist car is clicked in the depot or a
/// trainset is picked in Scenarios.
/// </summary>
public string? StartingVehicleName public string? StartingVehicleName
{ {
get => _startingVehicleName; get => _startingVehicleName;
@@ -56,6 +45,5 @@ public sealed class AppState
} }
} }
/// <summary>Notify listeners after in-place edits that affect Start enablement.</summary>
public void NotifyChanged() => Changed?.Invoke(); public void NotifyChanged() => Changed?.Invoke();
} }

View File

@@ -5,35 +5,17 @@ using System.Text;
namespace StarterNG.Classes; namespace StarterNG.Classes;
/// <summary>
/// A line-oriented reader/writer for the EU07 <c>eu07.ini</c> configuration
/// file.
///
/// The file is not a classic INI: it is a flat list of <c>key value [value…]</c>
/// entries, one per line, with <c>//</c> 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 <see cref="Set"/> 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.
/// </summary>
public sealed class ConfigFile public sealed class ConfigFile
{ {
private readonly List<string> _lines = new(); private readonly List<string> _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<string, int> _index = private readonly Dictionary<string, int> _index =
new(StringComparer.OrdinalIgnoreCase); new(StringComparer.OrdinalIgnoreCase);
/// <summary>Parses raw file text, preserving line breaks and content.</summary>
public static ConfigFile Parse(string text) public static ConfigFile Parse(string text)
{ {
var cfg = new ConfigFile(); var cfg = new ConfigFile();
// Normalise CRLF/CR to LF for splitting; output uses Environment.NewLine.
text = text.Replace("\r\n", "\n").Replace('\r', '\n'); text = text.Replace("\r\n", "\n").Replace('\r', '\n');
foreach (var line in text.Split('\n')) foreach (var line in text.Split('\n'))
cfg.AddLine(line); cfg.AddLine(line);
@@ -45,13 +27,11 @@ public sealed class ConfigFile
int idx = _lines.Count; int idx = _lines.Count;
_lines.Add(raw); _lines.Add(raw);
if (TryParseKey(raw, out string key)) if (TryParseKey(raw, out string key))
_index[key] = idx; // later duplicates override earlier ones _index[key] = idx;
} }
/// <summary>True if the key is present as an active (uncommented) entry.</summary>
public bool Has(string key) => _index.ContainsKey(key); public bool Has(string key) => _index.ContainsKey(key);
/// <summary>Raw value (all tokens after the key, comment stripped), or null.</summary>
public string? GetRaw(string key) public string? GetRaw(string key)
{ {
if (!_index.TryGetValue(key, out int i)) if (!_index.TryGetValue(key, out int i))
@@ -89,7 +69,6 @@ public sealed class ConfigFile
CultureInfo.InvariantCulture, out double d) ? d : fallback; CultureInfo.InvariantCulture, out double d) ? d : fallback;
} }
/// <summary>All whitespace-separated value tokens for a key (never null).</summary>
public string[] GetTokens(string key) public string[] GetTokens(string key)
{ {
var v = GetRaw(key); var v = GetRaw(key);
@@ -98,11 +77,6 @@ public sealed class ConfigFile
: v.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); : v.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
} }
/// <summary>
/// Sets the value of <paramref name="key"/>. If the key already exists its
/// line is rewritten in place, preserving original indentation and any
/// trailing comment. Otherwise a new line is appended.
/// </summary>
public void Set(string key, string value) public void Set(string key, string value)
{ {
value ??= string.Empty; value ??= string.Empty;
@@ -125,11 +99,6 @@ public sealed class ConfigFile
} }
} }
/// <summary>
/// Deactivates <paramref name="key"/> by turning its line into a comment,
/// preserving indentation, the key/value that was there and any trailing
/// comment. No-op if the key is not currently active.
/// </summary>
public void Remove(string key) public void Remove(string key)
{ {
if (!_index.TryGetValue(key, out int i)) if (!_index.TryGetValue(key, out int i))
@@ -153,28 +122,19 @@ public sealed class ConfigFile
public void SetDouble(string key, double value) => public void SetDouble(string key, double value) =>
Set(key, value.ToString("0.###", CultureInfo.InvariantCulture)); Set(key, value.ToString("0.###", CultureInfo.InvariantCulture));
/// <summary>Serialises back to text using the platform newline.</summary>
public string ToText() => string.Join(Environment.NewLine, _lines); public string ToText() => string.Join(Environment.NewLine, _lines);
// --- line parsing helpers -------------------------------------------------
private static bool TryParseKey(string raw, out string key) private static bool TryParseKey(string raw, out string key)
{ {
SplitLine(raw, out _, out key, out _, out _); SplitLine(raw, out _, out key, out _, out _);
return key.Length > 0; return key.Length > 0;
} }
/// <summary>
/// Decomposes a raw line into leading whitespace, key, value (tokens after
/// the key) and trailing comment (including the <c>//</c>). For blank or
/// fully commented-out lines, key is empty.
/// </summary>
private static void SplitLine(string raw, out string indent, out string key, private static void SplitLine(string raw, out string indent, out string key,
out string value, out string comment) out string value, out string comment)
{ {
indent = key = value = comment = string.Empty; indent = key = value = comment = string.Empty;
// leading whitespace
int start = 0; int start = 0;
while (start < raw.Length && char.IsWhiteSpace(raw[start])) while (start < raw.Length && char.IsWhiteSpace(raw[start]))
start++; start++;
@@ -182,7 +142,6 @@ public sealed class ConfigFile
string body = raw.Substring(start); string body = raw.Substring(start);
// A line whose first non-space content is "//" is a pure comment.
if (body.StartsWith("//")) if (body.StartsWith("//"))
{ {
comment = body; comment = body;
@@ -191,8 +150,6 @@ public sealed class ConfigFile
if (body.Length == 0) if (body.Length == 0)
return; 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); int commentAt = FindCommentStart(body);
string content = commentAt >= 0 ? body.Substring(0, commentAt) : body; string content = commentAt >= 0 ? body.Substring(0, commentAt) : body;
if (commentAt >= 0) if (commentAt >= 0)

View File

@@ -13,14 +13,8 @@ public enum LoadPhase
Done Done
} }
/// <summary>A single progress update emitted while the game data loads.</summary>
public readonly record struct LoadStatus(double Fraction, LoadPhase Phase, string? Detail); public readonly record struct LoadStatus(double Fraction, LoadPhase Phase, string? Detail);
/// <summary>
/// Loads and holds the data shared across the launcher (vehicle database and
/// parsed sceneries) so it is parsed once, at startup, behind the splash —
/// instead of inside each view's constructor.
/// </summary>
public sealed class GameData public sealed class GameData
{ {
public static GameData Instance { get; } = new(); public static GameData Instance { get; } = new();
@@ -30,10 +24,6 @@ public sealed class GameData
public bool Loaded { get; private set; } public bool Loaded { get; private set; }
/// <summary>
/// Parses the vehicle database and all sceneries, reporting progress.
/// Runs synchronously; call it from a background thread. Never throws.
/// </summary>
public void Load(IProgress<LoadStatus>? progress = null, public void Load(IProgress<LoadStatus>? progress = null,
string vehiclesDir = "databases/vehicles/", string vehiclesDir = "databases/vehicles/",
string sceneryDir = "scenery/", string sceneryDir = "scenery/",
@@ -47,27 +37,21 @@ public sealed class GameData
int total = Math.Max(1, vehicleFiles.Count + scnFiles.Count); int total = Math.Max(1, vehicleFiles.Count + scnFiles.Count);
int done = 0; int done = 0;
// Phase 1 - vehicle database
Vehicles.BeginLoad(); Vehicles.BeginLoad();
foreach (string file in vehicleFiles) foreach (string file in vehicleFiles)
{ {
progress?.Report(new LoadStatus((double)done / total, LoadPhase.Vehicles, progress?.Report(new LoadStatus((double)done / total, LoadPhase.Vehicles,
Path.GetFileName(file))); Path.GetFileName(file)));
try { Vehicles.LoadFile(file); } try { Vehicles.LoadFile(file); }
catch { /* skip malformed file */ } catch { }
done++; done++;
} }
Vehicles.EndLoad(); Vehicles.EndLoad();
// Preload the miniature .bmp index now (behind the splash) so the depot's
// first thumbnail render doesn't scan textures/mini/ on the UI thread.
VehicleDatabase.PreloadMiniIndex(miniDir); VehicleDatabase.PreloadMiniIndex(miniDir);
// Likewise index every .fiz under dynamic/ once, so the consist's physics
// (length / mass / couplings) resolve without a UI-thread directory scan.
Physics.PreloadIndex(); Physics.PreloadIndex();
// Phase 2 - sceneries (parallelized)
var sceneryLock = new object(); var sceneryLock = new object();
var doneCount = done; var doneCount = done;
Parallel.ForEach(scnFiles, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, Parallel.ForEach(scnFiles, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
@@ -84,7 +68,7 @@ public sealed class GameData
Path.GetFileName(file))); Path.GetFileName(file)));
} }
} }
catch { /* skip unreadable scenery */ } catch { }
}); });
done = doneCount; done = doneCount;
@@ -92,7 +76,6 @@ public sealed class GameData
Loaded = true; Loaded = true;
} }
/// <summary>Re-reads scenery/*.scn (Pascal actReloadScenarios).</summary>
public void ReloadSceneries(string sceneryDir = "scenery/") public void ReloadSceneries(string sceneryDir = "scenery/")
{ {
Sceneries.Clear(); Sceneries.Clear();
@@ -107,7 +90,7 @@ public sealed class GameData
lock (sceneryLock) lock (sceneryLock)
Sceneries.Add(scenery); Sceneries.Add(scenery);
} }
catch { /* skip */ } catch { }
}); });
} }

View File

@@ -4,16 +4,11 @@ using Avalonia.Input;
namespace StarterNG.Classes; namespace StarterNG.Classes;
/// <summary>
/// One cap on the on-screen keyboard. <see cref="Token"/> is the simulator key
/// token a binding would reference; when null the cap is a non-bindable filler
/// (Shift, Ctrl, Tab …) drawn only to keep the layout recognisable.
/// </summary>
public sealed class KeyCap public sealed class KeyCap
{ {
public string Label = string.Empty; public string Label = string.Empty;
public string? Token; // null => filler / not bindable public string? Token;
public double Width = 1.0; // relative width (1 == a standard key) public double Width = 1.0;
public KeyCap(string label, string? token = null, double width = 1.0) public KeyCap(string label, string? token = null, double width = 1.0)
{ {
@@ -23,20 +18,12 @@ public sealed class KeyCap
} }
} }
/// <summary>
/// Translates between physical keyboard input, simulator key tokens and the
/// on-screen keyboard layout used by the controls visualisation.
/// </summary>
public static class KeyMap public static class KeyMap
{ {
/// <summary>
/// Resolves a pressed key to its simulator token, using the physical key to
/// disambiguate the numeric keypad. Returns null for keys with no token
/// (e.g. bare modifiers).
/// </summary>
public static string? FromInput(Key key, PhysicalKey physical) public static string? FromInput(Key key, PhysicalKey physical)
{ {
// Numeric keypad first - distinguished only by the physical key.
switch (physical) switch (physical)
{ {
case PhysicalKey.NumPad0: return "num_0"; case PhysicalKey.NumPad0: return "num_0";
@@ -57,15 +44,12 @@ public static class KeyMap
case PhysicalKey.NumPadEnter: return "num_enter"; case PhysicalKey.NumPadEnter: return "num_enter";
} }
// Numeric keypad via the virtual key (fallback when PhysicalKey is Unknown).
if (key >= Key.NumPad0 && key <= Key.NumPad9) if (key >= Key.NumPad0 && key <= Key.NumPad9)
return "num_" + (char)('0' + (key - Key.NumPad0)); return "num_" + (char)('0' + (key - Key.NumPad0));
// Letters.
if (key >= Key.A && key <= Key.Z) if (key >= Key.A && key <= Key.Z)
return ((char)('a' + (key - Key.A))).ToString(); return ((char)('a' + (key - Key.A))).ToString();
// Top-row digits.
if (key >= Key.D0 && key <= Key.D9) if (key >= Key.D0 && key <= Key.D9)
return ((char)('0' + (key - Key.D0))).ToString(); return ((char)('0' + (key - Key.D0))).ToString();
@@ -98,14 +82,12 @@ public static class KeyMap
}; };
} }
/// <summary>True for keys that only act as modifiers and never form a binding alone.</summary>
public static bool IsModifierKey(Key key) => key is public static bool IsModifierKey(Key key) => key is
Key.LeftShift or Key.RightShift or Key.LeftShift or Key.RightShift or
Key.LeftCtrl or Key.RightCtrl or Key.LeftCtrl or Key.RightCtrl or
Key.LeftAlt or Key.RightAlt or Key.LeftAlt or Key.RightAlt or
Key.LWin or Key.RWin; Key.LWin or Key.RWin;
/// <summary>Friendly, explicit name for a token, used in the binding list text.</summary>
public static string DisplayName(string? token) public static string DisplayName(string? token)
{ {
if (string.IsNullOrEmpty(token) || if (string.IsNullOrEmpty(token) ||
@@ -144,14 +126,9 @@ public static class KeyMap
}; };
} }
// ── On-screen keyboard layout ──────────────────────────────────────────
// Filler caps (Token == null) keep the shape familiar; only caps with a
// token are coloured by assignment state.
/// <summary>Rows of the main (alphanumeric) keyboard block.</summary>
public static readonly KeyCap[][] MainBlock = public static readonly KeyCap[][] MainBlock =
{ {
// F-row keys are reserved by the simulator, so they carry no token.
new[] new[]
{ {
new KeyCap("Esc", null, 1.4), new KeyCap("Esc", null, 1.4),
@@ -205,7 +182,6 @@ public static class KeyMap
}, },
}; };
/// <summary>Rows of the numeric-keypad block, drawn to the right of the main block.</summary>
public static readonly KeyCap[][] NumpadBlock = public static readonly KeyCap[][] NumpadBlock =
{ {
new[] new[]
@@ -234,10 +210,9 @@ public static class KeyMap
}, },
}; };
/// <summary>Navigation cluster (Insert/Home/PageUp · Delete/End/PageDown), drawn between the blocks.</summary>
public static readonly KeyCap[][] NavBlock = public static readonly KeyCap[][] NavBlock =
{ {
// PgUp / PgDn are reserved by the simulator (no token), like [ ] and Enter.
new[] new[]
{ {
new KeyCap("Ins", "insert"), new KeyCap("Home", "home"), new KeyCap("PgUp"), new KeyCap("Ins", "insert"), new KeyCap("Home", "home"), new KeyCap("PgUp"),

View File

@@ -5,60 +5,33 @@ using System.Text;
namespace StarterNG.Classes; namespace StarterNG.Classes;
/// <summary>
/// A single keyboard command binding parsed from <c>eu07_input-keyboard.ini</c>.
/// A binding is a simulator command name, an optional pair of modifier flags
/// (<c>shift</c> / <c>ctrl</c>, either or both) and the key it is mapped to.
/// <c>none</c> (or an empty key) means the command is intentionally unbound.
/// </summary>
public sealed class KeyBinding public sealed class KeyBinding
{ {
/// <summary>Simulator command token, e.g. <c>mastercontrollerincrease</c>.</summary>
public string Command = string.Empty; public string Command = string.Empty;
/// <summary>True when the binding requires the Shift modifier.</summary>
public bool Shift; public bool Shift;
/// <summary>True when the binding requires the Ctrl modifier.</summary>
public bool Ctrl; public bool Ctrl;
/// <summary>The key token (e.g. <c>q</c>, <c>num_+</c>) or <c>none</c> when unbound.</summary>
public string Key = "none"; public string Key = "none";
/// <summary>Human description taken from the line's trailing comment (no <c>//</c>).</summary>
public string Description = string.Empty; public string Description = string.Empty;
/// <summary>Index into the backing line list, so the original line can be rewritten in place.</summary>
internal int LineIndex = -1; internal int LineIndex = -1;
/// <summary>True when the command is mapped to an actual key.</summary>
public bool IsAssigned => public bool IsAssigned =>
!string.IsNullOrEmpty(Key) && !string.IsNullOrEmpty(Key) &&
!Key.Equals("none", StringComparison.OrdinalIgnoreCase); !Key.Equals("none", StringComparison.OrdinalIgnoreCase);
} }
/// <summary>
/// Reader/writer for the simulator's keyboard layout file,
/// <c>eu07_input-keyboard.ini</c>, which lives next to <c>eu07.ini</c>
/// (<c>%APPDATA%\MaSzyna\</c> on Windows, <c>~/.config/MaSzyna/</c> elsewhere).
///
/// <para>The file is a flat list of <c>command [shift] [ctrl] key // comment</c>
/// lines. Modifiers are the literal tokens <c>shift</c> and <c>ctrl</c> appearing
/// in any order between the command and the key; the last value token is the key.
/// Blank lines, pure comments and any lines the launcher does not recognise are
/// preserved verbatim on save - only the binding lines the user actually edits
/// are rewritten.</para>
/// </summary>
public sealed class KeyboardConfig public sealed class KeyboardConfig
{ {
public static KeyboardConfig Instance { get; } = new(); public static KeyboardConfig Instance { get; } = new();
private readonly List<string> _lines = new(); private readonly List<string> _lines = new();
/// <summary>All recognised bindings, in file order.</summary>
public List<KeyBinding> Bindings { get; private set; } = new(); public List<KeyBinding> Bindings { get; private set; } = new();
/// <summary>True when a binding has been changed since the last load/save.</summary>
public bool Dirty { get; set; } public bool Dirty { get; set; }
private string _savePath = string.Empty; private string _savePath = string.Empty;
@@ -67,7 +40,7 @@ public sealed class KeyboardConfig
private static Encoding ResolveEncoding() private static Encoding ResolveEncoding()
{ {
// Comments use Polish diacritics in Windows-1250, like eu07.ini; preserve them.
try try
{ {
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
@@ -79,7 +52,6 @@ public sealed class KeyboardConfig
} }
} }
/// <summary>Per-user keyboard config path for the current OS (next to eu07.ini).</summary>
public static string UserConfigPath() public static string UserConfigPath()
{ {
if (OperatingSystem.IsWindows()) if (OperatingSystem.IsWindows())
@@ -103,15 +75,12 @@ public sealed class KeyboardConfig
return Path.Combine(AppContext.BaseDirectory, "eu07_input-keyboard.ini"); return Path.Combine(AppContext.BaseDirectory, "eu07_input-keyboard.ini");
} }
/// <summary>Default file shipped in the simulator's working directory.</summary>
private static string WorkingDirDefaultPath() => private static string WorkingDirDefaultPath() =>
Path.Combine(Directory.GetCurrentDirectory(), "eu07_input-keyboard.ini"); Path.Combine(Directory.GetCurrentDirectory(), "eu07_input-keyboard.ini");
/// <summary>Fallback template bundled with the launcher (startercfg/).</summary>
private static string BundledDefaultPath() => private static string BundledDefaultPath() =>
Path.Combine(AppContext.BaseDirectory, "startercfg", "eu07_input-keyboard.ini"); Path.Combine(AppContext.BaseDirectory, "startercfg", "eu07_input-keyboard.ini");
/// <summary>Loads the per-user file, falling back to the working-dir or bundled default.</summary>
public void Load() public void Load()
{ {
_savePath = UserConfigPath(); _savePath = UserConfigPath();
@@ -120,7 +89,6 @@ public sealed class KeyboardConfig
Dirty = false; Dirty = false;
} }
/// <summary>Reloads the shipped defaults, discarding the user's current edits.</summary>
public void LoadDefaults() public void LoadDefaults()
{ {
if (string.IsNullOrEmpty(_savePath)) if (string.IsNullOrEmpty(_savePath))
@@ -151,7 +119,6 @@ public sealed class KeyboardConfig
return paths.Length > 0 ? paths[^1] : string.Empty; return paths.Length > 0 ? paths[^1] : string.Empty;
} }
/// <summary>Parses raw file text into <see cref="Bindings"/>, keeping every line verbatim.</summary>
public void Parse(string text) public void Parse(string text)
{ {
_lines.Clear(); _lines.Clear();
@@ -170,10 +137,6 @@ public sealed class KeyboardConfig
Bindings = bindings; Bindings = bindings;
} }
/// <summary>
/// Parses a single line into a binding. Returns false for blank lines, pure
/// comments and anything without a command token.
/// </summary>
private static bool TryParseBinding(string raw, int lineIndex, out KeyBinding binding) private static bool TryParseBinding(string raw, int lineIndex, out KeyBinding binding)
{ {
binding = new KeyBinding { LineIndex = lineIndex }; binding = new KeyBinding { LineIndex = lineIndex };
@@ -181,7 +144,6 @@ public sealed class KeyboardConfig
string body = raw; string body = raw;
string description = string.Empty; string description = string.Empty;
// Split off a trailing "// comment".
int commentAt = body.IndexOf("//", StringComparison.Ordinal); int commentAt = body.IndexOf("//", StringComparison.Ordinal);
if (commentAt >= 0) if (commentAt >= 0)
{ {
@@ -191,7 +153,7 @@ public sealed class KeyboardConfig
body = body.Trim(); body = body.Trim();
if (body.Length == 0) if (body.Length == 0)
return false; // blank or pure-comment line return false;
var tokens = body.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); var tokens = body.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length == 0) if (tokens.Length == 0)
@@ -200,7 +162,6 @@ public sealed class KeyboardConfig
binding.Command = tokens[0]; binding.Command = tokens[0];
binding.Description = description; binding.Description = description;
// Everything after the command is modifiers + (at most) one key.
bool shift = false, ctrl = false; bool shift = false, ctrl = false;
string key = "none"; string key = "none";
for (int i = 1; i < tokens.Length; i++) for (int i = 1; i < tokens.Length; i++)
@@ -208,7 +169,7 @@ public sealed class KeyboardConfig
string t = tokens[i]; string t = tokens[i];
if (t.Equals("shift", StringComparison.OrdinalIgnoreCase)) shift = true; if (t.Equals("shift", StringComparison.OrdinalIgnoreCase)) shift = true;
else if (t.Equals("ctrl", StringComparison.OrdinalIgnoreCase)) ctrl = true; else if (t.Equals("ctrl", StringComparison.OrdinalIgnoreCase)) ctrl = true;
else key = t; // last non-modifier token wins as the key else key = t;
} }
binding.Shift = shift; binding.Shift = shift;
@@ -217,7 +178,6 @@ public sealed class KeyboardConfig
return true; return true;
} }
/// <summary>Rewrites each binding's line, then serialises the file to text.</summary>
public string ToText() public string ToText()
{ {
foreach (var b in Bindings) foreach (var b in Bindings)
@@ -229,7 +189,6 @@ public sealed class KeyboardConfig
return string.Join(Environment.NewLine, _lines); return string.Join(Environment.NewLine, _lines);
} }
/// <summary>Formats one binding as <c>command [shift] [ctrl] key // comment</c>.</summary>
private static string FormatLine(KeyBinding b) private static string FormatLine(KeyBinding b)
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
@@ -243,7 +202,6 @@ public sealed class KeyboardConfig
return sb.ToString(); return sb.ToString();
} }
/// <summary>Writes the bindings back to the per-user keyboard file.</summary>
public void Save() public void Save()
{ {
if (string.IsNullOrEmpty(_savePath)) if (string.IsNullOrEmpty(_savePath))
@@ -259,7 +217,7 @@ public sealed class KeyboardConfig
} }
catch catch
{ {
// Could not write (permissions / path). Leave values in memory.
} }
} }
} }

View File

@@ -6,17 +6,11 @@ using System.Text;
namespace StarterNG.Classes; namespace StarterNG.Classes;
/// <summary>
/// Vehicle physics read from a .fiz file (dynamic/&lt;dir&gt;/&lt;model&gt;.fiz), mirroring
/// the original Starter's TLexParser.ParsePhysics. Provides mass, top speed,
/// length, accepted loads and the coupler AllowedFlag / ControlType used for the
/// automatic coupling calculation.
/// </summary>
public sealed class Physics public sealed class Physics
{ {
public double Mass; // kg, as written in the .fiz public double Mass;
public double VMax; // km/h public double VMax;
public double Length; // metres public double Length;
public string LoadAccepted = ""; public string LoadAccepted = "";
public int MaxLoad; public int MaxLoad;
public int AllowedFlagA = 3; public int AllowedFlagA = 3;
@@ -26,12 +20,8 @@ public sealed class Physics
private static readonly Dictionary<string, Physics?> Cache = new(StringComparer.OrdinalIgnoreCase); private static readonly Dictionary<string, Physics?> Cache = new(StringComparer.OrdinalIgnoreCase);
// Index of every .fiz under dynamic/, keyed by its file name without extension
// (e.g. "en57", "en57dumb"). Built once - the directory layout of the data
// folder vs the scenery's mmd token is unreliable, so we resolve by model name.
private static Dictionary<string, string>? _fizIndex; private static Dictionary<string, string>? _fizIndex;
/// <summary>Builds the .fiz index up front (call from the startup load).</summary>
public static void PreloadIndex(string dynamicRoot = "dynamic") => FizIndex(dynamicRoot); public static void PreloadIndex(string dynamicRoot = "dynamic") => FizIndex(dynamicRoot);
private static Dictionary<string, string> FizIndex(string dynamicRoot) private static Dictionary<string, string> FizIndex(string dynamicRoot)
@@ -46,23 +36,18 @@ public sealed class Physics
foreach (string f in Directory.EnumerateFiles(dynamicRoot, "*.fiz", SearchOption.AllDirectories)) foreach (string f in Directory.EnumerateFiles(dynamicRoot, "*.fiz", SearchOption.AllDirectories))
index[Path.GetFileNameWithoutExtension(f)] = f; index[Path.GetFileNameWithoutExtension(f)] = f;
} }
catch { /* unreadable dynamic/ - no physics */ } catch { }
_fizIndex = index; _fizIndex = index;
return index; return index;
} }
/// <summary>
/// Loads (and caches) the physics for a model name (the .fiz is located by name
/// anywhere under dynamic/). The <paramref name="dataFolder"/> is unused now but
/// kept for call-site clarity. Returns null when nothing matches. Never throws.
/// </summary>
public static Physics? For(string? dataFolder, string? model, string dynamicRoot = "dynamic") public static Physics? For(string? dataFolder, string? model, string dynamicRoot = "dynamic")
{ {
if (string.IsNullOrEmpty(model)) if (string.IsNullOrEmpty(model))
return null; return null;
string m = Path.GetFileNameWithoutExtension(model!); // strip any .mmd/.t3d/etc. string m = Path.GetFileNameWithoutExtension(model!);
if (m.Length == 0) if (m.Length == 0)
return null; return null;
@@ -77,12 +62,10 @@ public sealed class Physics
Physics? phys = null; Physics? phys = null;
if (path != null) if (path != null)
{ {
phys = new Physics { AllowedFlagB = -1 }; // -1 = "B end not declared yet" phys = new Physics { AllowedFlagB = -1 };
try { Parse(phys, path, Path.GetDirectoryName(path) ?? "", null, 0); } try { Parse(phys, path, Path.GetDirectoryName(path) ?? "", null, 0); }
catch { /* partial data still useful */ } catch { }
// The original copies the A-end coupler onto the B-end when only one
// BuffCoupl section is declared.
if (phys.AllowedFlagB == -1) if (phys.AllowedFlagB == -1)
{ {
phys.AllowedFlagB = phys.AllowedFlagA; phys.AllowedFlagB = phys.AllowedFlagA;
@@ -116,7 +99,6 @@ public sealed class Physics
if (tok == "=") if (tok == "=")
continue; continue;
// include <file> <params...> end -> parse the included physics
if (tok.Equals("include", StringComparison.OrdinalIgnoreCase)) if (tok.Equals("include", StringComparison.OrdinalIgnoreCase))
{ {
if (++i >= tokens.Count) break; if (++i >= tokens.Count) break;
@@ -149,13 +131,11 @@ public sealed class Physics
} }
else if (KnownKeys.Contains(t)) else if (KnownKeys.Contains(t))
{ {
pendingKey = t; // value comes in a following token (spaced "Key = value") pendingKey = t;
} }
else else
{ {
// any other bare identifier is a section header (Param, Load,
// Dimensions, BuffCoupl/1/2, or one we don't read). Keep only
// letters/digits so "Param:" or "Param," still match.
section = new string(t.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant(); section = new string(t.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant();
} }
} }
@@ -188,7 +168,6 @@ public sealed class Physics
} }
} }
// A negative AllowedFlag means a permanent coupling: abs + 128 (workshop-lock bit).
private static int Flag(string v, int fallback) private static int Flag(string v, int fallback)
=> int.TryParse(v, System.Globalization.NumberStyles.Integer, => int.TryParse(v, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out int f) System.Globalization.CultureInfo.InvariantCulture, out int f)
@@ -201,8 +180,6 @@ public sealed class Physics
? d ? d
: fallback; : fallback;
// Substitutes an include placeholder like "(c1)" with the matching include
// parameter (1-based), otherwise returns the token unchanged.
private static string Resolve(string token, string[]? prms) private static string Resolve(string token, string[]? prms)
{ {
if (prms == null || token.Length < 3 || token[0] != '(' || token[^1] != ')') if (prms == null || token.Length < 3 || token[0] != '(' || token[^1] != ')')

View File

@@ -17,30 +17,18 @@ public enum PresetSort
Track Track
} }
/// <summary>One saved consist in the user's vehicle warehouse.</summary>
public sealed class TrainsetPreset public sealed class TrainsetPreset
{ {
/// <summary>Display name shown in the "load from warehouse" menu.</summary>
public string Name { get; set; } = ""; public string Name { get; set; } = "";
/// <summary>Complete "trainset … endtrainset" scenery entry (self-contained).</summary>
public string Entry { get; set; } = ""; public string Entry { get; set; } = "";
} }
/// <summary>Root object persisted to userpresets.json.</summary>
public sealed class PresetCollection public sealed class PresetCollection
{ {
public List<TrainsetPreset> Presets { get; set; } = new(); public List<TrainsetPreset> Presets { get; set; } = new();
} }
/// <summary>
/// The user's vehicle/consist "warehouse": named trainset presets persisted as JSON.
/// Stored under the per-user config directory so it survives reinstalls:
/// Windows: %APPDATA%\MaSzyna\starter\userpresets.json
/// macOS: ~/Library/Application Support/MaSzyna/starter/userpresets.json
/// Linux: ~/.config/MaSzyna/starter/userpresets.json
/// All operations are best-effort and never throw.
/// </summary>
public static class PresetStore public static class PresetStore
{ {
private static string ResolvePath() private static string ResolvePath()
@@ -63,11 +51,8 @@ public static class PresetStore
public static string FilePath { get; } = ResolvePath(); public static string FilePath { get; } = ResolvePath();
/// <summary>Pascal miSortByVehicleName / miSortByTrackName for the warehouse menu.</summary>
public static PresetSort SortMode { get; set; } = PresetSort.Name; public static PresetSort SortMode { get; set; } = PresetSort.Name;
// Source-generated metadata keeps (de)serialisation AOT/trim-safe, matching the
// approach used for the vehicle database.
private static readonly JsonSerializerOptions Options = new() private static readonly JsonSerializerOptions Options = new()
{ {
WriteIndented = true, WriteIndented = true,
@@ -77,7 +62,6 @@ public static class PresetStore
private static JsonTypeInfo<PresetCollection> TypeInfo => private static JsonTypeInfo<PresetCollection> TypeInfo =>
(JsonTypeInfo<PresetCollection>)Options.GetTypeInfo(typeof(PresetCollection)); (JsonTypeInfo<PresetCollection>)Options.GetTypeInfo(typeof(PresetCollection));
/// <summary>All saved presets, sorted per <see cref="SortMode"/>.</summary>
public static IReadOnlyList<TrainsetPreset> All() public static IReadOnlyList<TrainsetPreset> All()
{ {
var list = Load().Presets.ToList(); var list = Load().Presets.ToList();
@@ -88,10 +72,6 @@ public static class PresetStore
return list; return list;
} }
/// <summary>
/// Saves (or overwrites, by name) a consist preset. No-op on a blank name or
/// empty entry.
/// </summary>
public static void Save(string? name, string? entry) public static void Save(string? name, string? entry)
{ {
name = name?.Trim() ?? ""; name = name?.Trim() ?? "";
@@ -105,7 +85,6 @@ public static class PresetStore
Persist(col); Persist(col);
} }
/// <summary>Removes a preset by name (case-insensitive).</summary>
public static void Delete(string name) public static void Delete(string name)
{ {
var col = Load(); var col = Load();
@@ -113,10 +92,6 @@ public static class PresetStore
Persist(col); Persist(col);
} }
/// <summary>
/// Import Pascal <c>starter/magazyn.ini</c> (or legacy starter.ini / RAINSTED.INI)
/// into the JSON warehouse. Returns how many consists were added.
/// </summary>
public static int ImportMagazyn(string? path = null) public static int ImportMagazyn(string? path = null)
{ {
path ??= FindMagazynPath(); path ??= FindMagazynPath();
@@ -197,7 +172,7 @@ public static class PresetStore
} }
catch catch
{ {
// unreadable / malformed - start from an empty warehouse
} }
return new PresetCollection(); return new PresetCollection();
} }
@@ -211,24 +186,15 @@ public static class PresetStore
} }
catch catch
{ {
// best-effort: a failed save just means the warehouse isn't updated
} }
} }
} }
/// <summary>
/// Helpers to convert a consist to/from its canonical scenery text - the full
/// "trainset … endtrainset" block, exactly what is placed on the clipboard.
/// </summary>
public static class ConsistText public static class ConsistText
{ {
/// <summary>The complete scenery entry for a trainset (incl. endtrainset).</summary>
public static string Serialize(Trainset trainset) => trainset.ToSceneryEntry(); public static string Serialize(Trainset trainset) => trainset.ToSceneryEntry();
/// <summary>
/// Parses the vehicles out of a complete trainset entry. Returns null when the
/// text holds no usable node::dynamic vehicles.
/// </summary>
public static List<Dynamic>? VehiclesFrom(string? text) public static List<Dynamic>? VehiclesFrom(string? text)
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
@@ -245,7 +211,6 @@ public static class ConsistText
} }
} }
// Source-generated JSON metadata (AOT/trim-safe; no runtime reflection).
[JsonSerializable(typeof(PresetCollection))] [JsonSerializable(typeof(PresetCollection))]
[JsonSerializable(typeof(TrainsetPreset))] [JsonSerializable(typeof(TrainsetPreset))]
internal partial class PresetJsonContext : JsonSerializerContext internal partial class PresetJsonContext : JsonSerializerContext

View File

@@ -18,7 +18,6 @@ public sealed class SceneryInclude
{ {
public string FilePath = ""; public string FilePath = "";
public string Desc = ""; public string Desc = "";
/// <summary>0 = default off, 1 = default on, 2 = terrain (auto if no .sbt).</summary>
public int Kind; public int Kind;
public bool Selected; public bool Selected;
} }
@@ -29,49 +28,33 @@ public class Scenery
public List<Trainset> Trainsets; public List<Trainset> Trainsets;
public List<SceneryAttachment> Attachments = new(); public List<SceneryAttachment> Attachments = new();
public List<SceneryInclude> Includes = new(); public List<SceneryInclude> Includes = new();
/// <summary>
/// Names of <c>node … dynamic</c> entries outside trainsets (kept in the
/// template on export; collected so the depot can avoid name clashes).
/// </summary>
public List<string> LooseVehicleNames = new(); public List<string> LooseVehicleNames = new();
/// <summary>Parsed loose dynamics (Pascal <c>SCN.Vehicles</c>).</summary>
public List<Dynamic> LooseVehicles = new(); public List<Dynamic> LooseVehicles = new();
/// <summary>Parse / vehicle faults for the Info tab (Pascal FaultList).</summary>
public List<string> Faults = new(); public List<string> Faults = new();
public string Group; public string Group;
public string Path; public string Path;
// Starter directives (comment-syntax metadata, see wiki "Plik scenerii"). public string Name;
// They do not affect the simulation, only how a starter presents the scenery. public string Description;
public string Name; // //$n - scenery name public string ImageName;
public string Description; // //$d - scenery description
public string ImageName; // //$i - main-window image (scenery thumbnail)
/// <summary>True when the scenery declares the //$a "archival" flag.</summary>
public bool Archival; public bool Archival;
// Weather / environment. Like the original Starter, these are editable and are public string Time = "10:30";
// written into the scenery's "config" block on launch (see RewriteWeather). public string ScenarioTimeOverride = "10:30";
// public int Day = 0;
// Two clocks, matching Pascal TConfig.StartTime / TConfig.Time: public double Temperature = 15;
// Time -> classic "time … endtime" (from the SCN; UI does not edit it) public int FogStart = 10;
// ScenarioTimeOverride -> "scenario.time.override" (what the Weather picker edits) public int FogEnd = 2000;
// Defaults: classic start 10:30; override falls back to "now" when the SCN has neither. public double Overcast = 0;
public string Time = "10:30"; // h:mm -> "time … endtime"
public string ScenarioTimeOverride = "10:30"; // h:mm -> "scenario.time.override"
public int Day = 0; // "movelight <day>" (day of year / season)
public double Temperature = 15; // "scenario.weather.temperature"
public int FogStart = 10; // atmo fog start (metres)
public int FogEnd = 2000; // visibility in metres (atmo fog range)
public double Overcast = 0; // atmo overcast factor (-1.5 .. 1.5)
/// <summary>True when the scenery actually declared any weather command.</summary>
public bool HasWeather; public bool HasWeather;
/// <summary>Set once the user edits the weather, so export rewrites the config.</summary>
public bool WeatherDirty; public bool WeatherDirty;
// Snapshot of weather as loaded from the SCN (Pascal actRestoreWeather).
private string _origTime = "10:30"; private string _origTime = "10:30";
private string _origOverride = "10:30"; private string _origOverride = "10:30";
private int _origDay; private int _origDay;
@@ -79,11 +62,8 @@ public class Scenery
private int _origFogEnd = 2000; private int _origFogEnd = 2000;
private double _origOvercast; private double _origOvercast;
// The file content with each trainset block replaced by a {{i}} placeholder,
// used to rebuild the .scn on export.
private readonly string _template; private readonly string _template;
/// <summary>Display label: //$n when set (with @token i18n), otherwise the file name.</summary>
public string DisplayName public string DisplayName
{ {
get get
@@ -94,12 +74,10 @@ public class Scenery
} }
} }
/// <summary>//$d lines with @token translation applied.</summary>
public string LocalizedDescription => public string LocalizedDescription =>
string.IsNullOrEmpty(Description) ? "" : string.IsNullOrEmpty(Description) ? "" :
string.Join("\n", Description.Split('\n').Select(SceneryI18n.T)); string.Join("\n", Description.Split('\n').Select(SceneryI18n.T));
// Lazily resolved, cached path to the //$i image on disk (null if not found).
private string _imagePath; private string _imagePath;
private bool _imagePathResolved; private bool _imagePathResolved;
@@ -109,29 +87,23 @@ public class Scenery
Trainsets = new List<Trainset>(); Trainsets = new List<Trainset>();
if (!File.Exists(path)) if (!File.Exists(path))
throw new FileNotFoundException(path); throw new FileNotFoundException(path);
var encoding = Encoding.GetEncoding(1250); // Windows-1250 var encoding = Encoding.GetEncoding(1250);
string content = File.ReadAllText(path, encoding); string content = File.ReadAllText(path, encoding);
// property scanning - starter directives written as // comments.
// \b after the letter keeps //$d from matching //$decor, //$i from
// matching //$it, etc.
this.Group = MatchDirective(content, "l"); this.Group = MatchDirective(content, "l");
this.Name = MatchDirective(content, "n"); this.Name = MatchDirective(content, "n");
// //$d may appear on several lines; each is one line of the description.
this.Description = MatchAllDirectives(content, "d"); this.Description = MatchAllDirectives(content, "d");
this.ImageName = MatchDirective(content, "i"); this.ImageName = MatchDirective(content, "i");
// //$a marks an archival scenery (present = archival, regardless of value).
this.Archival = MatchDirective(content, "a") != null; this.Archival = MatchDirective(content, "a") != null;
// weather/environment is read from the raw text before trainset blocks
// are stripped out below (the atmosphere commands live outside trainsets)
ParseWeather(content); ParseWeather(content);
SnapshotWeather(); SnapshotWeather();
ParseAttachments(content); ParseAttachments(content);
content = ParseAndStripOptionalIncludes(content); content = ParseAndStripOptionalIncludes(content);
// parsing trainsets
List<string> trainsetEntries = new List<string>(); List<string> trainsetEntries = new List<string>();
Regex regex = new Regex( Regex regex = new Regex(
@"trainset\b[\s\S]*?\bendtrainset\b", @"trainset\b[\s\S]*?\bendtrainset\b",
@@ -146,8 +118,6 @@ public class Scenery
content = ExtractLooseVehicles(content); content = ExtractLooseVehicles(content);
_template = content; _template = content;
// 1:1 with placeholders - the Trainset ctor never throws (unparsable
// blocks are kept verbatim), so indices stay aligned for export.
foreach (string trainsetEntry in trainsetEntries) foreach (string trainsetEntry in trainsetEntries)
{ {
var ts = new Trainset(trainsetEntry); var ts = new Trainset(trainsetEntry);
@@ -157,8 +127,6 @@ public class Scenery
} }
} }
// Pull standalone node…dynamic blocks out of the template (Pascal SCN.Vehicles)
// so export can re-emit them via PrepareNode(false) and names stay editable.
private string ExtractLooseVehicles(string content) private string ExtractLooseVehicles(string content)
{ {
LooseVehicles.Clear(); LooseVehicles.Clear();
@@ -212,10 +180,6 @@ public class Scenery
}); });
} }
/// <summary>
/// Rebuilds the full .scn content with the (possibly modified) trainsets
/// substituted back into their original positions.
/// </summary>
public string BuildExportContent(bool skipDecorTrainsets = false) public string BuildExportContent(bool skipDecorTrainsets = false)
{ {
string result = _template; string result = _template;
@@ -260,7 +224,7 @@ public class Scenery
string rest = m.Groups[1].Value.Trim(); string rest = m.Groups[1].Value.Trim();
if (rest.Length == 0) continue; if (rest.Length == 0) continue;
string[] parts = rest.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); string[] parts = rest.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
// "$f XX path label…" (Pascal) or "$f path label"
if (parts.Length >= 3 && parts[0].Length <= 2) if (parts.Length >= 3 && parts[0].Length <= 2)
Attachments.Add(new SceneryAttachment Attachments.Add(new SceneryAttachment
{ {
@@ -282,8 +246,6 @@ public class Scenery
} }
} }
// Pulls include … end //$optional… … endoptional out of the template so Start
// can re-inject only the checked ones (Pascal ParseInc / actStartExecute).
private string ParseAndStripOptionalIncludes(string content) private string ParseAndStripOptionalIncludes(string content)
{ {
Includes.Clear(); Includes.Clear();
@@ -346,16 +308,9 @@ public class Scenery
return sb + text; return sb + text;
} }
/// <summary>
/// Reads the scenery's environment commands into the editable weather fields.
/// Mirrors the original Starter (config: movelight / scenario.weather.temperature
/// / scenario.time.override, plus top-level time and the atmo fog/overcast block).
/// </summary>
private void ParseWeather(string content) private void ParseWeather(string content)
{ {
// Classic "time h:mm" seeds both clocks (Pascal: Config.Time + Config.StartTime).
// "scenario.time.override" seeds only the UI clock (Pascal ignored it on load;
// we honour it so a previously exported $scn round-trips correctly).
var time = Regex.Match(content, @"(?im)^\s*time\s+(\d{1,2})[:.](\d{2})\b"); var time = Regex.Match(content, @"(?im)^\s*time\s+(\d{1,2})[:.](\d{2})\b");
if (time.Success) if (time.Success)
{ {
@@ -371,12 +326,11 @@ public class Scenery
} }
else if (!time.Success) else if (!time.Success)
{ {
// No clock in the SCN → UI shows "now", classic export stays 10:30 (Pascal).
var now = System.DateTime.Now; var now = System.DateTime.Now;
ScenarioTimeOverride = $"{now.Hour:D2}:{now.Minute:D2}"; ScenarioTimeOverride = $"{now.Hour:D2}:{now.Minute:D2}";
} }
// "movelight <day>" - day of the year (sun elevation / season)
var move = Regex.Match(content, @"(?im)\bmovelight\s+(-?\d+)"); var move = Regex.Match(content, @"(?im)\bmovelight\s+(-?\d+)");
if (move.Success && int.TryParse(move.Groups[1].Value, out int day)) if (move.Success && int.TryParse(move.Groups[1].Value, out int day))
{ {
@@ -384,7 +338,6 @@ public class Scenery
HasWeather = true; HasWeather = true;
} }
// "scenario.weather.temperature <°C>"
var temp = Regex.Match(content, @"(?i)scenario\.weather\.temperature\s+(-?\d+(?:\.\d+)?)"); var temp = Regex.Match(content, @"(?i)scenario\.weather\.temperature\s+(-?\d+(?:\.\d+)?)");
if (temp.Success && if (temp.Success &&
double.TryParse(temp.Groups[1].Value, System.Globalization.NumberStyles.Float, double.TryParse(temp.Groups[1].Value, System.Globalization.NumberStyles.Float,
@@ -394,8 +347,6 @@ public class Scenery
HasWeather = true; HasWeather = true;
} }
// "atmo R G B fogStart fogEnd R G B overcast endatmo"
// Pascal ParseAtmo: FogEnd := RandomRange(FogStart, FogEnd).
var atmo = Regex.Match(content, @"(?is)\batmo\b(.*?)\bendatmo\b"); var atmo = Regex.Match(content, @"(?is)\batmo\b(.*?)\bendatmo\b");
if (atmo.Success) if (atmo.Success)
{ {
@@ -429,7 +380,6 @@ public class Scenery
_origOvercast = Overcast; _origOvercast = Overcast;
} }
/// <summary>Restores weather fields to the values parsed from the SCN file.</summary>
public void RestoreWeather() public void RestoreWeather()
{ {
Time = _origTime; Time = _origTime;
@@ -441,17 +391,11 @@ public class Scenery
WeatherDirty = true; WeatherDirty = true;
} }
/// <summary>
/// Replaces the scenery's environment commands with a fresh config block built
/// from the current weather fields (the C# equivalent of the original Starter's
/// TLexParser.ChangeConfig). On any failure the original text is returned
/// unchanged so a launch is never blocked by a bad rewrite.
/// </summary>
private string RewriteWeather(string text) private string RewriteWeather(string text)
{ {
try try
{ {
// strip the existing weather commands
string s = text; string s = text;
s = Regex.Replace(s, @"(?is)\bconfig\b.*?\bendconfig\b", " "); s = Regex.Replace(s, @"(?is)\bconfig\b.*?\bendconfig\b", " ");
s = Regex.Replace(s, @"(?is)\btime\b\s+\d[^\r\n]*?\bendtime\b", " "); s = Regex.Replace(s, @"(?is)\btime\b\s+\d[^\r\n]*?\bendtime\b", " ");
@@ -461,7 +405,7 @@ public class Scenery
s = Regex.Replace(s, @"(?i)scenario\.time\.override\s+\S+", " "); s = Regex.Replace(s, @"(?i)scenario\.time\.override\s+\S+", " ");
var inv = System.Globalization.CultureInfo.InvariantCulture; var inv = System.Globalization.CultureInfo.InvariantCulture;
// Override = picker value; classic time = original SCN start (unchanged by UI).
string config = string config =
"config\r\n" + "config\r\n" +
$"movelight {Day}\r\n" + $"movelight {Day}\r\n" +
@@ -479,10 +423,6 @@ public class Scenery
} }
} }
/// <summary>
/// Collects every //$&lt;letter&gt; line (e.g. all //$d lines) and joins them
/// into one multi-line string. Returns null when none are present.
/// </summary>
private static string MatchAllDirectives(string content, string letter) private static string MatchAllDirectives(string content, string letter)
{ {
var matches = Regex.Matches( var matches = Regex.Matches(
@@ -495,11 +435,6 @@ public class Scenery
return string.Join("\n", matches.Select(m => m.Groups[1].Value.TrimEnd())); return string.Join("\n", matches.Select(m => m.Groups[1].Value.TrimEnd()));
} }
/// <summary>
/// Reads a single starter directive value (the text after //$&lt;letter&gt;).
/// Returns null when the directive is absent. The trailing whitespace
/// requirement separates e.g. //$d from //$decor and //$i from //$it.
/// </summary>
private static string MatchDirective(string content, string letter) private static string MatchDirective(string content, string letter)
{ {
var match = Regex.Match( var match = Regex.Match(
@@ -510,11 +445,6 @@ public class Scenery
return match.Success ? match.Groups[1].Value.Trim() : null; return match.Success ? match.Groups[1].Value.Trim() : null;
} }
/// <summary>
/// Resolved on-disk path of the //$i scenery image, or null if none is
/// declared or the file cannot be found. The value of //$i may be a bare
/// file name or a path; common locations are probed and the result cached.
/// </summary>
public string ImagePath public string ImagePath
{ {
get get
@@ -532,19 +462,17 @@ public class Scenery
if (string.IsNullOrWhiteSpace(ImageName)) if (string.IsNullOrWhiteSpace(ImageName))
return null; return null;
// normalise legacy back-slashes so paths work cross-platform
string name = ImageName.Replace('\\', '/').Trim(); string name = ImageName.Replace('\\', '/').Trim();
string scnDir = System.IO.Path.GetDirectoryName(Path) ?? "."; // e.g. scenery/ string scnDir = System.IO.Path.GetDirectoryName(Path) ?? ".";
string root = System.IO.Path.GetDirectoryName(scnDir) ?? "."; // MaSzyna root string root = System.IO.Path.GetDirectoryName(scnDir) ?? ".";
// probe the usual places, first hit wins
var candidates = new List<string> var candidates = new List<string>
{ {
name, // as given (cwd / absolute) name,
System.IO.Path.Combine(root, name), // relative to MaSzyna root System.IO.Path.Combine(root, name),
System.IO.Path.Combine(scnDir, name), // next to the .scn System.IO.Path.Combine(scnDir, name),
System.IO.Path.Combine(scnDir, "images", name), // scenery/images/ System.IO.Path.Combine(scnDir, "images", name),
System.IO.Path.Combine(root, "scenery", "images", name), // scenery/images/ from root System.IO.Path.Combine(root, "scenery", "images", name),
}; };
foreach (string candidate in candidates) foreach (string candidate in candidates)

View File

@@ -9,163 +9,126 @@ namespace StarterNG.Classes;
public enum BatteryDefault { Default = 0, AlwaysOff = 1, AlwaysOn = 2 } public enum BatteryDefault { Default = 0, AlwaysOff = 1, AlwaysOn = 2 }
/// <summary>
/// Application settings backing the Settings view, persisted to the simulator's
/// <c>eu07.ini</c>.
///
/// <para><b>Where it loads/saves.</b> The active file lives next to the
/// simulator's own config: <c>%APPDATA%\MaSzyna\eu07.ini</c> on Windows or
/// <c>~/.config/MaSzyna/eu07.ini</c> elsewhere. If that file does not exist yet,
/// defaults are read from an <c>eu07.ini</c> in the launcher's working directory
/// (the same fallback the simulator uses). Saving always writes to the per-user
/// path.</para>
///
/// <para><b>Unknown keys are kept.</b> The whole file is round-tripped through
/// <see cref="ConfigFile"/>; only the keys the UI actually edits are rewritten,
/// so any extra settings the launcher does not model survive a save.</para>
///
/// <para>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.</para>
/// </summary>
public sealed class Settings public sealed class Settings
{ {
public static Settings Instance { get; } = new(); public static Settings Instance { get; } = new();
/// <summary>
/// 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).
/// </summary>
public Action? CaptureFromUi { get; set; } public Action? CaptureFromUi { get; set; }
/// <summary>True if the loaded config explicitly contained a <c>lang</c> entry.</summary>
public bool LanguageWasSet { get; private set; } public bool LanguageWasSet { get; private set; }
// ── General ─────────────────────────────────────────────────────────── public string Language = "English";
public string Language = "English"; // lang (pl/en) public bool Fullscreen;
public bool Fullscreen; // fullscreen public bool PauseWhenInactive = true;
public bool PauseWhenInactive = true; // inactivepause public bool PauseOnStart;
public bool PauseOnStart; // pause public int CursorSensitivity = 2;
public int CursorSensitivity = 2; // |mousescale x| public bool InvertMouseHorizontal;
public bool InvertMouseHorizontal; // mousescale x < 0 public bool InvertMouseVertical;
public bool InvertMouseVertical; // mousescale y < 0
// ── Communication ───────────────────────────────────────────────────── public bool IgnoreGamepad;
public bool IgnoreGamepad; // input.gamepad (inverted) public int FeedbackMode = 1;
public int FeedbackMode = 1; // feedbackmode (0-5) public int FeedbackPort = 888;
public int FeedbackPort = 888; // feedbackport public bool UartEnabled;
public bool UartEnabled; // uart presence public string UartPort = "";
public string UartPort = ""; // uart public string UartTune = "";
public string UartTune = ""; // uarttune public bool UartDebug;
public bool UartDebug; // uartdebug public bool UartMain, UartScnd, UartTrain, UartLocal, UartRadioVolume, UartRadioChannel;
public bool UartMain, UartScnd, UartTrain, UartLocal, UartRadioVolume, UartRadioChannel; // uartfeature tokens
// ── Other ───────────────────────────────────────────────────────────── public bool SelectExeAutomatically = true;
public bool SelectExeAutomatically = true; // starter.exe.auto (launcher-only) public string ExecutablePath = "eu07.exe";
public string ExecutablePath = "eu07.exe"; // starter.exe.path (launcher-only) public bool DebugMode;
public bool DebugMode; // debugmode public bool VirtualShunting = true;
public bool VirtualShunting = true; // ai.trainman public bool LogMissingVehicleFiles;
public bool LogMissingVehicleFiles; // starter.logmissingvehicles → bledy.txt dump
// ── Graphics ────────────────────────────────────────────────────────── public int RenderEngine;
public int RenderEngine; // gfxrenderer (index, see RenderEngines) public int Width = 1280;
public int Width = 1280; // width public int Height = 720;
public int Height = 720; // height public int BufferScalePercent = 100;
public int BufferScalePercent = 100; // starter.bufferscale → gfx.framebuffer.fidelity public int MaxTextureSize = 4096;
public int MaxTextureSize = 4096; // maxtexturesize public int MaxCabTextureSize = 4096;
public int MaxCabTextureSize = 4096; // maxcabtexturesize public int TextureFiltering = 8;
public int TextureFiltering = 8; // anisotropicfiltering (1/2/4/8/16) public int Multisampling = 2;
public int Multisampling = 2; // multisampling (0-3) public int DynamicLights = 7;
public int DynamicLights = 7; // dynamiclights (0-7) public double DrawRangeFactor = 3.0;
public double DrawRangeFactor = 3.0; // gfx.drawrange.factor.max public bool VSync;
public bool VSync; // vsync public bool Smoke = true;
public bool Smoke = true; // gfx.smoke public int SmokeFidelity = 1;
public int SmokeFidelity = 1; // gfx.smoke.fidelity (1-4) public bool ChromaticAberration = true;
public bool ChromaticAberration = true; // gfx.postfx.chromaticaberration.enabled public bool MotionBlur = true;
public bool MotionBlur = true; // gfx.postfx.motionblur.enabled public bool ExtraEffects = true;
public bool ExtraEffects = true; // gfx.extraeffects public bool EnvMap = true;
public bool EnvMap = true; // gfx.envmap.enabled public bool UseVbo = true;
public bool UseVbo = true; // usevbo public bool RenderShadows = true;
public bool RenderShadows = true; // shadows + gfx.shadowmap.enabled public int ReflectionsFramerate = 60;
public int ReflectionsFramerate = 60; // gfx.reflections.framerate public int ShadowMapResolution = 2048;
public int ShadowMapResolution = 2048; // shadowtune[0] public int ShadowProjectionRange = 250;
public int ShadowProjectionRange = 250; // shadowtune[1] public int CabShadowsRange;
public int CabShadowsRange; // gfx.shadows.cab.range public int ShadowRankCutoff = 3;
public int ShadowRankCutoff = 3; // gfx.shadow.rank.cutoff (NOTE) public int ReflectionsFidelity;
public int ReflectionsFidelity; // gfx.reflections.fidelity (0-2) public int FieldOfView = 45;
public int FieldOfView = 45; // fieldofview (15-75) NOTE: 'fovSlider' in XAML public bool PythonScreens = true;
public bool PythonScreens = true; // python.enabled public bool PythonThreadedUpload = true;
public bool PythonThreadedUpload = true; // python.threadedupload public bool FpsLimitEnabled;
public bool FpsLimitEnabled; // fpslimit (presence) public int FpsLimit = 30;
public int FpsLimit = 30; // fpslimit (1-100) public double ShadowAngleLimit = 0.2;
public double ShadowAngleLimit = 0.2; // gfx.shadow.angle.min (0.2-1.0) public bool FullscreenWindowed;
public bool FullscreenWindowed; // fullscreenwindowed public bool RenderAngleVulkan;
public bool RenderAngleVulkan; // gfx.angleplatform ("vulkan")
// ── Physics ─────────────────────────────────────────────────────────── public int SplineFidelity = 1;
public int SplineFidelity = 1; // splinefidelity (1-4) public bool FullPhysics = true;
public bool FullPhysics = true; // fullphysics public bool EnableTraction = true;
public bool EnableTraction = true; // enabletraction (pantograph can break) public bool LiveTraction = true;
public bool LiveTraction = true; // livetraction public bool PhysicsLog;
public bool PhysicsLog; // physicslog (speedometer tapes) public bool DebugLog = true;
public bool DebugLog = true; // debuglog (3 / 0) public bool MultipleLogs;
public bool MultipleLogs; // multiplelogs public bool DisplaySimulation = true;
public bool DisplaySimulation = true; // gfx.skiprendering (inverted) public bool CrashDamage = true;
public bool CrashDamage = true; // crashdamage public double Friction = 1.0;
public double Friction = 1.0; // friction (0.1-3.0) public double BrakeStep = 1.0;
public double BrakeStep = 1.0; // brakestep (0.4-3.0) public double BrakeSpeed = 3.0;
public double BrakeSpeed = 3.0; // brakespeed (0.4-3.0)
// ── Sound ───────────────────────────────────────────────────────────── public bool SoundEnabled = true;
public bool SoundEnabled = true; // soundenabled public int Volume = 50;
public int Volume = 50; // sound.volume (0-2 → 0-100) public int RadioVolume = 75;
public int RadioVolume = 75; // sound.volume.radio (0-1 → 0-100) public int VehiclesVolume = 100;
public int VehiclesVolume = 100; // sound.volume.vehicle public int PositionalVolume = 100;
public int PositionalVolume = 100; // sound.volume.positional public int AmbientVolume = 100;
public int AmbientVolume = 100; // sound.volume.ambient
// ── Advanced ────────────────────────────────────────────────────────── public bool CompressTextures = true;
public bool CompressTextures = true; // compresstex public bool ScaleSpeculars = true;
public bool ScaleSpeculars = true; // scalespeculars public bool UseGLES;
public bool UseGLES; // gfx.usegles public bool ShaderGamma;
public bool ShaderGamma; // gfx.shadergamma public bool ScreenMipmaps = true;
public bool ScreenMipmaps = true; // python.mipmaps public bool ExtendedModelConversion;
public bool ExtendedModelConversion; // convertmodels ("143"/"0") public bool GfxResourceMove;
public bool GfxResourceMove; // gfx.resource.move public bool GfxResourceSweep = true;
public bool GfxResourceSweep = true; // gfx.resource.sweep public int TrainPhysicsThreads;
public int TrainPhysicsThreads; // async.trainThreads public bool IgnoreIrrelevantTrains;
public bool IgnoreIrrelevantTrains; // starter.ignoreirrelevant (launcher-only)
// ── Starter (launcher-only, stored under starter.* keys) ─────────────── public bool AutoCloseStarter;
public bool AutoCloseStarter; // starter.autoclose public bool LargeThumbnails;
public bool LargeThumbnails; // starter.largethumbnails public bool AutoExpandSceneryTree;
public bool AutoExpandSceneryTree; // starter.expandtree public BatteryDefault BatteryDefault = BatteryDefault.Default;
public BatteryDefault BatteryDefault = BatteryDefault.Default; // starter.batterydefault (launcher-only)
// List filters (launcher-only). Default on. public bool ShowAiVehicles = true;
public bool ShowAiVehicles = true; // starter.show.ai (show //$o "-" consists) public bool DrivableOnly = true;
public bool DrivableOnly = true; // starter.drivableonly (hide //$decor consists) public bool ShowArchivalSceneries = true;
public bool ShowArchivalSceneries = true; // starter.show.archival public bool HideArchivalVehicles = true;
public bool HideArchivalVehicles = true; // starter.hide.archivalvehicles
/// <summary>Last selected scenery file name (without path), restored on next launch.</summary> public string LastScenery = "";
public string LastScenery = ""; // starter.last.scenery
/// <summary>gfxrenderer tokens in the order shown by the render-engine combo.</summary>
public static readonly string[] RenderEngines = public static readonly string[] RenderEngines =
{ "full", "legacy", "simpleshader", "simple", "off", "experimental" }; { "full", "legacy", "simpleshader", "simple", "off", "experimental" };
/// <summary>Anisotropic-filtering steps for the texture-quality slider (1-5).</summary>
public static readonly int[] AnisotropySteps = { 1, 2, 4, 8, 16 }; public static readonly int[] AnisotropySteps = { 1, 2, 4, 8, 16 };
// Tokens of mousescale / shadowtune that the UI does not edit but must keep.
private double _mouseScaleYMagnitude = 0.2; private double _mouseScaleYMagnitude = 0.2;
private string[] _shadowTuneExtra = { "400", "300" }; // tokens [2], [3] private string[] _shadowTuneExtra = { "400", "300" };
private ConfigFile _config = new(); private ConfigFile _config = new();
private string _savePath = string.Empty; private string _savePath = string.Empty;
private DateTime _configAgeUtc = DateTime.MinValue; private DateTime _configAgeUtc = DateTime.MinValue;
/// <summary>Resolves the per-user eu07.ini path for the current OS.</summary>
public static string UserConfigPath() public static string UserConfigPath()
{ {
if (OperatingSystem.IsWindows()) if (OperatingSystem.IsWindows())
@@ -186,24 +149,16 @@ public sealed class Settings
if (!string.IsNullOrEmpty(home)) if (!string.IsNullOrEmpty(home))
return Path.Combine(home, ".config", "MaSzyna", "eu07.ini"); return Path.Combine(home, ".config", "MaSzyna", "eu07.ini");
} }
// Last resort: alongside the launcher.
return Path.Combine(AppContext.BaseDirectory, "eu07.ini"); return Path.Combine(AppContext.BaseDirectory, "eu07.ini");
} }
/// <summary>Default config shipped in the launcher's working directory.</summary>
private static string DefaultConfigPath() => private static string DefaultConfigPath() =>
Path.Combine(Directory.GetCurrentDirectory(), "eu07.ini"); Path.Combine(Directory.GetCurrentDirectory(), "eu07.ini");
/// <summary>Directory holding the per-user eu07.ini, for locating launcher-only data alongside it.</summary>
public static string UserConfigDirectory() => public static string UserConfigDirectory() =>
Path.GetDirectoryName(UserConfigPath()) ?? AppContext.BaseDirectory; Path.GetDirectoryName(UserConfigPath()) ?? AppContext.BaseDirectory;
/// <summary>
/// The simulator executable to launch. With auto-selection on, the working
/// directory is scanned for an <c>eu07*</c> binary - <c>eu07</c> on Linux/macOS,
/// <c>eu07.exe</c> on Windows - so the same procedure works on every OS. With it
/// off, the explicitly chosen path is used.
/// </summary>
public string ResolveExecutable() public string ResolveExecutable()
{ {
if (!SelectExeAutomatically && !string.IsNullOrWhiteSpace(ExecutablePath)) if (!SelectExeAutomatically && !string.IsNullOrWhiteSpace(ExecutablePath))
@@ -219,23 +174,18 @@ public sealed class Settings
continue; continue;
string file = Path.GetFileName(path); string file = Path.GetFileName(path);
if (string.Equals(file, canonical, StringComparison.OrdinalIgnoreCase)) if (string.Equals(file, canonical, StringComparison.OrdinalIgnoreCase))
return path; // exact canonical name wins return path;
if (best == null || file.Length < Path.GetFileName(best).Length) if (best == null || file.Length < Path.GetFileName(best).Length)
best = path; // otherwise prefer the shortest eu07* name best = path;
} }
if (best != null) if (best != null)
return best; return best;
} }
catch { /* fall through to the canonical name */ } catch { }
return canonical; return canonical;
} }
/// <summary>
/// Scans <paramref name="directory"/> (defaults to the working directory) for
/// plausible <c>eu07*</c> launcher binaries, newest first, for populating the
/// manual-selection dropdown. Does not affect <see cref="ResolveExecutable"/>.
/// </summary>
public static List<string> ListCandidateExecutables(string? directory = null) public static List<string> ListCandidateExecutables(string? directory = null)
{ {
directory ??= Directory.GetCurrentDirectory(); directory ??= Directory.GetCurrentDirectory();
@@ -250,8 +200,6 @@ public sealed class Settings
catch { return new List<string>(); } catch { return new List<string>(); }
} }
// Filters the eu07* matches down to plausible launcher binaries, skipping data
// and config files (eu07.ini, eu07.log, libraries, …).
private static bool IsExecutableCandidate(string path) private static bool IsExecutableCandidate(string path)
{ {
string ext = Path.GetExtension(path).ToLowerInvariant(); string ext = Path.GetExtension(path).ToLowerInvariant();
@@ -260,9 +208,7 @@ public sealed class Settings
return false; return false;
if (OperatingSystem.IsWindows()) if (OperatingSystem.IsWindows())
return ext == ".exe"; return ext == ".exe";
// Linux / macOS: no extension (eu07), a known binary suffix, or .exe
// (some builds - notably this project's own build-macos.sh - ship the
// macOS binary under the historical "eu07.exe" name).
return ext.Length == 0 || ext is ".exe" or ".x86_64" or ".run" or ".appimage"; return ext.Length == 0 || ext is ".exe" or ".x86_64" or ".run" or ".appimage";
} }
@@ -270,7 +216,7 @@ public sealed class Settings
private static Encoding ResolveEncoding() private static Encoding ResolveEncoding()
{ {
// eu07.ini comments use Polish diacritics in Windows-1250; preserve them.
try try
{ {
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
@@ -282,7 +228,6 @@ public sealed class Settings
} }
} }
/// <summary>Loads settings from the per-user file, or the working-dir default.</summary>
public void Load() public void Load()
{ {
_savePath = UserConfigPath(); _savePath = UserConfigPath();
@@ -290,14 +235,12 @@ public sealed class Settings
LoadFrom(source); LoadFrom(source);
} }
/// <summary>Captures the latest UI values (if the view is open) and saves.</summary>
public void CaptureAndSave() public void CaptureAndSave()
{ {
CaptureFromUi?.Invoke(); CaptureFromUi?.Invoke();
Save(); Save();
} }
/// <summary>Loads settings from an arbitrary path without changing where <see cref="Save"/> writes.</summary>
public void LoadFrom(string path) public void LoadFrom(string path)
{ {
try try
@@ -315,7 +258,6 @@ public sealed class Settings
TouchConfigAge(); TouchConfigAge();
} }
/// <summary>Persists settings to the per-user eu07.ini, keeping unknown keys.</summary>
public void Save() public void Save()
{ {
if (string.IsNullOrEmpty(_savePath)) if (string.IsNullOrEmpty(_savePath))
@@ -324,7 +266,6 @@ public sealed class Settings
SaveTo(_savePath); SaveTo(_savePath);
} }
/// <summary>Writes settings to an arbitrary path without changing where <see cref="Save"/> writes.</summary>
public void SaveTo(string path) public void SaveTo(string path)
{ {
WriteToConfig(); WriteToConfig();
@@ -341,7 +282,7 @@ public sealed class Settings
} }
catch catch
{ {
// Could not write (permissions / path). Leave values in memory.
} }
} }
@@ -360,7 +301,6 @@ public sealed class Settings
} }
} }
/// <summary>Pascal CheckSettingsFile: true when eu07.ini is newer than last load/save.</summary>
public bool IsConfigNewerOnDisk() public bool IsConfigNewerOnDisk()
{ {
string path = string.IsNullOrEmpty(_savePath) ? UserConfigPath() : _savePath; string path = string.IsNullOrEmpty(_savePath) ? UserConfigPath() : _savePath;
@@ -376,7 +316,6 @@ public sealed class Settings
} }
} }
// Map BufferScalePercent → gfx.framebuffer.fidelity like Pascal's 720/1080/1440/Custom.
private void ApplyFramebufferFidelity() private void ApplyFramebufferFidelity()
{ {
int fidelity = BufferScalePercent switch int fidelity = BufferScalePercent switch
@@ -392,10 +331,9 @@ public sealed class Settings
_config.Remove("gfx.framebuffer.fidelity"); _config.Remove("gfx.framebuffer.fidelity");
} }
/// <summary>Copy the Reinhard GLSL into shaders/tonemapping.glsl when present.</summary>
public void ApplyHdrShader() public void ApplyHdrShader()
{ {
// Only meaningful for full / legacy / experimental renderers (Pascal).
if (RenderEngine is not (0 or 1 or 5)) if (RenderEngine is not (0 or 1 or 5))
return; return;
@@ -408,7 +346,7 @@ public sealed class Settings
Directory.CreateDirectory(destDir); Directory.CreateDirectory(destDir);
File.Copy(src, Path.Combine(destDir, "tonemapping.glsl"), overwrite: true); File.Copy(src, Path.Combine(destDir, "tonemapping.glsl"), overwrite: true);
} }
catch { /* missing shaders dir / permissions */ } catch { }
} }
private static string? FindBundled(string fileName) private static string? FindBundled(string fileName)
@@ -426,7 +364,6 @@ public sealed class Settings
return null; return null;
} }
/// <summary>Pascal chLogExt on exit: dump missing vehicle assets to starter/bledy.txt.</summary>
public void DumpMissingVehicleLog() public void DumpMissingVehicleLog()
{ {
if (!LogMissingVehicleFiles) return; if (!LogMissingVehicleFiles) return;
@@ -441,12 +378,10 @@ public sealed class Settings
catch { } catch { }
} }
// ── config → fields ───────────────────────────────────────────────────
private void ReadFromConfig() private void ReadFromConfig()
{ {
var c = _config; var c = _config;
// General
LanguageWasSet = c.Has("lang"); LanguageWasSet = c.Has("lang");
Language = c.GetString("lang", "en").ToLowerInvariant() == "pl" ? "Polski" : "English"; Language = c.GetString("lang", "en").ToLowerInvariant() == "pl" ? "Polski" : "English";
Fullscreen = c.GetBool("fullscreen", false); Fullscreen = c.GetBool("fullscreen", false);
@@ -465,7 +400,6 @@ public sealed class Settings
_mouseScaleYMagnitude = Math.Abs(my); _mouseScaleYMagnitude = Math.Abs(my);
} }
// Communication
IgnoreGamepad = !c.GetBool("input.gamepad", true); IgnoreGamepad = !c.GetBool("input.gamepad", true);
FeedbackMode = Clamp(c.GetInt("feedbackmode", 1), 0, 5); FeedbackMode = Clamp(c.GetInt("feedbackmode", 1), 0, 5);
FeedbackPort = c.GetInt("feedbackport", 888); FeedbackPort = c.GetInt("feedbackport", 888);
@@ -486,14 +420,12 @@ public sealed class Settings
UartRadioVolume = UartRadioChannel = false; UartRadioVolume = UartRadioChannel = false;
} }
// Other
SelectExeAutomatically = c.GetBool("starter.exe.auto", true); SelectExeAutomatically = c.GetBool("starter.exe.auto", true);
ExecutablePath = c.GetString("starter.exe.path", "eu07.exe"); ExecutablePath = c.GetString("starter.exe.path", "eu07.exe");
DebugMode = c.GetBool("debugmode", false); DebugMode = c.GetBool("debugmode", false);
VirtualShunting = c.GetBool("ai.trainman", true); VirtualShunting = c.GetBool("ai.trainman", true);
LogMissingVehicleFiles = c.GetBool("starter.logmissingvehicles", false); LogMissingVehicleFiles = c.GetBool("starter.logmissingvehicles", false);
// Graphics
RenderEngine = IndexOf(RenderEngines, c.GetString("gfxrenderer", "full"), 0); RenderEngine = IndexOf(RenderEngines, c.GetString("gfxrenderer", "full"), 0);
Width = c.GetInt("width", 1280); Width = c.GetInt("width", 1280);
Height = c.GetInt("height", 720); Height = c.GetInt("height", 720);
@@ -532,7 +464,6 @@ public sealed class Settings
FullscreenWindowed = c.GetBool("fullscreenwindowed", false); FullscreenWindowed = c.GetBool("fullscreenwindowed", false);
RenderAngleVulkan = string.Equals(c.GetString("gfx.angleplatform", ""), "vulkan", StringComparison.OrdinalIgnoreCase); RenderAngleVulkan = string.Equals(c.GetString("gfx.angleplatform", ""), "vulkan", StringComparison.OrdinalIgnoreCase);
// Physics
SplineFidelity = Clamp(c.GetInt("splinefidelity", 1), 1, 4); SplineFidelity = Clamp(c.GetInt("splinefidelity", 1), 1, 4);
FullPhysics = c.GetBool("fullphysics", true); FullPhysics = c.GetBool("fullphysics", true);
EnableTraction = c.GetBool("enabletraction", true); EnableTraction = c.GetBool("enabletraction", true);
@@ -546,7 +477,6 @@ public sealed class Settings
BrakeStep = c.GetDouble("brakestep", 1.0); BrakeStep = c.GetDouble("brakestep", 1.0);
BrakeSpeed = c.GetDouble("brakespeed", 3.0); BrakeSpeed = c.GetDouble("brakespeed", 3.0);
// Sound
SoundEnabled = c.GetBool("soundenabled", true); SoundEnabled = c.GetBool("soundenabled", true);
Volume = Clamp((int)Math.Round(c.GetDouble("sound.volume", 1.0) * 50), 1, 100); Volume = Clamp((int)Math.Round(c.GetDouble("sound.volume", 1.0) * 50), 1, 100);
RadioVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.radio", 0.75) * 100), 1, 100); RadioVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.radio", 0.75) * 100), 1, 100);
@@ -554,7 +484,6 @@ public sealed class Settings
PositionalVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.positional", 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", 1.0) * 100), 1, 100); AmbientVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.ambient", 1.0) * 100), 1, 100);
// Advanced
CompressTextures = c.GetBool("compresstex", true); CompressTextures = c.GetBool("compresstex", true);
ScaleSpeculars = c.GetBool("scalespeculars", true); ScaleSpeculars = c.GetBool("scalespeculars", true);
UseGLES = c.GetBool("gfx.usegles", false); UseGLES = c.GetBool("gfx.usegles", false);
@@ -566,7 +495,6 @@ public sealed class Settings
TrainPhysicsThreads = Clamp(c.GetInt("async.trainThreads", 0), 0, Environment.ProcessorCount); TrainPhysicsThreads = Clamp(c.GetInt("async.trainThreads", 0), 0, Environment.ProcessorCount);
IgnoreIrrelevantTrains = c.GetBool("starter.ignoreirrelevant", false); IgnoreIrrelevantTrains = c.GetBool("starter.ignoreirrelevant", false);
// Starter
AutoCloseStarter = c.GetBool("starter.autoclose", false); AutoCloseStarter = c.GetBool("starter.autoclose", false);
LargeThumbnails = c.GetBool("starter.largethumbnails", false); LargeThumbnails = c.GetBool("starter.largethumbnails", false);
AutoExpandSceneryTree = c.GetBool("starter.expandtree", false); AutoExpandSceneryTree = c.GetBool("starter.expandtree", false);
@@ -578,12 +506,10 @@ public sealed class Settings
LastScenery = c.GetString("starter.last.scenery", ""); LastScenery = c.GetString("starter.last.scenery", "");
} }
// ── fields → config ───────────────────────────────────────────────────
private void WriteToConfig() private void WriteToConfig()
{ {
var c = _config; var c = _config;
// General
c.Set("lang", Language == "Polski" ? "pl" : "en"); c.Set("lang", Language == "Polski" ? "pl" : "en");
c.SetBool("fullscreen", Fullscreen); c.SetBool("fullscreen", Fullscreen);
c.SetBool("inactivepause", PauseWhenInactive); c.SetBool("inactivepause", PauseWhenInactive);
@@ -595,7 +521,6 @@ public sealed class Settings
$"{mx.ToString("0.###", CultureInfo.InvariantCulture)} " + $"{mx.ToString("0.###", CultureInfo.InvariantCulture)} " +
$"{my.ToString("0.###", CultureInfo.InvariantCulture)}"); $"{my.ToString("0.###", CultureInfo.InvariantCulture)}");
// Communication
c.SetBool("input.gamepad", !IgnoreGamepad); c.SetBool("input.gamepad", !IgnoreGamepad);
c.SetInt("feedbackmode", FeedbackMode); c.SetInt("feedbackmode", FeedbackMode);
c.SetInt("feedbackport", FeedbackPort); c.SetInt("feedbackport", FeedbackPort);
@@ -611,14 +536,12 @@ public sealed class Settings
if (UartRadioChannel) sb.Append("radiochannel|"); if (UartRadioChannel) sb.Append("radiochannel|");
c.Set("uartfeature", sb.ToString()); c.Set("uartfeature", sb.ToString());
// Other
c.SetBool("starter.exe.auto", SelectExeAutomatically); c.SetBool("starter.exe.auto", SelectExeAutomatically);
c.Set("starter.exe.path", ExecutablePath); c.Set("starter.exe.path", ExecutablePath);
c.SetBool("debugmode", DebugMode); c.SetBool("debugmode", DebugMode);
c.SetBool("ai.trainman", VirtualShunting); c.SetBool("ai.trainman", VirtualShunting);
c.SetBool("starter.logmissingvehicles", LogMissingVehicleFiles); c.SetBool("starter.logmissingvehicles", LogMissingVehicleFiles);
// Graphics
c.Set("gfxrenderer", RenderEngines[Clamp(RenderEngine, 0, RenderEngines.Length - 1)]); c.Set("gfxrenderer", RenderEngines[Clamp(RenderEngine, 0, RenderEngines.Length - 1)]);
c.SetInt("width", Width); c.SetInt("width", Width);
c.SetInt("height", Height); c.SetInt("height", Height);
@@ -654,7 +577,6 @@ public sealed class Settings
c.SetBool("fullscreenwindowed", FullscreenWindowed); c.SetBool("fullscreenwindowed", FullscreenWindowed);
if (RenderAngleVulkan) c.Set("gfx.angleplatform", "vulkan"); else c.Remove("gfx.angleplatform"); if (RenderAngleVulkan) c.Set("gfx.angleplatform", "vulkan"); else c.Remove("gfx.angleplatform");
// Physics
c.SetInt("splinefidelity", SplineFidelity); c.SetInt("splinefidelity", SplineFidelity);
c.SetBool("fullphysics", FullPhysics); c.SetBool("fullphysics", FullPhysics);
c.SetBool("enabletraction", EnableTraction); c.SetBool("enabletraction", EnableTraction);
@@ -668,7 +590,6 @@ public sealed class Settings
c.SetDouble("brakestep", BrakeStep); c.SetDouble("brakestep", BrakeStep);
c.SetDouble("brakespeed", BrakeSpeed); c.SetDouble("brakespeed", BrakeSpeed);
// Sound
c.SetBool("soundenabled", SoundEnabled); c.SetBool("soundenabled", SoundEnabled);
c.SetDouble("sound.volume", Volume / 50.0); c.SetDouble("sound.volume", Volume / 50.0);
c.SetDouble("sound.volume.radio", RadioVolume / 100.0); c.SetDouble("sound.volume.radio", RadioVolume / 100.0);
@@ -676,7 +597,6 @@ public sealed class Settings
c.SetDouble("sound.volume.positional", PositionalVolume / 100.0); c.SetDouble("sound.volume.positional", PositionalVolume / 100.0);
c.SetDouble("sound.volume.ambient", AmbientVolume / 100.0); c.SetDouble("sound.volume.ambient", AmbientVolume / 100.0);
// Advanced
c.SetBool("compresstex", CompressTextures); c.SetBool("compresstex", CompressTextures);
c.SetBool("scalespeculars", ScaleSpeculars); c.SetBool("scalespeculars", ScaleSpeculars);
c.SetBool("gfx.usegles", UseGLES); c.SetBool("gfx.usegles", UseGLES);
@@ -688,7 +608,6 @@ public sealed class Settings
c.SetInt("async.trainThreads", TrainPhysicsThreads); c.SetInt("async.trainThreads", TrainPhysicsThreads);
c.SetBool("starter.ignoreirrelevant", IgnoreIrrelevantTrains); c.SetBool("starter.ignoreirrelevant", IgnoreIrrelevantTrains);
// Starter
c.SetBool("starter.autoclose", AutoCloseStarter); c.SetBool("starter.autoclose", AutoCloseStarter);
c.SetBool("starter.largethumbnails", LargeThumbnails); c.SetBool("starter.largethumbnails", LargeThumbnails);
c.SetBool("starter.expandtree", AutoExpandSceneryTree); c.SetBool("starter.expandtree", AutoExpandSceneryTree);
@@ -703,7 +622,6 @@ public sealed class Settings
c.Remove("starter.last.scenery"); c.Remove("starter.last.scenery");
} }
// ── small helpers ──────────────────────────────────────────────────────
private static int Clamp(int v, int lo, int hi) => v < lo ? lo : (v > hi ? hi : v); 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) => private static int ParseInt(string s, int fallback) =>

View File

@@ -25,19 +25,14 @@ public class Trainset
public string Description; public string Description;
public List<Dynamic> Vehicles; public List<Dynamic> Vehicles;
/// <summary>Original .scn text of this trainset block (incl. endtrainset).</summary>
public string RawEntry; public string RawEntry;
/// <summary>True if the block parsed cleanly; false blocks are exported verbatim.</summary>
public bool Parsed; public bool Parsed;
/// <summary>True when the block carries the //$decor flag (decoration, not drivable).</summary>
public bool Decor; public bool Decor;
/// <summary>Loading-screen logo key from <c>//$il</c>.</summary>
public string? Logo; public string? Logo;
/// <summary>Optional trainset mini from <c>//$it</c>.</summary>
public string? Mini; public string? Mini;
public Trainset(string trainsetEntry) public Trainset(string trainsetEntry)
@@ -65,8 +60,6 @@ public class Trainset
.Where(t => !t.StartsWith("//") && !t.StartsWith("/*")) .Where(t => !t.StartsWith("//") && !t.StartsWith("/*"))
.ToList(); .ToList();
// handle specific descriptions
// //$o
var match = Regex.Match( var match = Regex.Match(
trainsetEntry, trainsetEntry,
@"^\s*//\$o\s*(.*)$", @"^\s*//\$o\s*(.*)$",
@@ -83,14 +76,11 @@ public class Trainset
if (mini.Success) if (mini.Success)
Mini = mini.Groups[1].Value.Trim(); Mini = mini.Groups[1].Value.Trim();
// parse trainset
for (int i = 0; i < tokens.Count; i++) for (int i = 0; i < tokens.Count; i++)
{ {
if (tokens[i] == "endtrainset") if (tokens[i] == "endtrainset")
break; break;
// trainset properties
if (tokens[i] == "trainset") if (tokens[i] == "trainset")
{ {
this.Name = tokens[++i]; this.Name = tokens[++i];
@@ -101,23 +91,21 @@ public class Trainset
continue; continue;
} }
// skip entire assignments block
if (tokens[i] == "assignments") if (tokens[i] == "assignments")
{ {
while (i < tokens.Count && tokens[i] != "endassignment") while (i < tokens.Count && tokens[i] != "endassignment")
i++; i++;
i++; // jump over endassignment i++;
continue; continue;
} }
// load vehicles
if (tokens[i] == "node") if (tokens[i] == "node")
{ {
Dynamic nodeDynamic = new Dynamic(); Dynamic nodeDynamic = new Dynamic();
nodeDynamic.RangeMax = float.Parse(tokens[++i], CultureInfo.InvariantCulture); nodeDynamic.RangeMax = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
nodeDynamic.RangeMin = float.Parse(tokens[++i], CultureInfo.InvariantCulture); nodeDynamic.RangeMin = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
nodeDynamic.Name = tokens[++i]; nodeDynamic.Name = tokens[++i];
i++; // dynamic keyword i++;
nodeDynamic.DataFolder = tokens[++i]; nodeDynamic.DataFolder = tokens[++i];
nodeDynamic.SkinFile = tokens[++i]; nodeDynamic.SkinFile = tokens[++i];
nodeDynamic.MmdFile = tokens[++i]; nodeDynamic.MmdFile = tokens[++i];
@@ -131,37 +119,26 @@ public class Trainset
_ => eDriverType.Nobody _ => eDriverType.Nobody
}; };
// couplingdata: numeric bit-mask plus optional ".B/.W/.T" parameter
// codes (brake / wheel / thermo settings) kept verbatim.
nodeDynamic.Coupling = Coupling.Parse(tokens[++i]); nodeDynamic.Coupling = Coupling.Parse(tokens[++i]);
// Everything before enddynamic is the optional, positional tail
// documented for node::dynamic:
// [Lx] velocity [loadcount [loadtype]] [destination [destination]]
var trailing = new List<string>(); var trailing = new List<string>();
while (i + 1 < tokens.Count && tokens[i + 1] != "enddynamic") while (i + 1 < tokens.Count && tokens[i + 1] != "enddynamic")
trailing.Add(tokens[++i]); trailing.Add(tokens[++i]);
nodeDynamic.ReadTrailing(trailing); nodeDynamic.ReadTrailing(trailing);
i++; // jump over enddynamic i++;
Vehicles.Add(nodeDynamic); Vehicles.Add(nodeDynamic);
} }
} }
} }
/// <summary>
/// The .scn text for this trainset for export. Unparsed blocks are written
/// back verbatim; parsed blocks are regenerated from the current vehicles.
/// </summary>
public string ToSceneryEntry() => public string ToSceneryEntry() =>
Parsed ? GetTrainsetEntry() + "endtrainset\n" : RawEntry; Parsed ? GetTrainsetEntry() + "endtrainset\n" : RawEntry;
public string GetTrainsetEntry() public string GetTrainsetEntry()
{ {
// Starter metadata comments (parsed for filters/UI). Pascal's launch rewrite
// omits them from $scn; we keep them so a regenerated block stays filterable
// if the template is re-opened or compared.
var sb = new StringBuilder(); var sb = new StringBuilder();
if (Decor) if (Decor)
sb.Append("//$decor\n"); sb.Append("//$decor\n");
@@ -206,7 +183,6 @@ public class Trainset
$"{driverType} {vehicle.Coupling}{vehicle.WriteTrailing()} enddynamic\n"; $"{driverType} {vehicle.Coupling}{vehicle.WriteTrailing()} enddynamic\n";
} }
return entry; return entry;
} }
} }
@@ -223,65 +199,38 @@ public class Dynamic
public float Offset; public float Offset;
public eDriverType DriverType; public eDriverType DriverType;
/// <summary>
/// Coupling with the next vehicle (the <c>couplingdata</c> field): the bit-mask
/// plus any ".B/.W/.T" parameter codes (brake / wheel / thermo settings).
/// </summary>
public Coupling Coupling = new(); public Coupling Coupling = new();
/// <summary>
/// Low byte of the coupling mask, exposed for the depot's per-bit editor.
/// Backed by <see cref="Coupling"/> so the two never diverge.
/// </summary>
public byte couplingData public byte couplingData
{ {
get => (byte)(Coupling.AbsFlags & 0xFF); get => (byte)(Coupling.AbsFlags & 0xFF);
set => Coupling.Flags = value; set => Coupling.Flags = value;
} }
// --- optional node::dynamic trailing parameters (all positional, all optional) ---
/// <summary>"Lx" MaxLoad override token (e.g. "L0"), or null when absent.</summary>
public string? MaxLoad; public string? MaxLoad;
/// <summary>Whether a per-vehicle velocity token is present / should be written.</summary>
public bool HasVelocity; public bool HasVelocity;
/// <summary>Per-vehicle starting velocity.</summary>
public float Velocity; public float Velocity;
/// <summary>Cargo amount (loadcount).</summary>
public int LoadCount; public int LoadCount;
/// <summary>Cargo type (loadtype) — only meaningful when <see cref="LoadCount"/> &gt; 0.</summary>
public string? LoadType; public string? LoadType;
/// <summary>Optional cargo destination tokens (rarely used; preserved verbatim).</summary>
public List<string> Destinations = new(); public List<string> Destinations = new();
// --- depot / consist-builder extras (not part of the .scn syntax) ---
/// <summary>Explicit miniature name (from the vehicle database). Falls back to SkinFile.</summary>
public string? MiniName; public string? MiniName;
/// <summary>Whether the vehicle is visually reversed in the consist.</summary>
public bool Flipped; public bool Flipped;
/// <summary>
/// Parses the optional positional parameters that follow couplingdata:
/// <c>[Lx] velocity [loadcount [loadtype]] [destination [destination]]</c>.
/// See https://wiki.eu07.pl/index.php?title=Obiekt_node::dynamic
/// </summary>
internal void ReadTrailing(List<string> t) internal void ReadTrailing(List<string> t)
{ {
int p = 0; int p = 0;
// Lx — MaxLoad override (e.g. "L0"); only when it genuinely looks like one.
if (p < t.Count && (t[p][0] == 'L' || t[p][0] == 'l') if (p < t.Count && (t[p][0] == 'L' || t[p][0] == 'l')
&& t[p].Length > 1 && char.IsDigit(t[p][1])) && t[p].Length > 1 && char.IsDigit(t[p][1]))
MaxLoad = t[p++]; MaxLoad = t[p++];
// velocity — the first plain number after the coupling
if (p < t.Count && float.TryParse(t[p], NumberStyles.Float, if (p < t.Count && float.TryParse(t[p], NumberStyles.Float,
CultureInfo.InvariantCulture, out float v)) CultureInfo.InvariantCulture, out float v))
{ {
@@ -290,7 +239,6 @@ public class Dynamic
p++; p++;
} }
// loadcount [loadtype] — loadtype is present only when loadcount > 0
if (p < t.Count && int.TryParse(t[p], NumberStyles.Integer, if (p < t.Count && int.TryParse(t[p], NumberStyles.Integer,
CultureInfo.InvariantCulture, out int lc)) CultureInfo.InvariantCulture, out int lc))
{ {
@@ -300,16 +248,10 @@ public class Dynamic
LoadType = t[p++]; LoadType = t[p++];
} }
// remaining tokens: optional destinations, kept verbatim
while (p < t.Count) while (p < t.Count)
Destinations.Add(t[p++]); Destinations.Add(t[p++]);
} }
/// <summary>
/// Regenerates the trailing parameter string (each present token prefixed with
/// a space), the inverse of <see cref="ReadTrailing"/>. A loadcount of 0 is
/// omitted, matching the canonical trainset entries the simulator expects.
/// </summary>
internal string WriteTrailing() internal string WriteTrailing()
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
@@ -328,7 +270,6 @@ public class Dynamic
return sb.ToString(); return sb.ToString();
} }
/// <summary>Deep copy, used when importing a trainset into the editable consist.</summary>
public Dynamic Clone() => new Dynamic public Dynamic Clone() => new Dynamic
{ {
RangeMax = RangeMax, RangeMax = RangeMax,
@@ -351,7 +292,6 @@ public class Dynamic
Flipped = Flipped Flipped = Flipped
}; };
/// <summary>Pascal <c>PrepareNode(..., TrainSet=False)</c>.</summary>
public string ToLooseNode() public string ToLooseNode()
{ {
string driver = DriverType switch string driver = DriverType switch
@@ -372,7 +312,6 @@ public class Dynamic
} }
} }
/// <summary>One entry in the depot consist: a single vehicle or a locked multi-car unit.</summary>
public sealed class ConsistItem public sealed class ConsistItem
{ {
public List<Dynamic> Cars { get; set; } = new(); public List<Dynamic> Cars { get; set; } = new();
@@ -381,39 +320,24 @@ public sealed class ConsistItem
public eDriverType Driver { get; set; } = eDriverType.Nobody; public eDriverType Driver { get; set; } = eDriverType.Nobody;
} }
/// <summary>
/// The <c>couplingdata</c> value joining a vehicle to the next one: a bit-mask of
/// the active coupling types, optionally followed by ".B/.W/.T" parameter codes.
/// A negative mask means the coupling is locked (cannot be uncoupled in-sim); the
/// simulator takes its absolute value before testing the individual bits.
/// See https://wiki.eu07.pl/index.php?title=Wpisy_hamulca_dla_pojazdow
/// </summary>
public sealed class Coupling public sealed class Coupling
{ {
// Mask bit values, per the wiki.
public const int Mechanical = 1; // mechanical link
public const int BrakePipe = 2; // pneumatic 5 atm (brakes)
public const int ControlMU = 4; // multiple-unit control
public const int HighVoltage = 8; // high voltage
public const int Gangway = 16; // passage between vehicles
public const int AuxPneumatic = 32; // auxiliary pneumatic 8 atm
public const int Heating = 64; // heating
public const int WorkshopLock = 128; // workshop coupling (uncouple lock)
/// <summary>Signed mask exactly as written; negative = locked.</summary> public const int Mechanical = 1;
public const int BrakePipe = 2;
public const int ControlMU = 4;
public const int HighVoltage = 8;
public const int Gangway = 16;
public const int AuxPneumatic = 32;
public const int Heating = 64;
public const int WorkshopLock = 128;
public int Flags; public int Flags;
/// <summary>
/// Textual parameter codes that follow the mask after a '.', in order
/// (e.g. "BR", "WH25F5"). Preserved verbatim so brake / wheel / thermo
/// settings survive a round-trip even when the launcher does not edit them.
/// </summary>
public List<string> Parameters = new(); public List<string> Parameters = new();
/// <summary>Mask with the lock sign removed (what the simulator tests bits on).</summary>
public int AbsFlags => Flags < 0 ? -Flags : Flags; public int AbsFlags => Flags < 0 ? -Flags : Flags;
/// <summary>True when the coupling is marked permanent (negative mask).</summary>
public bool Locked public bool Locked
{ {
get => Flags < 0; get => Flags < 0;
@@ -422,7 +346,6 @@ public sealed class Coupling
public bool Has(int bit) => (AbsFlags & bit) != 0; public bool Has(int bit) => (AbsFlags & bit) != 0;
/// <summary>Sets or clears a mask bit, preserving the lock sign.</summary>
public void Set(int bit, bool on) public void Set(int bit, bool on)
{ {
int abs = on ? (AbsFlags | bit) : (AbsFlags & ~bit); int abs = on ? (AbsFlags | bit) : (AbsFlags & ~bit);
@@ -444,12 +367,10 @@ public sealed class Coupling
return c; return c;
} }
/// <summary>The brake-rack setting carried in the "B" parameter, or null.</summary>
public BrakeSetting? GetBrake() => public BrakeSetting? GetBrake() =>
BrakeSetting.FromParameter( BrakeSetting.FromParameter(
Parameters.FirstOrDefault(p => p.StartsWith("B", StringComparison.Ordinal))); Parameters.FirstOrDefault(p => p.StartsWith("B", StringComparison.Ordinal)));
/// <summary>Replaces (or clears, when null) the "B" brake parameter.</summary>
public void SetBrake(BrakeSetting? brake) public void SetBrake(BrakeSetting? brake)
{ {
Parameters.RemoveAll(p => p.StartsWith("B", StringComparison.Ordinal)); Parameters.RemoveAll(p => p.StartsWith("B", StringComparison.Ordinal));
@@ -457,12 +378,10 @@ public sealed class Coupling
Parameters.Insert(0, brake.ToParameter()); Parameters.Insert(0, brake.ToParameter());
} }
/// <summary>The wheel/damage setting carried in the "W" parameter, or null.</summary>
public WheelSettings? GetWheels() => public WheelSettings? GetWheels() =>
WheelSettings.FromParameter( WheelSettings.FromParameter(
Parameters.FirstOrDefault(p => p.StartsWith("W", StringComparison.Ordinal))); Parameters.FirstOrDefault(p => p.StartsWith("W", StringComparison.Ordinal)));
/// <summary>Replaces (or clears, when empty/null) the "W" wheel parameter.</summary>
public void SetWheels(WheelSettings? wheels) public void SetWheels(WheelSettings? wheels)
{ {
Parameters.RemoveAll(p => p.StartsWith("W", StringComparison.Ordinal)); Parameters.RemoveAll(p => p.StartsWith("W", StringComparison.Ordinal));
@@ -470,7 +389,6 @@ public sealed class Coupling
Parameters.Add(wheels.ToParameter()); Parameters.Add(wheels.ToParameter());
} }
/// <summary>Coolant at ambient temperature (<c>.TA</c>), like Pascal ThermoDynamic.</summary>
public bool ThermoDynamic public bool ThermoDynamic
{ {
get => Parameters.Exists(p => p.Equals("TA", StringComparison.OrdinalIgnoreCase)); get => Parameters.Exists(p => p.Equals("TA", StringComparison.OrdinalIgnoreCase));
@@ -487,7 +405,6 @@ public sealed class Coupling
Parameters = new List<string>(Parameters) Parameters = new List<string>(Parameters)
}; };
/// <summary>Serialises back to "&lt;flags&gt;[.param[.param…]]".</summary>
public override string ToString() public override string ToString()
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
@@ -498,23 +415,14 @@ public sealed class Coupling
} }
} }
/// <summary>
/// A vehicle's brake-rack setting, carried in the "B" parameter of its coupling
/// field: <c>B&lt;mode&gt;[&lt;load&gt;][&lt;switch&gt;]</c> (e.g. "BP", "BRA", "BG1").
/// See https://wiki.eu07.pl/index.php?title=Wpisy_hamulca_dla_pojazdow
/// </summary>
public sealed class BrakeSetting public sealed class BrakeSetting
{ {
/// <summary>Brake modes, longest first so "R+Mg" wins over "R" while parsing.</summary>
public static readonly string[] Modes = { "R+Mg", "G", "P", "R", "Q", "O", "A" }; public static readonly string[] Modes = { "R+Mg", "G", "P", "R", "Q", "O", "A" };
/// <summary>Brake mode: G (freight), P (passenger), R (express), R+Mg, Q, O (off), A (auto).</summary>
public string Mode = "P"; public string Mode = "P";
/// <summary>Load adaptation: T (empty), H (medium), F (loaded), A (auto), or null.</summary>
public string? Load; public string? Load;
/// <summary>Brake on/off switch: 0 (off), 1 (off ~10%), A (on), or null.</summary>
public string? Switch; public string? Switch;
public static BrakeSetting? FromParameter(string? param) public static BrakeSetting? FromParameter(string? param)
@@ -539,23 +447,14 @@ public sealed class BrakeSetting
public string ToParameter() => "B" + Mode + (Load ?? "") + (Switch ?? ""); public string ToParameter() => "B" + Mode + (Load ?? "") + (Switch ?? "");
} }
/// <summary>
/// Wheel-damage settings carried in the "W" parameter of a vehicle's coupling
/// field: <c>W[H&lt;sway%&gt;][F&lt;flat mm&gt;][R&lt;random flat mm&gt;][P&lt;flat prob%&gt;]</c>
/// (e.g. "WH25F5R10P8"). See https://wiki.eu07.pl/index.php?title=Wpisy_hamulca_dla_pojazdow
/// </summary>
public sealed class WheelSettings public sealed class WheelSettings
{ {
/// <summary>H — sway ("wężykowanie") probability, %.</summary>
public int Sway; public int Sway;
/// <summary>F — flat spot ("podkucie") of this size, mm.</summary>
public int Flatness; public int Flatness;
/// <summary>R — additional random flat spot, 0..x mm.</summary>
public int FlatnessRand; public int FlatnessRand;
/// <summary>P — flat-spot probability, % (guaranteed when omitted).</summary>
public int FlatnessProb; public int FlatnessProb;
public bool IsEmpty => Sway <= 0 && Flatness <= 0 && FlatnessRand <= 0 && FlatnessProb <= 0; public bool IsEmpty => Sway <= 0 && Flatness <= 0 && FlatnessRand <= 0 && FlatnessProb <= 0;

View File

@@ -8,12 +8,6 @@ using System.Text.Json.Serialization.Metadata;
namespace StarterNG.Classes; namespace StarterNG.Classes;
// =====================================================================
// Vehicle JSON database (databases/vehicles/*.json)
// Format documented in vehicleEntryDoc.md (schema_version 1).
// =====================================================================
/// <summary>Root object of a single vehicle JSON file.</summary>
public class VehicleEntry public class VehicleEntry
{ {
[JsonPropertyName("uuid")] public string? Uuid { get; set; } [JsonPropertyName("uuid")] public string? Uuid { get; set; }
@@ -24,13 +18,11 @@ public class VehicleEntry
[JsonPropertyName("unknown")] public List<string> Unknown { get; set; } = new(); [JsonPropertyName("unknown")] public List<string> Unknown { get; set; } = new();
} }
/// <summary>Merged database root (databases/vehicles/vehicles.json).</summary>
public class VehicleEntryCollection public class VehicleEntryCollection
{ {
[JsonPropertyName("vehicles")] public List<VehicleEntry> Vehicles { get; set; } = new(); [JsonPropertyName("vehicles")] public List<VehicleEntry> Vehicles { get; set; } = new();
} }
/// <summary>Legacy header / vehicle category group.</summary>
public class VehicleGroup public class VehicleGroup
{ {
[JsonPropertyName("id")] public string Id { get; set; } = ""; [JsonPropertyName("id")] public string Id { get; set; } = "";
@@ -40,7 +32,6 @@ public class VehicleGroup
[JsonPropertyName("implicit")] public bool Implicit { get; set; } [JsonPropertyName("implicit")] public bool Implicit { get; set; }
} }
/// <summary>A single .mat skin entry.</summary>
public class VehicleTexture public class VehicleTexture
{ {
[JsonPropertyName("uuid")] public string? Uuid { get; set; } [JsonPropertyName("uuid")] public string? Uuid { get; set; }
@@ -54,24 +45,15 @@ public class VehicleTexture
[JsonPropertyName("aliases")] public List<VehicleAlias> Aliases { get; set; } = new(); [JsonPropertyName("aliases")] public List<VehicleAlias> Aliases { get; set; } = new();
[JsonPropertyName("meta")] public VehicleMeta? Meta { get; set; } [JsonPropertyName("meta")] public VehicleMeta? Meta { get; set; }
/// <summary>Full skin path = directory + skinfile.</summary>
[JsonIgnore] public string FullPath => Directory + Skinfile; [JsonIgnore] public string FullPath => Directory + Skinfile;
// ── cached lookups, populated once after load (VehicleDatabase.BuildTextureIndex) ──
// These mirror the depot's ClassOf / CategoryOf so the search/filter hot path
// never recomputes them (no per-keystroke dictionary lookups over every texture).
/// <summary>Group's "mini" (or this texture's mini_ref) — the vehicle class. Never null.</summary>
[JsonIgnore] public string ResolvedClass { get; internal set; } = ""; [JsonIgnore] public string ResolvedClass { get; internal set; } = "";
/// <summary>Group's "category" letter, or null when the group is unknown.</summary>
[JsonIgnore] public string? ResolvedCategory { get; internal set; } [JsonIgnore] public string? ResolvedCategory { get; internal set; }
/// <summary>True when the texture's group is marked archival (<c>archived</c> in JSON).</summary>
[JsonIgnore] public bool ResolvedArchived { get; internal set; } [JsonIgnore] public bool ResolvedArchived { get; internal set; }
} }
/// <summary>Alternate mapping from a malformed multi-= legacy line.</summary>
public class VehicleAlias public class VehicleAlias
{ {
[JsonPropertyName("model")] public string? Model { get; set; } [JsonPropertyName("model")] public string? Model { get; set; }
@@ -80,7 +62,6 @@ public class VehicleAlias
[JsonPropertyName("texture_mini")] public string? TextureMini { get; set; } [JsonPropertyName("texture_mini")] public string? TextureMini { get; set; }
} }
/// <summary>Parsed metadata from the legacy // comment section.</summary>
public class VehicleMeta public class VehicleMeta
{ {
[JsonPropertyName("raw")] public string? Raw { get; set; } [JsonPropertyName("raw")] public string? Raw { get; set; }
@@ -95,29 +76,18 @@ public class VehicleMeta
[JsonPropertyName("extra")] public List<string> Extra { get; set; } = new(); [JsonPropertyName("extra")] public List<string> Extra { get; set; } = new();
} }
/// <summary>
/// Aggregated, in-memory view over every vehicle JSON file found in
/// databases/vehicles/. Provides lookup helpers for the depot UI.
/// </summary>
public class VehicleDatabase public class VehicleDatabase
{ {
public List<VehicleTexture> Textures { get; } = new(); public List<VehicleTexture> Textures { get; } = new();
public Dictionary<string, VehicleGroup> GroupsById { get; } = new(); public Dictionary<string, VehicleGroup> GroupsById { get; } = new();
public List<VehicleSet> Sets { get; } = new(); public List<VehicleSet> Sets { get; } = new();
/// <summary>Texture-uuid -> automatic-consist set that contains it.</summary>
public Dictionary<string, VehicleSet> SetByTextureUuid { get; } = new(); public Dictionary<string, VehicleSet> SetByTextureUuid { get; } = new();
/// <summary>Texture-uuid -> texture (includes wrecks, so set refs resolve).</summary>
public Dictionary<string, VehicleTexture> TextureByUuid { get; } = new(); public Dictionary<string, VehicleTexture> TextureByUuid { get; } = new();
/// <summary>skinfile (without extension, lower-case) -> texture.</summary>
public Dictionary<string, VehicleTexture> TextureBySkin { get; } = new(); public Dictionary<string, VehicleTexture> TextureBySkin { get; } = new();
// System.Text.Json options matching the previous Newtonsoft leniency: property
// names matched case-insensitively, // and /* */ comments skipped, and trailing
// commas allowed. Unknown members are ignored by default. The source-generated
// VehicleJsonContext resolver keeps deserialization AOT/trim-safe (no reflection).
private static readonly JsonSerializerOptions JsonOpts = new() private static readonly JsonSerializerOptions JsonOpts = new()
{ {
PropertyNameCaseInsensitive = true, PropertyNameCaseInsensitive = true,
@@ -126,11 +96,6 @@ public class VehicleDatabase
TypeInfoResolver = VehicleJsonContext.Default, TypeInfoResolver = VehicleJsonContext.Default,
}; };
/// <summary>
/// Loads the database in one call. When a merged vehicles.json is present
/// it is used, otherwise every per-vehicle *.json file is read.
/// Never throws: unreadable files are skipped.
/// </summary>
public void Load(string directory = "databases/vehicles/") public void Load(string directory = "databases/vehicles/")
{ {
BeginLoad(); BeginLoad();
@@ -139,7 +104,6 @@ public class VehicleDatabase
EndLoad(); EndLoad();
} }
/// <summary>Resets the aggregate before an incremental load.</summary>
public void BeginLoad() public void BeginLoad()
{ {
Textures.Clear(); Textures.Clear();
@@ -150,17 +114,12 @@ public class VehicleDatabase
TextureBySkin.Clear(); TextureBySkin.Clear();
} }
/// <summary>Finalises an incremental load (builds lookup indexes).</summary>
public void EndLoad() public void EndLoad()
{ {
BuildSetIndex(); BuildSetIndex();
BuildTextureIndex(); BuildTextureIndex();
} }
/// <summary>
/// Files that make up the database: the merged vehicles.json if present,
/// otherwise every per-vehicle *.json. Used to drive load progress.
/// </summary>
public static List<string> EnumerateFiles(string directory = "databases/vehicles/") public static List<string> EnumerateFiles(string directory = "databases/vehicles/")
{ {
if (!Directory.Exists(directory)) if (!Directory.Exists(directory))
@@ -173,7 +132,6 @@ public class VehicleDatabase
return Directory.GetFiles(directory, "*.json").ToList(); return Directory.GetFiles(directory, "*.json").ToList();
} }
/// <summary>Ingests a single database file (merged or per-vehicle).</summary>
public void LoadFile(string file) public void LoadFile(string file)
{ {
if (string.Equals(Path.GetFileName(file), "vehicles.json", StringComparison.OrdinalIgnoreCase)) if (string.Equals(Path.GetFileName(file), "vehicles.json", StringComparison.OrdinalIgnoreCase))
@@ -195,7 +153,7 @@ public class VehicleDatabase
} }
catch catch
{ {
// ignore malformed merged database
} }
} }
@@ -211,7 +169,7 @@ public class VehicleDatabase
} }
catch catch
{ {
// ignore malformed single-vehicle file
} }
} }
@@ -225,13 +183,12 @@ public class VehicleDatabase
foreach (var texture in entry.Textures) foreach (var texture in entry.Textures)
{ {
// index every texture (wrecks too) so set / skin references resolve
if (!string.IsNullOrEmpty(texture.Uuid)) if (!string.IsNullOrEmpty(texture.Uuid))
TextureByUuid[texture.Uuid!] = texture; TextureByUuid[texture.Uuid!] = texture;
if (!string.IsNullOrEmpty(texture.Skinfile)) if (!string.IsNullOrEmpty(texture.Skinfile))
TextureBySkin.TryAdd(Path.GetFileNameWithoutExtension(texture.Skinfile).ToLowerInvariant(), texture); TextureBySkin.TryAdd(Path.GetFileNameWithoutExtension(texture.Skinfile).ToLowerInvariant(), texture);
// skip wrecks from the standard browser list
if (texture.Wreck) continue; if (texture.Wreck) continue;
Textures.Add(texture); Textures.Add(texture);
} }
@@ -239,10 +196,6 @@ public class VehicleDatabase
Sets.AddRange(entry.Sets); Sets.AddRange(entry.Sets);
} }
/// <summary>
/// If the texture belongs to an automatic-consist set, returns all of that
/// set's textures in their defined order; otherwise null.
/// </summary>
public List<VehicleTexture>? ResolveSet(VehicleTexture texture) public List<VehicleTexture>? ResolveSet(VehicleTexture texture)
{ {
if (string.IsNullOrEmpty(texture.Uuid)) return null; if (string.IsNullOrEmpty(texture.Uuid)) return null;
@@ -271,9 +224,6 @@ public class VehicleDatabase
} }
} }
// Resolves each texture's class (group mini / mini_ref) and category letter
// once, after all groups are loaded, so the depot reads cached fields instead
// of doing a GroupsById lookup per texture on every search keystroke.
private void BuildTextureIndex() private void BuildTextureIndex()
{ {
foreach (var t in Textures) foreach (var t in Textures)
@@ -288,9 +238,6 @@ public class VehicleDatabase
string? category = g?.Category; string? category = g?.Category;
// A "*" category is a wildcard: the actual category is the first letter
// of the mini (upper-cased, like the original Starter's UpCase(mini[1])
// wagon-typing), e.g. a "Bdhpumn" mini -> category "B".
if (category == "*") if (category == "*")
{ {
string source = !string.IsNullOrEmpty(mini) ? mini : (t.TextureMini ?? ""); string source = !string.IsNullOrEmpty(mini) ? mini : (t.TextureMini ?? "");
@@ -304,10 +251,6 @@ public class VehicleDatabase
} }
} }
/// <summary>
/// True when the texture is a non-leading member of an auto-consist set
/// (subsequent multi-unit cars that the depot browser should hide).
/// </summary>
public bool IsSetFollower(VehicleTexture texture) public bool IsSetFollower(VehicleTexture texture)
{ {
if (string.IsNullOrEmpty(texture.Uuid)) return false; if (string.IsNullOrEmpty(texture.Uuid)) return false;
@@ -320,10 +263,6 @@ public class VehicleDatabase
!string.Equals(lead, texture.Uuid, StringComparison.OrdinalIgnoreCase); !string.Equals(lead, texture.Uuid, StringComparison.OrdinalIgnoreCase);
} }
/// <summary>
/// Miniature name for a texture: the texture_mini property, but if no .bmp
/// for it exists, falls back to the mini of the group it belongs to.
/// </summary>
public string? ResolveMiniName(VehicleTexture texture) public string? ResolveMiniName(VehicleTexture texture)
{ {
if (!string.IsNullOrEmpty(texture.TextureMini) && MiniPath(texture.TextureMini) != null) if (!string.IsNullOrEmpty(texture.TextureMini) && MiniPath(texture.TextureMini) != null)
@@ -336,7 +275,6 @@ public class VehicleDatabase
return texture.TextureMini; return texture.TextureMini;
} }
/// <summary>Resolved mini for a skin file (matched case-insensitively), or null.</summary>
public string? MiniForSkin(string? skinFile) public string? MiniForSkin(string? skinFile)
{ {
if (string.IsNullOrEmpty(skinFile)) return null; if (string.IsNullOrEmpty(skinFile)) return null;
@@ -344,7 +282,6 @@ public class VehicleDatabase
return TextureBySkin.TryGetValue(key, out var tex) ? ResolveMiniName(tex) : null; return TextureBySkin.TryGetValue(key, out var tex) ? ResolveMiniName(tex) : null;
} }
/// <summary>The texture for a skin file (matched case-insensitively), or null.</summary>
public VehicleTexture? TextureForSkin(string? skinFile) public VehicleTexture? TextureForSkin(string? skinFile)
{ {
if (string.IsNullOrEmpty(skinFile)) return null; if (string.IsNullOrEmpty(skinFile)) return null;
@@ -352,7 +289,6 @@ public class VehicleDatabase
return TextureBySkin.TryGetValue(key, out var tex) ? tex : null; return TextureBySkin.TryGetValue(key, out var tex) ? tex : null;
} }
/// <summary>Lines for starter/bledy.txt (Pascal chLogExt / missing assets).</summary>
public List<string> CollectMissingAssetLines() public List<string> CollectMissingAssetLines()
{ {
var lines = new List<string>(); var lines = new List<string>();
@@ -377,7 +313,6 @@ public class VehicleDatabase
return lines; return lines;
} }
/// <summary>Header label for the group a texture belongs to.</summary>
public string GroupHeader(string? groupId) public string GroupHeader(string? groupId)
{ {
if (!string.IsNullOrEmpty(groupId) && GroupsById.TryGetValue(groupId, out var grp)) if (!string.IsNullOrEmpty(groupId) && GroupsById.TryGetValue(groupId, out var grp))
@@ -388,21 +323,11 @@ public class VehicleDatabase
return groupId ?? ""; return groupId ?? "";
} }
// Case-insensitive index of mini .bmp files (built once).
private static Dictionary<string, string>? _miniIndex; private static Dictionary<string, string>? _miniIndex;
/// <summary>
/// Builds the miniature .bmp index up front (called from the startup load,
/// behind the splash) so the first thumbnail render never stalls the UI
/// thread scanning textures/mini/. No-op if it was already built.
/// </summary>
public static void PreloadMiniIndex(string miniDir = "textures/mini/") => public static void PreloadMiniIndex(string miniDir = "textures/mini/") =>
_miniIndex ??= BuildMiniIndex(miniDir); _miniIndex ??= BuildMiniIndex(miniDir);
/// <summary>
/// Resolves a miniature .bmp path under textures/mini/ case-insensitively,
/// or null if missing. The directory is indexed once and reused.
/// </summary>
public static string? MiniPath(string? miniName, string miniDir = "textures/mini/") public static string? MiniPath(string? miniName, string miniDir = "textures/mini/")
{ {
if (string.IsNullOrEmpty(miniName)) return null; if (string.IsNullOrEmpty(miniName)) return null;
@@ -423,7 +348,6 @@ public class VehicleDatabase
} }
} }
/// <summary>Automatic consist definition (legacy ^x grouping).</summary>
public class VehicleSet public class VehicleSet
{ {
[JsonPropertyName("uuid")] public string? Uuid { get; set; } [JsonPropertyName("uuid")] public string? Uuid { get; set; }
@@ -432,9 +356,6 @@ public class VehicleSet
[JsonPropertyName("texture_refs")] public List<string> TextureRefs { get; set; } = new(); [JsonPropertyName("texture_refs")] public List<string> TextureRefs { get; set; } = new();
} }
// Source-generated JSON metadata — makes deserialization AOT/trim-safe by avoiding
// runtime reflection over the model types. Nested types (VehicleGroup, VehicleTexture,
// VehicleSet, VehicleAlias, VehicleMeta) are pulled in automatically by the generator.
[JsonSerializable(typeof(VehicleEntry))] [JsonSerializable(typeof(VehicleEntry))]
[JsonSerializable(typeof(VehicleEntryCollection))] [JsonSerializable(typeof(VehicleEntryCollection))]
internal partial class VehicleJsonContext : JsonSerializerContext internal partial class VehicleJsonContext : JsonSerializerContext

View File

@@ -6,10 +6,6 @@ using StarterNG.Classes;
namespace StarterNG.Domain; namespace StarterNG.Domain;
/// <summary>
/// Pascal <c>TI18n</c> / <c>TrToken</c>: scenery strings starting with <c>@</c>
/// resolve against <c>scenery/i18n/{id}_{lang}.json</c>.
/// </summary>
public static class SceneryI18n public static class SceneryI18n
{ {
private static readonly Dictionary<string, string> Map = private static readonly Dictionary<string, string> Map =
@@ -17,7 +13,6 @@ public static class SceneryI18n
public static void Clear() => Map.Clear(); public static void Clear() => Map.Clear();
/// <param name="langCode">Short UI language code (pl/en/cz/hu/ru).</param>
public static void LoadFor(Scenery scenery, string langCode = "en") public static void LoadFor(Scenery scenery, string langCode = "en")
{ {
Map.Clear(); Map.Clear();
@@ -26,13 +21,11 @@ public static class SceneryI18n
string lang = NormalizeLang(langCode); string lang = NormalizeLang(langCode);
string i18nDir = Path.Combine(scnDir, "i18n"); string i18nDir = Path.Combine(scnDir, "i18n");
// Fallback first (en), then active language overlays — same as Pascal.
TryLoad(Path.Combine(i18nDir, $"{id}_en.json")); TryLoad(Path.Combine(i18nDir, $"{id}_en.json"));
if (!string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase))
TryLoad(Path.Combine(i18nDir, $"{id}_{lang}.json")); TryLoad(Path.Combine(i18nDir, $"{id}_{lang}.json"));
} }
/// <summary>Translate <c>@token</c> / <c>@nested.key</c>; plain text passes through.</summary>
public static string T(string? text) public static string T(string? text)
{ {
if (string.IsNullOrWhiteSpace(text)) return text ?? ""; if (string.IsNullOrWhiteSpace(text)) return text ?? "";
@@ -51,7 +44,7 @@ public static class SceneryI18n
if (doc.RootElement.ValueKind == JsonValueKind.Object) if (doc.RootElement.ValueKind == JsonValueKind.Object)
Flatten("", doc.RootElement); Flatten("", doc.RootElement);
} }
catch { /* ignore bad i18n files */ } catch { }
} }
private static void Flatten(string prefix, JsonElement obj) private static void Flatten(string prefix, JsonElement obj)

View File

@@ -6,11 +6,6 @@ using StarterNG.Classes;
namespace StarterNG.Domain; namespace StarterNG.Domain;
/// <summary>
/// Skin shuffle for the depot consist — same idea as Pascal <c>TTexRandomizer</c>:
/// pick another texture of a similar mini/class (expanded by <c>starter/reguly.txt</c>),
/// optionally clamped by operator and revision year.
/// </summary>
public sealed class TexRandomizer public sealed class TexRandomizer
{ {
private readonly VehicleDatabase _db; private readonly VehicleDatabase _db;
@@ -60,13 +55,10 @@ public sealed class TexRandomizer
} }
catch catch
{ {
// bad rules file — keep going with an empty stock
} }
} }
/// <summary>
/// Reassigns skins on <paramref name="consist"/> in place. Returns how many units changed.
/// </summary>
public int Apply(IList<ConsistItem> consist, Func<VehicleTexture, Dynamic> makeCar) public int Apply(IList<ConsistItem> consist, Func<VehicleTexture, Dynamic> makeCar)
{ {
int changed = 0; int changed = 0;
@@ -82,7 +74,6 @@ public sealed class TexRandomizer
string? mini = ResolveFilterMini(item, i, consist, current); string? mini = ResolveFilterMini(item, i, consist, current);
if (string.IsNullOrEmpty(mini)) continue; if (string.IsNullOrEmpty(mini)) continue;
// Pascal skips units that have a revision filter on but no parseable year.
int? revYear = null; int? revYear = null;
if (RevisionTolerance > 0) if (RevisionTolerance > 0)
{ {
@@ -92,7 +83,7 @@ public sealed class TexRandomizer
string? owner = null; string? owner = null;
string? cat = current?.ResolvedCategory; string? cat = current?.ResolvedCategory;
// Keep operator match for non-EMU / non-railbus (Pascal skips EZT/SZYNOBUS).
if (current != null && cat is not ("z" or "a")) if (current != null && cat is not ("z" or "a"))
owner = current.Meta?.Operator; owner = current.Meta?.Operator;
@@ -134,7 +125,6 @@ public sealed class TexRandomizer
return string.IsNullOrEmpty(mini) ? null : mini; return string.IsNullOrEmpty(mini) ? null : mini;
} }
// No texture in DB — borrow from a neighbour like Pascal does for loose dynamics.
if (index > 0) if (index > 0)
{ {
var prev = _db.TextureForSkin(consist[index - 1].Cars[0].SkinFile); var prev = _db.TextureForSkin(consist[index - 1].Cars[0].SkinFile);
@@ -208,20 +198,16 @@ public sealed class TexRandomizer
result.Add(t); result.Add(t);
} }
// Always keep the current skin as a possible "no change" so Count < 2 means
// there is nothing else to pick from.
if (current != null && !result.Contains(current)) if (current != null && !result.Contains(current))
result.Add(current); result.Add(current);
return result; return result;
} }
/// <summary>Pulls a 4-digit year from dates like <c>12.05.2018</c> or <c>2018-05-12</c>.</summary>
public static int? ParseRevisionYear(string? revision) public static int? ParseRevisionYear(string? revision)
{ {
if (string.IsNullOrWhiteSpace(revision)) return null; if (string.IsNullOrWhiteSpace(revision)) return null;
// Pascal: Copy(Revision, 7, 4) on dd.mm.yyyy
if (revision.Length >= 10 && revision[2] == '.' && revision[5] == '.' && if (revision.Length >= 10 && revision[2] == '.' && revision[5] == '.' &&
int.TryParse(revision.AsSpan(6, 4), out int yDot) && yDot is >= 1900 and <= 2100) int.TryParse(revision.AsSpan(6, 4), out int yDot) && yDot is >= 1900 and <= 2100)
return yDot; return yDot;

View File

@@ -8,17 +8,12 @@ using StarterNG.Classes;
namespace StarterNG.Domain; namespace StarterNG.Domain;
/// <summary>Shared trainset list labels and status-bar formatting for Scenarios / Depot.</summary>
public static class TrainsetDisplay public static class TrainsetDisplay
{ {
public static bool IsEmpty(Trainset trainset) => public static bool IsEmpty(Trainset trainset) =>
trainset.Vehicles.Count == 0 || trainset.Vehicles.Count == 0 ||
trainset.Vehicles.All(v => string.IsNullOrWhiteSpace(v.Name) && string.IsNullOrWhiteSpace(v.SkinFile)); trainset.Vehicles.All(v => string.IsNullOrWhiteSpace(v.Name) && string.IsNullOrWhiteSpace(v.SkinFile));
/// <summary>
/// Composition caption like Pascal <c>PrepareTrainsetDesc</c>: first loco/EZT by
/// name, then collapse consecutive same-class wagons as <c>Mini(N)</c>.
/// </summary>
public static string? CompositionText(Trainset trainset) public static string? CompositionText(Trainset trainset)
{ {
if (IsEmpty(trainset)) if (IsEmpty(trainset))
@@ -78,10 +73,6 @@ public static class TrainsetDisplay
string.IsNullOrWhiteSpace(v.SkinFile) ? "?" : string.IsNullOrWhiteSpace(v.SkinFile) ? "?" :
System.IO.Path.GetFileNameWithoutExtension(v.SkinFile); System.IO.Path.GetFileNameWithoutExtension(v.SkinFile);
/// <summary>
/// Like Pascal <c>SelectTrain</c>: prefer the last car if it has cab crew
/// (push-pull), else the first staffed/passenger car, else the first car.
/// </summary>
public static string? DefaultStartingVehicle(Trainset trainset) public static string? DefaultStartingVehicle(Trainset trainset)
{ {
if (trainset.Vehicles.Count == 0) if (trainset.Vehicles.Count == 0)
@@ -96,10 +87,6 @@ public static class TrainsetDisplay
return staffed?.Name ?? trainset.Vehicles[0].Name; return staffed?.Name ?? trainset.Vehicles[0].Name;
} }
/// <summary>
/// Length / mass / vehicle count / track (+ speed), with Pascal mass rules
/// (<c>IncludeVehicleToMass</c> + 200 t AllVehicles retry).
/// </summary>
public static string FormatStats(Trainset? trainset, Func<Dynamic, Physics?> physicsFor, public static string FormatStats(Trainset? trainset, Func<Dynamic, Physics?> physicsFor,
string lengthLabel, string massLabel, string vehiclesLabel, string trackLabel, string lengthLabel, string massLabel, string vehiclesLabel, string trackLabel,
Func<Dynamic, string?>? categoryOf = null, Func<Dynamic, string?>? categoryOf = null,
@@ -171,11 +158,6 @@ public static class TrainsetDisplay
return p.LoadAccepted.Contains(loadType, StringComparison.OrdinalIgnoreCase); return p.LoadAccepted.Contains(loadType, StringComparison.OrdinalIgnoreCase);
} }
/// <summary>
/// Pascal <c>UniqueVehicleName</c> / <c>UniqueVehiclesName</c> at launch:
/// EZT/szynobus → rename every car; otherwise only the start vehicle.
/// Returns the (possibly new) name of the start vehicle.
/// </summary>
public static string? UniquifyForLaunch(Trainset trainset, Scenery scenery, string? startName) public static string? UniquifyForLaunch(Trainset trainset, Scenery scenery, string? startName)
{ {
var used = CollectNames(scenery); var used = CollectNames(scenery);
@@ -220,7 +202,7 @@ public static class TrainsetDisplay
var tex = GameData.Instance.Vehicles.TextureForSkin(v.SkinFile); var tex = GameData.Instance.Vehicles.TextureForSkin(v.SkinFile);
if (tex != null) if (tex != null)
{ {
// Prefer texture_mini when it is more specific than the class mini (≈ MiniD).
if (!string.IsNullOrEmpty(tex.TextureMini) && if (!string.IsNullOrEmpty(tex.TextureMini) &&
!string.Equals(tex.TextureMini, tex.ResolvedClass, StringComparison.OrdinalIgnoreCase)) !string.Equals(tex.TextureMini, tex.ResolvedClass, StringComparison.OrdinalIgnoreCase))
return SanitizeName(tex.TextureMini!); return SanitizeName(tex.TextureMini!);

View File

@@ -8,10 +8,6 @@ using Avalonia.Platform;
namespace StarterNG.Infrastructure; namespace StarterNG.Infrastructure;
/// <summary>
/// Pascal <c>PrepareLoadingScreen</c>: pick a logo and write <c>textures/logo.bmp</c>
/// for the simulator splash.
/// </summary>
public static class LoadingScreen public static class LoadingScreen
{ {
public static void Prepare(string? logoKey, string? sceneryName = null) public static void Prepare(string? logoKey, string? sceneryName = null)
@@ -35,7 +31,7 @@ public static class LoadingScreen
} }
catch catch
{ {
// never block launch on a bad logo
} }
} }

View File

@@ -5,23 +5,14 @@ using Avalonia.Media;
namespace StarterNG.Infrastructure; namespace StarterNG.Infrastructure;
/// <summary>
/// A fixed-width font family that actually exists on this machine.
/// Avalonia treats the extra names in "Consolas, monospace" as glyph fallbacks,
/// not as family alternatives, and "monospace" is a fontconfig alias it never
/// resolves — so that stack silently renders in the proportional UI font
/// everywhere except Windows. The timetable and the fault/preview boxes align
/// their columns with spaces, so they need a real monospace family: probe the
/// installed ones once and keep the first match.
/// </summary>
public static class MonoFont public static class MonoFont
{ {
private static readonly string[] Preferred = private static readonly string[] Preferred =
{ {
"Consolas", // Windows "Consolas",
"Menlo", // macOS "Menlo",
"SF Mono", "SF Mono",
"DejaVu Sans Mono", // most Linux distros "DejaVu Sans Mono",
"Liberation Mono", "Liberation Mono",
"Noto Sans Mono", "Noto Sans Mono",
"Ubuntu Mono", "Ubuntu Mono",
@@ -42,13 +33,12 @@ public static class MonoFont
if (installed.Contains(name)) if (installed.Contains(name))
return new FontFamily(name); return new FontFamily(name);
// None of the usual suspects: take any family that calls itself Mono.
string? anyMono = installed.FirstOrDefault( string? anyMono = installed.FirstOrDefault(
n => n.Contains("Mono", StringComparison.OrdinalIgnoreCase)); n => n.Contains("Mono", StringComparison.OrdinalIgnoreCase));
if (anyMono is not null) if (anyMono is not null)
return new FontFamily(anyMono); return new FontFamily(anyMono);
} }
catch { /* no font manager yet - fall back to the default family */ } catch { }
return FontFamily.Default; return FontFamily.Default;
} }

View File

@@ -20,7 +20,7 @@ namespace StarterNG;
public partial class MainWindow : Window public partial class MainWindow : Window
{ {
// Height of the caption strip the OS buttons live in.
private const int TitleBarHeight = 32; private const int TitleBarHeight = 32;
private DateTime _openedUtc = DateTime.UtcNow; private DateTime _openedUtc = DateTime.UtcNow;
@@ -30,24 +30,15 @@ public partial class MainWindow : Window
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
InitializeComponent(); InitializeComponent();
// Extend the client area over the title bar so the logo + navigation sit
// on the top bar, while the OS caption buttons (min / max / close) stay in
// the top-right corner. The default chrome hints already keep the system
// caption buttons, so only the two supported hints are set here (this
// Avalonia build no longer exposes ExtendClientAreaChromeHints).
ExtendClientAreaToDecorationsHint = true; ExtendClientAreaToDecorationsHint = true;
ExtendClientAreaTitleBarHeightHint = TitleBarHeight; ExtendClientAreaTitleBarHeightHint = TitleBarHeight;
// Windows draws the min / max / close buttons over the top of the extended
// client area, on top of the toolbar row. Push the bar down by the caption
// height so they get a strip of their own.
if (OperatingSystem.IsWindows()) if (OperatingSystem.IsWindows())
TopBar.Padding = new Thickness(0, TitleBarHeight, 0, 0); TopBar.Padding = new Thickness(0, TitleBarHeight, 0, 0);
PopulateLangCombo(); PopulateLangCombo();
App.Loc.LanguageChanged += OnLanguageChanged; App.Loc.LanguageChanged += OnLanguageChanged;
// Keep Start enabled/caption in sync with selection (Pascal actStartUpdate).
AppState.Instance.Changed += UpdateStartButton; AppState.Instance.Changed += UpdateStartButton;
Opened += (_, _) => Opened += (_, _) =>
{ {
@@ -57,20 +48,17 @@ public partial class MainWindow : Window
Activated += (_, _) => CheckExternalSettings(); Activated += (_, _) => CheckExternalSettings();
} }
// Lets the user drag the window by the top caption strip.
private void TitleBar_OnPointerPressed(object? sender, PointerPressedEventArgs e) private void TitleBar_OnPointerPressed(object? sender, PointerPressedEventArgs e)
{ {
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
BeginMoveDrag(e); BeginMoveDrag(e);
} }
// ── Navigation: show the page whose RadioButton was just checked ──────────
private void Nav_OnCheckedChanged(object? sender, RoutedEventArgs e) private void Nav_OnCheckedChanged(object? sender, RoutedEventArgs e)
{ {
if (sender is not RadioButton { IsChecked: true } rb) if (sender is not RadioButton { IsChecked: true } rb)
return; return;
// Guard against the controls not being created yet during initial load.
if (ScenariosView is null || DepotView is null || SettingsView is null) if (ScenariosView is null || DepotView is null || SettingsView is null)
return; return;
@@ -80,9 +68,6 @@ public partial class MainWindow : Window
SettingsView.IsVisible = page == "settings"; SettingsView.IsVisible = page == "settings";
} }
// One entry per file in lang/. The entries are language names, which are not
// translated, so this runs once - never from OnLanguageChanged, where it
// would clear the combo from inside its own SelectionChanged and throw.
private void PopulateLangCombo() private void PopulateLangCombo()
{ {
LangCombo.Items.Clear(); LangCombo.Items.Clear();
@@ -94,8 +79,6 @@ public partial class MainWindow : Window
SelectActiveLanguage(); SelectActiveLanguage();
} }
// Points the combo at the loaded language; a no-op when it already shows it,
// which is the case for a pick made here.
private void SelectActiveLanguage() private void SelectActiveLanguage()
{ {
var item = LangCombo.Items.OfType<ComboBoxItem>() var item = LangCombo.Items.OfType<ComboBoxItem>()
@@ -112,18 +95,12 @@ public partial class MainWindow : Window
if ((LangCombo.SelectedItem as ComboBoxItem)?.Tag is not string code) if ((LangCombo.SelectedItem as ComboBoxItem)?.Tag is not string code)
return; return;
// Filling the combo in selects the language that is already loaded, which
// lands here as well - comparing against the loaded one is what tells the
// two apart, and it keeps the handler idempotent.
if (string.Equals(code, App.Loc.CurrentLangCode, StringComparison.OrdinalIgnoreCase)) if (string.Equals(code, App.Loc.CurrentLangCode, StringComparison.OrdinalIgnoreCase))
return; return;
App.ApplyLanguage(code); App.ApplyLanguage(code);
} }
// Captions built in code (train stats, panel headers, combo items) are read
// from App.Loc once, so they have to be built again. Runs for a pick made
// here or in the settings tab, hence hanging off Loc rather off the combo.
private void OnLanguageChanged() private void OnLanguageChanged()
{ {
SelectActiveLanguage(); SelectActiveLanguage();
@@ -204,8 +181,6 @@ public partial class MainWindow : Window
} }
} }
// Pascal actStartUpdate: Start needs a scenery and a trainset. A consist with
// nobody aboard still starts, after the confirmation in StartButton_OnClick.
private void UpdateStartButton() private void UpdateStartButton()
{ {
if (startButton is null) return; if (startButton is null) return;
@@ -225,8 +200,7 @@ public partial class MainWindow : Window
startButton.IsEnabled = false; startButton.IsEnabled = false;
return; return;
} }
// No driver and no passenger: still startable, but say so on the button
// and ask before launching.
bool staffed = HasStartableVehicle(trainset); bool staffed = HasStartableVehicle(trainset);
startButton.Content = staffed ? App.Loc["Start"] : App.Loc["StartNoStaff"]; startButton.Content = staffed ? App.Loc["Start"] : App.Loc["StartNoStaff"];
ToolTip.SetTip(startButton, staffed ? null : App.Loc["StartNoStaffConfirm"]); ToolTip.SetTip(startButton, staffed ? null : App.Loc["StartNoStaffConfirm"]);
@@ -249,8 +223,6 @@ public partial class MainWindow : Window
return trainset.Vehicles.Any(CanStart); return trainset.Vehicles.Any(CanStart);
} }
// ── START: export the (possibly depot-modified) scenery and launch the sim.
// Global on the main window, like the original Starter's bottom START button.
private async void StartButton_OnClick(object? sender, RoutedEventArgs e) private async void StartButton_OnClick(object? sender, RoutedEventArgs e)
{ {
var scenery = AppState.Instance.CurrentScenery; var scenery = AppState.Instance.CurrentScenery;
@@ -260,8 +232,6 @@ public partial class MainWindow : Window
if (trainset is null) if (trainset is null)
return; return;
// Launching a consist with no driver and no passenger is legal (a scenery
// can be watched from the outside), so confirm instead of blocking it.
if (!HasStartableVehicle(trainset) && if (!HasStartableVehicle(trainset) &&
!await MessageBox.Show(this, App.Loc["StartNoStaffConfirm"], !await MessageBox.Show(this, App.Loc["StartNoStaffConfirm"],
App.Loc["StartNoStaffTitle"], MessageBoxButtons.YesNo)) App.Loc["StartNoStaffTitle"], MessageBoxButtons.YesNo))
@@ -277,7 +247,6 @@ public partial class MainWindow : Window
}; };
} }
// Pascal UniqueVehicleName / UniqueVehiclesName before writing $scn.
string? vehicle = TrainsetDisplay.UniquifyForLaunch( string? vehicle = TrainsetDisplay.UniquifyForLaunch(
trainset, scenery, AppState.Instance.StartingVehicleName); trainset, scenery, AppState.Instance.StartingVehicleName);
if (string.IsNullOrEmpty(vehicle)) if (string.IsNullOrEmpty(vehicle))
@@ -286,11 +255,8 @@ public partial class MainWindow : Window
LoadingScreen.Prepare(trainset.Logo, Path.GetFileNameWithoutExtension(scenery.Path)); LoadingScreen.Prepare(trainset.Logo, Path.GetFileNameWithoutExtension(scenery.Path));
// Always inject the Weather-tab values (incl. start-time override), like the
// Pascal starter's ChangeConfig on every launch — not only after an edit.
scenery.WeatherDirty = true; scenery.WeatherDirty = true;
// write scenery/$<name>.scn with the replaced trainsets
string dir = Path.GetDirectoryName(scenery.Path) ?? "scenery"; string dir = Path.GetDirectoryName(scenery.Path) ?? "scenery";
string exportName = "$" + Path.GetFileName(scenery.Path); string exportName = "$" + Path.GetFileName(scenery.Path);
string exportPath = Path.Combine(dir, exportName); string exportPath = Path.Combine(dir, exportName);
@@ -300,16 +266,11 @@ public partial class MainWindow : Window
} }
catch catch
{ {
return; // couldn't write the scenery file return;
} }
// make sure the latest settings are on disk before the sim reads them
SettingsModel.Instance.CaptureAndSave(); SettingsModel.Instance.CaptureAndSave();
// launch the game: -s $<name>.scn -v <vehicle>. Keep the handle so we can
// watch for the simulator exiting. UseShellExecute = false runs the binary
// directly with its arguments, which works the same on Windows and Linux
// (the executable is resolved to eu07.exe / eu07 via ResolveExecutable).
Process? sim; Process? sim;
try try
{ {
@@ -324,29 +285,23 @@ public partial class MainWindow : Window
} }
catch catch
{ {
return; // executable not found / not configured yet return;
} }
// honour the "close starter automatically" preference
if (SettingsModel.Instance.AutoCloseStarter) if (SettingsModel.Instance.AutoCloseStarter)
{ {
Close(); Close();
return; return;
} }
// Otherwise keep the starter running but hidden while the sim is in the
// foreground, and bring it back once the simulator process exits.
Hide(); Hide();
// reduce ram usage while the sim runs
GC.Collect(); GC.Collect();
GC.WaitForPendingFinalizers(); GC.WaitForPendingFinalizers();
WatchSimulator(sim); WatchSimulator(sim);
} }
// Pascal: RunInfo.Vehicle := Train.Vehicles[SelVehicle].Name after actStartUpdate
// requires headdriver/reardriver/passenger on that car.
private static string? ResolveStartVehicle(Trainset? trainset) private static string? ResolveStartVehicle(Trainset? trainset)
{ {
if (trainset is null || trainset.Vehicles.Count == 0) if (trainset is null || trainset.Vehicles.Count == 0)
@@ -368,9 +323,6 @@ public partial class MainWindow : Window
?? trainset.Vehicles.FirstOrDefault()?.Name; ?? trainset.Vehicles.FirstOrDefault()?.Name;
} }
// Waits (off the UI thread) for the simulator to exit, then restores the
// starter window. Uses the launch handle when available, otherwise locates the
// process by name, and never leaves the UI hidden if it cannot be tracked.
private void WatchSimulator(Process? sim) private void WatchSimulator(Process? sim)
{ {
if (sim is null) if (sim is null)
@@ -381,7 +333,7 @@ public partial class MainWindow : Window
if (sim is null) if (sim is null)
{ {
// no handle to wait on - restore immediately rather than hide forever
Dispatcher.UIThread.Post(RestoreFromSimulator); Dispatcher.UIThread.Post(RestoreFromSimulator);
return; return;
} }
@@ -389,7 +341,7 @@ public partial class MainWindow : Window
var watcher = new Thread(() => var watcher = new Thread(() =>
{ {
try { sim.WaitForExit(); } try { sim.WaitForExit(); }
catch { /* already exited / access denied */ } catch { }
Dispatcher.UIThread.Post(RestoreFromSimulator); Dispatcher.UIThread.Post(RestoreFromSimulator);
}) })
{ {
@@ -399,7 +351,6 @@ public partial class MainWindow : Window
watcher.Start(); watcher.Start();
} }
// Pascal CheckSettingsFile on AppActivate (skip the first few seconds).
private async void CheckExternalSettings() private async void CheckExternalSettings()
{ {
if ((DateTime.UtcNow - _openedUtc).TotalSeconds < 3) if ((DateTime.UtcNow - _openedUtc).TotalSeconds < 3)
@@ -421,7 +372,6 @@ public partial class MainWindow : Window
SettingsModel.Instance.TouchConfigAge(); SettingsModel.Instance.TouchConfigAge();
} }
// Brings the starter back to the foreground after the simulator closes.
private void RestoreFromSimulator() private void RestoreFromSimulator()
{ {
Show(); Show();

View File

@@ -5,14 +5,11 @@ namespace StarterNG;
class Program class Program
{ {
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread] [STAThread]
public static void Main(string[] args) => BuildAvaloniaApp() public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args); .StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp() public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>() => AppBuilder.Configure<App>()
.UsePlatformDetect() .UsePlatformDetect()

View File

@@ -6,22 +6,8 @@ using System.Xml;
namespace StarterNG.Services; namespace StarterNG.Services;
/// <summary>
/// Loads UI translations from plain XML files shipped in a <c>lang/</c> folder next
/// to the executable (instead of dictionaries compiled into the assembly). The files
/// are parsed with <see cref="XmlReader"/>, which works under Native AOT — unlike the
/// Avalonia XAML loader, which has no runtime parser in AOT builds.
///
/// File format (lang/en.xml):
/// <code>
/// &lt;Language code="en" name="English"&gt;
/// &lt;String key="NavScenarios"&gt;Scenarios&lt;/String&gt;
/// &lt;/Language&gt;
/// </code>
/// </summary>
public class LocalizationService : INotifyPropertyChanged public class LocalizationService : INotifyPropertyChanged
{ {
/// <summary>Folder holding the language files, resolved as starter/lang next to the executable.</summary>
public static string LangDirectory => public static string LangDirectory =>
Path.Combine(AppContext.BaseDirectory, "startercfg", "lang"); Path.Combine(AppContext.BaseDirectory, "startercfg", "lang");
@@ -29,32 +15,17 @@ public class LocalizationService : INotifyPropertyChanged
public event PropertyChangedEventHandler? PropertyChanged; public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>
/// Raised after a language file is loaded. XAML bindings refresh themselves
/// through <see cref="PropertyChanged"/>; this is for captions that code
/// builds once and has to build again.
/// </summary>
public event Action? LanguageChanged; public event Action? LanguageChanged;
/// <summary>Display name of the active language (e.g. "English", "Polski").</summary>
public string CurrentLanguage { get; private set; } = "English"; public string CurrentLanguage { get; private set; } = "English";
/// <summary>Short ISO code of the active language (e.g. "en", "pl").</summary>
public string CurrentLangCode { get; private set; } = "en"; public string CurrentLangCode { get; private set; } = "en";
/// <summary>Indexer used by the XAML bindings: returns the key itself when missing.</summary>
public string this[string key] => public string this[string key] =>
_strings.TryGetValue(key, out var value) ? value : key; _strings.TryGetValue(key, out var value) ? value : key;
/// <summary>
/// Metadata for one discovered language file.
/// </summary>
public readonly record struct LanguageInfo(string Code, string Name, string Path); public readonly record struct LanguageInfo(string Code, string Name, string Path);
/// <summary>
/// Scans the lang/ folder and returns the header metadata of every *.xml file
/// without loading all of its strings. Used to populate the language selector.
/// </summary>
public static IReadOnlyList<LanguageInfo> AvailableLanguages() public static IReadOnlyList<LanguageInfo> AvailableLanguages()
{ {
var list = new List<LanguageInfo>(); var list = new List<LanguageInfo>();
@@ -71,17 +42,12 @@ public class LocalizationService : INotifyPropertyChanged
} }
catch catch
{ {
// Skip malformed files rather than crashing the launcher.
} }
} }
return list; return list;
} }
/// <summary>
/// Loads the language whose code or display name matches <paramref name="codeOrName"/>.
/// Falls back to English ("en") when no match is found. Returns the resolved
/// display name so callers can keep their stored setting consistent.
/// </summary>
public string Load(string codeOrName) public string Load(string codeOrName)
{ {
var languages = AvailableLanguages(); var languages = AvailableLanguages();
@@ -97,7 +63,6 @@ public class LocalizationService : INotifyPropertyChanged
} }
} }
// Fall back to English, then to whatever is available first.
if (match is null) if (match is null)
{ {
foreach (var lang in languages) foreach (var lang in languages)
@@ -114,14 +79,12 @@ public class LocalizationService : INotifyPropertyChanged
LoadFile(info.Path, info.Code, info.Name); LoadFile(info.Path, info.Code, info.Name);
else else
{ {
// No files at all: keep keys visible instead of blank UI.
_strings = new Dictionary<string, string>(); _strings = new Dictionary<string, string>();
CurrentLangCode = "en"; CurrentLangCode = "en";
CurrentLanguage = "English"; CurrentLanguage = "English";
} }
// "Item[]" is the indexer name bindings listen for; the empty name asks
// every binding on this object to re-read.
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]")); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]"));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(string.Empty)); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(string.Empty));
LanguageChanged?.Invoke(); LanguageChanged?.Invoke();
@@ -135,16 +98,12 @@ public class LocalizationService : INotifyPropertyChanged
var settings = new XmlReaderSettings { IgnoreComments = true, IgnoreWhitespace = true }; var settings = new XmlReaderSettings { IgnoreComments = true, IgnoreWhitespace = true };
using var reader = XmlReader.Create(path, settings); using var reader = XmlReader.Create(path, settings);
// NOTE: ReadElementContentAsString() already advances the reader past the
// element's end tag onto the *next* node, so we must NOT call Read() again
// in that case - doing so would skip every second <String> entry. We only
// advance explicitly for nodes we don't consume here.
while (!reader.EOF) while (!reader.EOF)
{ {
if (reader.NodeType == XmlNodeType.Element && reader.Name == "String") if (reader.NodeType == XmlNodeType.Element && reader.Name == "String")
{ {
var key = reader.GetAttribute("key"); var key = reader.GetAttribute("key");
var value = reader.ReadElementContentAsString(); // advances the reader var value = reader.ReadElementContentAsString();
if (!string.IsNullOrEmpty(key)) if (!string.IsNullOrEmpty(key))
dict[key] = value; dict[key] = value;
} }

View File

@@ -10,7 +10,6 @@ public partial class SplashWindow : Window
InitializeComponent(); InitializeComponent();
} }
/// <summary>Updates the bar and status line. Must be called on the UI thread.</summary>
public void Report(LoadStatus status) public void Report(LoadStatus status)
{ {
progressBar.Value = status.Fraction; progressBar.Value = status.Fraction;

View File

@@ -11,18 +11,9 @@ using StarterNG.Classes;
namespace StarterNG.Views; namespace StarterNG.Views;
/// <summary>
/// Right-click menu shared by the consist lists (the "Vehicles" list in Scenarios
/// and the "Map consists" list in the depot). Offers: save to warehouse, load from
/// warehouse (each entry deletable via an "✕"), copy/paste via the clipboard, and -
/// when <c>removeAll</c> is supplied - clearing the consist (Shift+Delete).
/// </summary>
public static class ConsistContextMenu public static class ConsistContextMenu
{ {
/// <param name="owner">Control the menu/flyouts anchor to (for clipboard + popups).</param>
/// <param name="getTarget">Supplies the trainset to act on when the menu opens.</param>
/// <param name="applyVehicles">Replaces the target's vehicles and refreshes the view.</param>
/// <param name="removeAll">When non-null, adds a "remove all vehicles" entry.</param>
public static ContextMenu Build( public static ContextMenu Build(
Control owner, Control owner,
Func<Trainset?> getTarget, Func<Trainset?> getTarget,
@@ -30,7 +21,7 @@ public static class ConsistContextMenu
Action? removeAll = null) Action? removeAll = null)
{ {
var menu = new ContextMenu(); var menu = new ContextMenu();
// Rebuild the entries each time it opens so the warehouse list stays fresh.
menu.Opening += (_, _) => Populate(menu, owner, getTarget, applyVehicles, removeAll); menu.Opening += (_, _) => Populate(menu, owner, getTarget, applyVehicles, removeAll);
return menu; return menu;
} }
@@ -42,12 +33,10 @@ public static class ConsistContextMenu
var target = getTarget(); var target = getTarget();
bool hasVehicles = target is { Vehicles.Count: > 0 }; bool hasVehicles = target is { Vehicles.Count: > 0 };
// ── Save current consist to the warehouse ──────────────────────────────
var save = new MenuItem { Header = App.Loc["PresetSave"], IsEnabled = hasVehicles }; var save = new MenuItem { Header = App.Loc["PresetSave"], IsEnabled = hasVehicles };
save.Click += (_, _) => ShowSaveFlyout(owner, target); save.Click += (_, _) => ShowSaveFlyout(owner, target);
menu.Items.Add(save); menu.Items.Add(save);
// ── Load from the warehouse (submenu; each entry deletable) ────────────
var load = new MenuItem { Header = App.Loc["PresetLoad"] }; var load = new MenuItem { Header = App.Loc["PresetLoad"] };
var presets = PresetStore.All(); var presets = PresetStore.All();
if (presets.Count == 0) if (presets.Count == 0)
@@ -82,7 +71,6 @@ public static class ConsistContextMenu
menu.Items.Add(new Separator()); menu.Items.Add(new Separator());
// ── Clipboard (full trainset text) ─────────────────────────────────────
var copy = new MenuItem { Header = App.Loc["ConsistCopy"], IsEnabled = hasVehicles }; var copy = new MenuItem { Header = App.Loc["ConsistCopy"], IsEnabled = hasVehicles };
copy.Click += async (_, _) => copy.Click += async (_, _) =>
{ {
@@ -103,7 +91,6 @@ public static class ConsistContextMenu
}; };
menu.Items.Add(paste); menu.Items.Add(paste);
// ── Remove all vehicles (depot only) ───────────────────────────────────
if (removeAll != null) if (removeAll != null)
{ {
menu.Items.Add(new Separator()); menu.Items.Add(new Separator());
@@ -118,8 +105,6 @@ public static class ConsistContextMenu
} }
} }
// A warehouse entry: the name loads the preset on click, the trailing "✕"
// deletes it from the warehouse.
private static MenuItem BuildPresetItem(ContextMenu menu, TrainsetPreset preset, private static MenuItem BuildPresetItem(ContextMenu menu, TrainsetPreset preset,
Action<List<Dynamic>> applyVehicles) Action<List<Dynamic>> applyVehicles)
{ {
@@ -134,9 +119,9 @@ public static class ConsistContextMenu
ToolTip.SetTip(del, App.Loc["PresetDelete"]); ToolTip.SetTip(del, App.Loc["PresetDelete"]);
del.Click += (_, e) => del.Click += (_, e) =>
{ {
e.Handled = true; // don't let the click also load the preset e.Handled = true;
PresetStore.Delete(preset.Name); PresetStore.Delete(preset.Name);
menu.Close(); // reopen to see the updated warehouse menu.Close();
}; };
var label = new TextBlock { Text = preset.Name, VerticalAlignment = VerticalAlignment.Center }; var label = new TextBlock { Text = preset.Name, VerticalAlignment = VerticalAlignment.Center };
@@ -156,7 +141,6 @@ public static class ConsistContextMenu
return item; return item;
} }
// Small popup to name the preset before saving it to the warehouse.
private static void ShowSaveFlyout(Control owner, Trainset? target) private static void ShowSaveFlyout(Control owner, Trainset? target)
{ {
if (target is null || target.Vehicles.Count == 0) if (target is null || target.Vehicles.Count == 0)

View File

@@ -21,25 +21,19 @@ namespace StarterNG.Views;
public partial class Depot : UserControl public partial class Depot : UserControl
{ {
// Data is parsed once at startup (behind the splash) and shared here.
private readonly VehicleDatabase _db = GameData.Instance.Vehicles; private readonly VehicleDatabase _db = GameData.Instance.Vehicles;
private readonly List<Scenery> _sceneries = GameData.Instance.Sceneries; private readonly List<Scenery> _sceneries = GameData.Instance.Sceneries;
// The consist being edited: a list of units (each unit = one or more cars).
private readonly List<ConsistItem> _consist = new(); private readonly List<ConsistItem> _consist = new();
private ConsistItem? _selected; private ConsistItem? _selected;
// The scenery trainset this consist is bound to; edits are written back to it
// so the scenery is modified. Null when building an unattached consist.
private Trainset? _editingTrainset; private Trainset? _editingTrainset;
// Shared, size-limited thumbnail cache (decoded down to display height).
private readonly Dictionary<string, Bitmap?> _miniCache = new(); private readonly Dictionary<string, Bitmap?> _miniCache = new();
private int ConsistThumbHeight => private int ConsistThumbHeight =>
StarterNG.Classes.Settings.Instance.LargeThumbnails ? 68 : 34; StarterNG.Classes.Settings.Instance.LargeThumbnails ? 68 : 34;
// Display height of the mini under the vehicle list; the class combo uses
// the same size.
private const int MiniPreviewHeight = 54; private const int MiniPreviewHeight = 54;
private const int CompactThumbHeight = 24; private const int CompactThumbHeight = 24;
@@ -47,35 +41,25 @@ public partial class Depot : UserControl
private readonly Cursor _hand = new(StandardCursorType.Hand); private readonly Cursor _hand = new(StandardCursorType.Hand);
private int _nameCounter; private int _nameCounter;
// shared RNG for the "random load / random amount" consist tools
private static readonly Random _rng = new(); private static readonly Random _rng = new();
// suppresses combo SelectionChanged handlers while we populate them
private bool _suppress; private bool _suppress;
private bool _syncingCombos; // set while the combos mirror a pick private bool _syncingCombos;
// debounces search input so we don't rebuild on every keystroke
private DispatcherTimer? _searchTimer; private DispatcherTimer? _searchTimer;
// the scenery whose consists are listed on the right (from the Scenarios tab)
private Scenery? _listScenery; private Scenery? _listScenery;
// active database filters private Func<string?, bool>? _categoryFilter;
private Func<string?, bool>? _categoryFilter; // matches a category letter, null = none chosen private string? _classFilter;
private string? _classFilter; // group mini (e.g. EU07), null = all in category
// the vehicle currently highlighted in the flat list (drives the mini preview
// and the single Add button)
private VehicleTexture? _browserSelected; private VehicleTexture? _browserSelected;
// drag-drop payload while a pointer drag is in progress
private VehicleTexture? _dragTexture; private VehicleTexture? _dragTexture;
private int _dragConsistIndex = -1; private int _dragConsistIndex = -1;
private const string DragVehicleFormat = "starterng/vehicle"; private const string DragVehicleFormat = "starterng/vehicle";
private const string DragConsistFormat = "starterng/consist-index"; private const string DragConsistFormat = "starterng/consist-index";
// Armed on press, started only after the pointer moves past DragThreshold.
// Starting DoDragDropAsync on every click freezes Avalonia (pointer capture).
private PointerPressedEventArgs? _pendingDragPress; private PointerPressedEventArgs? _pendingDragPress;
private Point _pendingDragOrigin; private Point _pendingDragOrigin;
private VehicleTexture? _pendingDragTexture; private VehicleTexture? _pendingDragTexture;
@@ -84,17 +68,13 @@ public partial class Depot : UserControl
private bool _dragSessionActive; private bool _dragSessionActive;
private const double DragThreshold = 6; private const double DragThreshold = 6;
// hard cap on how many entries the flat list shows at once
private const int MaxListRows = 2000; private const int MaxListRows = 2000;
// card highlight brushes (theme-neutral overlays)
private static readonly IBrush CardBorder = new SolidColorBrush(Color.Parse("#33888888")); private static readonly IBrush CardBorder = new SolidColorBrush(Color.Parse("#33888888"));
private static readonly IBrush CardBorderSel = new SolidColorBrush(Color.Parse("#AA41C400")); private static readonly IBrush CardBorderSel = new SolidColorBrush(Color.Parse("#AA41C400"));
private static readonly IBrush CardBgSel = new SolidColorBrush(Color.Parse("#2241C400")); private static readonly IBrush CardBgSel = new SolidColorBrush(Color.Parse("#2241C400"));
private static readonly IBrush Placeholder = new SolidColorBrush(Color.Parse("#22808080")); private static readonly IBrush Placeholder = new SolidColorBrush(Color.Parse("#22808080"));
// Vehicle types, keyed by the JSON "category" letter. Powered/technical
// vehicles use lowercase letters; wagons use uppercase letters.
private static readonly (string LocKey, Func<string?, bool> Match)[] CategoryDefs = private static readonly (string LocKey, Func<string?, bool> Match)[] CategoryDefs =
{ {
("CatElectricLoco", c => c == "e"), ("CatElectricLoco", c => c == "e"),
@@ -140,18 +120,14 @@ public partial class Depot : UserControl
("CatOther", IsOtherCat), ("CatOther", IsOtherCat),
}; };
// lowercase letters that already have a named type (so they aren't "other")
private static readonly HashSet<string> NamedLowerCats = private static readonly HashSet<string> NamedLowerCats =
new(StringComparer.Ordinal) { "a", "b", "c", "d", "e", "f", "h", "n", "o", "p", "r", "s", "t", "x", "z" }; new(StringComparer.Ordinal) { "a", "b", "c", "d", "e", "f", "h", "n", "o", "p", "r", "s", "t", "x", "z" };
private static bool IsOtherCat(string? c) => private static bool IsOtherCat(string? c) =>
c is { Length: 1 } && char.IsLower(c[0]) && !NamedLowerCats.Contains(c); c is { Length: 1 } && char.IsLower(c[0]) && !NamedLowerCats.Contains(c);
// Car-type suffixes (longest first) used only for the consist unit label.
private static readonly string[] CarSuffixes = { "sa", "sb", "ra", "rb", "s" }; private static readonly string[] CarSuffixes = { "sa", "sb", "ra", "rb", "s" };
// Coupling-bit localisation keys in mask-bit order (bit i = value 1<<i), per
// the wiki: https://wiki.eu07.pl/index.php?title=Wpisy_hamulca_dla_pojazdow
private static readonly string[] CouplingBitKeys = private static readonly string[] CouplingBitKeys =
{ "CplMechanical", "CplBrake", "CplControl", "CplHighVoltage", { "CplMechanical", "CplBrake", "CplControl", "CplHighVoltage",
"CplGangway", "CplAuxPneumatic", "CplHeating", "CplWorkshop" }; "CplGangway", "CplAuxPneumatic", "CplHeating", "CplWorkshop" };
@@ -170,17 +146,15 @@ public partial class Depot : UserControl
BuildList(); BuildList();
}; };
// Defer population so the tab renders immediately, then fills in.
Dispatcher.UIThread.Post(() => Dispatcher.UIThread.Post(() =>
{ {
hideArchivalCheck.IsChecked = StarterNG.Classes.Settings.Instance.HideArchivalVehicles; hideArchivalCheck.IsChecked = StarterNG.Classes.Settings.Instance.HideArchivalVehicles;
InitClassComboTemplates(); InitClassComboTemplates();
PopulateCategoryCombo(); // -> populates class combo -> builds browser PopulateCategoryCombo();
PopulateSceneryConsists(); // -> lists the current scenery's consists PopulateSceneryConsists();
RebuildConsist(); RebuildConsist();
}, DispatcherPriority.Background); }, DispatcherPriority.Background);
// Consist strip accepts drops from the browser and reorders via card drag.
DragDrop.SetAllowDrop(consistDropTarget, true); DragDrop.SetAllowDrop(consistDropTarget, true);
consistDropTarget.AddHandler(DragDrop.DragOverEvent, Consist_OnDragOver); consistDropTarget.AddHandler(DragDrop.DragOverEvent, Consist_OnDragOver);
consistDropTarget.AddHandler(DragDrop.DropEvent, Consist_OnDrop); consistDropTarget.AddHandler(DragDrop.DropEvent, Consist_OnDrop);
@@ -190,9 +164,6 @@ public partial class Depot : UserControl
miniPreviewPanel.AddHandler(InputElement.PointerMovedEvent, PendingDrag_OnPointerMoved, handledEventsToo: true); miniPreviewPanel.AddHandler(InputElement.PointerMovedEvent, PendingDrag_OnPointerMoved, handledEventsToo: true);
miniPreviewPanel.AddHandler(InputElement.PointerReleasedEvent, PendingDrag_OnPointerReleased, handledEventsToo: true); miniPreviewPanel.AddHandler(InputElement.PointerReleasedEvent, PendingDrag_OnPointerReleased, handledEventsToo: true);
// Switching tabs only toggles IsVisible (the view stays in the visual
// tree), so refresh whenever the depot becomes visible - this picks up a
// scenery changed in the Scenarios tab in the meantime.
AttachedToVisualTree += (_, _) => SyncFromSelection(); AttachedToVisualTree += (_, _) => SyncFromSelection();
PropertyChanged += (_, e) => PropertyChanged += (_, e) =>
{ {
@@ -203,7 +174,7 @@ public partial class Depot : UserControl
private void SyncFromSelection() private void SyncFromSelection()
{ {
// pick up Settings-tab changes to the archival-vehicle filter
bool hideArchival = StarterNG.Classes.Settings.Instance.HideArchivalVehicles; bool hideArchival = StarterNG.Classes.Settings.Instance.HideArchivalVehicles;
if (hideArchivalCheck.IsChecked != hideArchival) if (hideArchivalCheck.IsChecked != hideArchival)
{ {
@@ -213,19 +184,15 @@ public partial class Depot : UserControl
BuildList(); BuildList();
} }
// refresh the on-map consists list to the scenery picked in the Scenarios tab
PopulateSceneryConsists(); PopulateSceneryConsists();
var trainset = AppState.Instance.CurrentTrainset; var trainset = AppState.Instance.CurrentTrainset;
if (trainset != null && !ReferenceEquals(trainset, _editingTrainset)) if (trainset != null && !ReferenceEquals(trainset, _editingTrainset))
LoadTrainset(trainset); LoadTrainset(trainset);
else if (_editingTrainset != null) else if (_editingTrainset != null)
RebuildConsist(); // pick up LargeThumbnails / filter-driven list changes RebuildConsist();
} }
// Loads a scenery trainset into the editor and binds edits back to it.
// Consecutive cars that belong to the same auto-consist set are regrouped
// into one locked unit so a scenery EN57 shows as a single vehicle.
private void LoadTrainset(Trainset trainset) private void LoadTrainset(Trainset trainset)
{ {
_editingTrainset = trainset; _editingTrainset = trainset;
@@ -234,7 +201,7 @@ public partial class Depot : UserControl
var cars = trainset.Vehicles.Select(v => var cars = trainset.Vehicles.Select(v =>
{ {
var c = v.Clone(); var c = v.Clone();
c.MiniName = _db.MiniForSkin(c.SkinFile) ?? c.MiniName; // mini from texture_mini c.MiniName = _db.MiniForSkin(c.SkinFile) ?? c.MiniName;
return c; return c;
}).ToList(); }).ToList();
@@ -267,8 +234,7 @@ public partial class Depot : UserControl
{ {
Cars = group, Cars = group,
Grouped = true, Grouped = true,
// FirstOrDefault(predicate) defaults the enum to Headdriver(0) when
// every car is Nobody — that re-staffed decorative consists (#12).
Driver = UnitDriver(group) Driver = UnitDriver(group)
}); });
i = j; i = j;
@@ -276,7 +242,6 @@ public partial class Depot : UserControl
} }
} }
// fallback: group consecutive cars sharing a unit number (e.g. EN57-001ra/s/rb)
if (IsUnitCar(cars[i])) if (IsUnitCar(cars[i]))
{ {
string key = UnitKey(cars[i]); string key = UnitKey(cars[i]);
@@ -312,7 +277,6 @@ public partial class Depot : UserControl
RebuildConsist(); RebuildConsist();
} }
// First staffed occupancy on the unit, or Nobody when the whole unit is unstaffed.
private static eDriverType UnitDriver(IReadOnlyList<Dynamic> group) => private static eDriverType UnitDriver(IReadOnlyList<Dynamic> group) =>
group.Select(c => c.DriverType) group.Select(c => c.DriverType)
.Where(d => d != eDriverType.Nobody) .Where(d => d != eDriverType.Nobody)
@@ -325,10 +289,6 @@ public partial class Depot : UserControl
AppState.Instance.StartingVehicleName = _selected.Cars[0].Name; AppState.Instance.StartingVehicleName = _selected.Cars[0].Name;
} }
// ----------------------------------------------------- series / unit helpers
// Removes a trailing car-type suffix that directly follows a digit
// ("EN57-001ra" -> "EN57-001", "EN57ra" -> "EN57", "EP07-332" -> unchanged).
private static string StripCarSuffix(string name) private static string StripCarSuffix(string name)
{ {
foreach (string suf in CarSuffixes) foreach (string suf in CarSuffixes)
@@ -345,23 +305,15 @@ public partial class Depot : UserControl
private static string UnitKey(Dynamic d) => StripCarSuffix(Base(d.SkinFile)); private static string UnitKey(Dynamic d) => StripCarSuffix(Base(d.SkinFile));
// True when the skin has a car-type suffix (so it is part of a multi-car unit).
private static bool IsUnitCar(Dynamic d) => UnitKey(d) != Base(d.SkinFile); private static bool IsUnitCar(Dynamic d) => UnitKey(d) != Base(d.SkinFile);
// Vehicle class = the "mini" of the group the texture belongs to (e.g. EU07).
// Resolved once at load (see VehicleDatabase.BuildTextureIndex); read the cache
// here so search/filter never repeats the group lookup per keystroke.
private static string ClassOf(VehicleTexture t) => t.ResolvedClass; private static string ClassOf(VehicleTexture t) => t.ResolvedClass;
// Vehicle type = the group's "category" letter (also resolved once at load).
private static string? CategoryOf(VehicleTexture t) => t.ResolvedCategory; private static string? CategoryOf(VehicleTexture t) => t.ResolvedCategory;
// ------------------------------------------------------------ category combo
private void PopulateCategoryCombo() private void PopulateCategoryCombo()
{ {
// No "All" entry: nothing is selected by default, so the list starts empty
// (matching the original Starter's depot).
_suppress = true; _suppress = true;
categoryCombo.Items.Clear(); categoryCombo.Items.Clear();
foreach (var (locKey, match) in CategoryDefs) foreach (var (locKey, match) in CategoryDefs)
@@ -384,9 +336,6 @@ public partial class Depot : UserControl
BuildList(); BuildList();
} }
// The dropdown shows the big stacked miniature, the closed combo a one-line
// row. Plain "=": SelectionBoxItemTemplate coerces to ItemTemplate while
// unset, so "??=" would never assign.
private void InitClassComboTemplates() private void InitClassComboTemplates()
{ {
classCombo.ItemTemplate = new FuncDataTemplate<string>( classCombo.ItemTemplate = new FuncDataTemplate<string>(
@@ -395,9 +344,6 @@ public partial class Depot : UserControl
(cls, _) => ClassComboContent(cls, large: false), false); (cls, _) => ClassComboContent(cls, large: false), false);
} }
// Fills the class combo for the current category filter; the caller owns the
// selection and _classFilter. Runs under _suppress.
// No "All" entry: an unselected class means "every class in the category".
private void FillClassCombo() private void FillClassCombo()
{ {
classCombo.Items.Clear(); classCombo.Items.Clear();
@@ -405,8 +351,6 @@ public partial class Depot : UserControl
classCombo.Items.Add(cls); classCombo.Items.Add(cls);
} }
// Points both combos at the given category/class pair without firing their
// handlers. Returns false when they already matched.
private bool ApplyFilters(string? category, string? cls) private bool ApplyFilters(string? category, string? cls)
{ {
var catItem = categoryCombo.Items.OfType<ComboBoxItem>() var catItem = categoryCombo.Items.OfType<ComboBoxItem>()
@@ -433,7 +377,6 @@ public partial class Depot : UserControl
return true; return true;
} }
// Every texture of a class shares its category.
private string? CategoryOfClass(string cls) => private string? CategoryOfClass(string cls) =>
_db.Textures.FirstOrDefault(t => string.Equals(ClassOf(t), cls, StringComparison.OrdinalIgnoreCase)) _db.Textures.FirstOrDefault(t => string.Equals(ClassOf(t), cls, StringComparison.OrdinalIgnoreCase))
is { } t ? CategoryOf(t) : null; is { } t ? CategoryOf(t) : null;
@@ -491,7 +434,6 @@ public partial class Depot : UserControl
string? cls = classCombo.SelectedItem as string; string? cls = classCombo.SelectedItem as string;
_classFilter = cls; _classFilter = cls;
// ApplyFilters refills this very combo, so it cannot run from here.
if (cls != null && _categoryFilter == null && if (cls != null && _categoryFilter == null &&
PostSyncing(() => { ApplyFilters(CategoryOfClass(cls), cls); BuildList(); })) PostSyncing(() => { ApplyFilters(CategoryOfClass(cls), cls); BuildList(); }))
return; return;
@@ -499,9 +441,6 @@ public partial class Depot : UserControl
BuildList(); BuildList();
} }
// Hovering over the Type/Class combos and scrolling cycles the selection
// without having to open the dropdown (scroll up = previous, down = next).
// When the dropdown is open the wheel scrolls the list as usual.
private void Combo_OnPointerWheel(object? sender, PointerWheelEventArgs e) private void Combo_OnPointerWheel(object? sender, PointerWheelEventArgs e)
{ {
if (sender is not ComboBox combo || combo.IsDropDownOpen) if (sender is not ComboBox combo || combo.IsDropDownOpen)
@@ -512,9 +451,9 @@ public partial class Depot : UserControl
return; return;
int index = combo.SelectedIndex; int index = combo.SelectedIndex;
if (e.Delta.Y > 0) // scroll up -> previous if (e.Delta.Y > 0)
index = index <= 0 ? 0 : index - 1; index = index <= 0 ? 0 : index - 1;
else if (e.Delta.Y < 0) // scroll down -> next else if (e.Delta.Y < 0)
index = index < 0 ? 0 : Math.Min(index + 1, count - 1); index = index < 0 ? 0 : Math.Min(index + 1, count - 1);
else else
return; return;
@@ -523,15 +462,9 @@ public partial class Depot : UserControl
e.Handled = true; e.Handled = true;
} }
// ----------------------------------------------------------- vehicle browser
// Flat list of plain-text vehicle entries (no expanders, like the original
// Starter). Empty by default: an entry only appears once a category or class
// is chosen or a search is typed. Picking one drives the mini preview and the
// Add button.
private void BuildList() private void BuildList()
{ {
// reset selection-dependent UI
_browserSelected = null; _browserSelected = null;
miniPreview.Source = null; miniPreview.Source = null;
addVehicleButton.IsEnabled = false; addVehicleButton.IsEnabled = false;
@@ -542,8 +475,6 @@ public partial class Depot : UserControl
string search = searchBox.Text?.Trim() ?? ""; string search = searchBox.Text?.Trim() ?? "";
bool hasSearch = search.Length > 0; bool hasSearch = search.Length > 0;
// no filter and nothing searched -> keep the list empty (a class counts
// as a filter: it can be picked without a category)
if (_categoryFilter == null && _classFilter == null && !hasSearch) if (_categoryFilter == null && _classFilter == null && !hasSearch)
return; return;
@@ -572,7 +503,6 @@ public partial class Depot : UserControl
}); });
} }
// Selecting an entry shows its mini preview and enables the Add button.
private void VehicleListBox_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) private void VehicleListBox_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
{ {
if (vehicleListBox.SelectedItem is ListBoxItem { Tag: VehicleTexture texture }) if (vehicleListBox.SelectedItem is ListBoxItem { Tag: VehicleTexture texture })
@@ -630,7 +560,7 @@ public partial class Depot : UserControl
private void ShowTextureInfo(VehicleTexture texture) private void ShowTextureInfo(VehicleTexture texture)
{ {
textureInfoPanel.Children.Clear(); textureInfoPanel.Children.Clear();
// The rows are trimmed, so the tooltip carries the full text.
var full = new List<string>(); var full = new List<string>();
void Row(string label, string? value) void Row(string label, string? value)
{ {
@@ -659,29 +589,24 @@ public partial class Depot : UserControl
ToolTip.SetTip(textureInfoPanel, string.Join("\n", full)); ToolTip.SetTip(textureInfoPanel, string.Join("\n", full));
} }
// Double-click an entry to replace the selected consist vehicle.
private void VehicleListBox_OnDoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e) private void VehicleListBox_OnDoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e)
{ {
if (_browserSelected != null) if (_browserSelected != null)
ReplaceSelected(_browserSelected); ReplaceSelected(_browserSelected);
} }
// The single Add button inserts the highlighted vehicle after the selected
// consist vehicle (or at the end when nothing is selected).
private void AddVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) private void AddVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{ {
if (_browserSelected != null) if (_browserSelected != null)
AddTexture(_browserSelected); AddTexture(_browserSelected);
} }
// Pascal actAddVehiclesCategory — every skin currently listed in the browser.
private void AddAllCategoryButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) private void AddAllCategoryButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{ {
foreach (var t in CurrentBrowserTextures()) foreach (var t in CurrentBrowserTextures())
InsertTextureAt(t, _consist.Count); InsertTextureAt(t, _consist.Count);
} }
// Pascal actAddVehiclesMMD — first skin per distinct model/MMD.
private void AddUniqueMmdButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) private void AddUniqueMmdButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{ {
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -713,7 +638,6 @@ public partial class Depot : UserControl
!string.Equals(ClassOf(t), _classFilter, StringComparison.OrdinalIgnoreCase)) !string.Equals(ClassOf(t), _classFilter, StringComparison.OrdinalIgnoreCase))
return false; return false;
// Hide subsequent multi-unit cars; adding the lead expands the whole set.
if (_db.IsSetFollower(t)) if (_db.IsSetFollower(t))
return false; return false;
@@ -734,8 +658,6 @@ public partial class Depot : UserControl
|| C(t.Meta?.Vehicle) || C(t.Meta?.Operator) || C(ClassOf(t)); || C(t.Meta?.Vehicle) || C(t.Meta?.Operator) || C(ClassOf(t));
} }
// Replaces the selected consist unit with this vehicle/unit (keeping its
// slot, crew and orientation). Falls back to appending if nothing is selected.
private void ReplaceSelected(VehicleTexture texture) private void ReplaceSelected(VehicleTexture texture)
{ {
if (_selected is null) if (_selected is null)
@@ -767,8 +689,7 @@ public partial class Depot : UserControl
private string BrowserLabel(VehicleTexture texture, IReadOnlyList<VehicleTexture>? set) private string BrowserLabel(VehicleTexture texture, IReadOnlyList<VehicleTexture>? set)
{ {
// When texture_mini is missing or only the class/category mini, use the
// skin basename so the list doesn't show the category name as the vehicle.
string name; string name;
if (!string.IsNullOrEmpty(texture.TextureMini) && if (!string.IsNullOrEmpty(texture.TextureMini) &&
!string.Equals(texture.TextureMini, texture.ResolvedClass, StringComparison.OrdinalIgnoreCase)) !string.Equals(texture.TextureMini, texture.ResolvedClass, StringComparison.OrdinalIgnoreCase))
@@ -783,8 +704,6 @@ public partial class Depot : UserControl
return name; return name;
} }
// Clicking "Add" inserts after the selected vehicle (or appends). If the
// texture belongs to an auto-consist set, the whole set is one locked unit.
private void AddTexture(VehicleTexture texture) private void AddTexture(VehicleTexture texture)
{ {
int at = _consist.Count; int at = _consist.Count;
@@ -817,8 +736,6 @@ public partial class Depot : UserControl
RebuildConsist(); RebuildConsist();
} }
// Like the Pascal MatchOccupancy: only powered units get a driver, and only
// when the consist has no crew yet (or the unit is placed at the front).
private void MatchOccupancy(ConsistItem item, int position) private void MatchOccupancy(ConsistItem item, int position)
{ {
item.Driver = eDriverType.Nobody; item.Driver = eDriverType.Nobody;
@@ -839,20 +756,14 @@ public partial class Depot : UserControl
private static bool IsPoweredCategory(string? c) => private static bool IsPoweredCategory(string? c) =>
c is "e" or "s" or "p" or "z" or "a"; c is "e" or "s" or "p" or "z" or "a";
// Every caption here is read from App.Loc when it is built, so a language
// switch has to build them again.
public void RebuildAfterLanguageChange() public void RebuildAfterLanguageChange()
{ {
PopulateCategoryCombo(); // -> class combo -> browser list PopulateCategoryCombo();
PopulateSceneryConsists(); PopulateSceneryConsists();
RebuildConsist(); // -> train stats RebuildConsist();
UpdateDetails(); UpdateDetails();
} }
// ------------------------------------------------------------- scenery combos
// Lists the consists of the scenery currently selected in the Scenarios tab,
// so they can be picked and edited here.
private void PopulateSceneryConsists() private void PopulateSceneryConsists()
{ {
_suppress = true; _suppress = true;
@@ -878,7 +789,7 @@ public partial class Depot : UserControl
Content = ConsistListContent(trainset), Content = ConsistListContent(trainset),
Tag = trainset Tag = trainset
}; };
// right-click: warehouse save/load, clipboard copy/paste, clear-all
var ts = trainset; var ts = trainset;
entry.ContextMenu = ConsistContextMenu.Build( entry.ContextMenu = ConsistContextMenu.Build(
entry, entry,
@@ -894,7 +805,6 @@ public partial class Depot : UserControl
_suppress = false; _suppress = false;
} }
// Selecting a consist on the map loads it into the editor below.
private void SceneryConsistList_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) private void SceneryConsistList_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
{ {
if (_suppress) return; if (_suppress) return;
@@ -911,8 +821,6 @@ public partial class Depot : UserControl
!string.IsNullOrEmpty(trainset.Description) && !string.IsNullOrEmpty(trainset.Description) &&
trainset.Description.TrimStart().StartsWith("-", StringComparison.Ordinal); trainset.Description.TrimStart().StartsWith("-", StringComparison.Ordinal);
// Shift+Delete clears the selected scenery consist (same as the context-menu
// "remove all vehicles" entry).
private void SceneryConsistList_OnKeyDown(object? sender, KeyEventArgs e) private void SceneryConsistList_OnKeyDown(object? sender, KeyEventArgs e)
{ {
if (e.Key == Key.Delete && e.KeyModifiers.HasFlag(KeyModifiers.Shift) && if (e.Key == Key.Delete && e.KeyModifiers.HasFlag(KeyModifiers.Shift) &&
@@ -924,9 +832,6 @@ public partial class Depot : UserControl
} }
} }
// Replaces a scenery consist's vehicles (from a warehouse preset or a pasted
// clipboard consist), keeping the consist's map slot (name/track/offset), then
// loads it into the editor and refreshes the list.
private void ApplyConsist(Trainset target, List<Dynamic> vehicles) private void ApplyConsist(Trainset target, List<Dynamic> vehicles)
{ {
target.Vehicles = vehicles; target.Vehicles = vehicles;
@@ -936,7 +841,6 @@ public partial class Depot : UserControl
PopulateSceneryConsists(); PopulateSceneryConsists();
} }
// Clears every vehicle from a scenery consist.
private void RemoveAllVehicles(Trainset target) private void RemoveAllVehicles(Trainset target)
{ {
target.Vehicles = new List<Dynamic>(); target.Vehicles = new List<Dynamic>();
@@ -946,7 +850,6 @@ public partial class Depot : UserControl
PopulateSceneryConsists(); PopulateSceneryConsists();
} }
// List caption for a scenery consist — grows with the list width (ellipsis).
private static Control ConsistListContent(Trainset trainset) => private static Control ConsistListContent(Trainset trainset) =>
new TextBlock new TextBlock
{ {
@@ -972,8 +875,6 @@ public partial class Depot : UserControl
} }
} }
// ------------------------------------------------------------ consist edits
private Dynamic MakeDynamic(VehicleTexture texture) private Dynamic MakeDynamic(VehicleTexture texture)
{ {
string skin = Base(texture.Skinfile); string skin = Base(texture.Skinfile);
@@ -987,8 +888,8 @@ public partial class Depot : UserControl
MmdFile = string.IsNullOrEmpty(texture.Model) ? skin : texture.Model!, MmdFile = string.IsNullOrEmpty(texture.Model) ? skin : texture.Model!,
Offset = 0f, Offset = 0f,
DriverType = eDriverType.Nobody, DriverType = eDriverType.Nobody,
Coupling = new Coupling { Flags = Coupling.Mechanical | Coupling.BrakePipe }, // 3 Coupling = new Coupling { Flags = Coupling.Mechanical | Coupling.BrakePipe },
HasVelocity = true, // emit a canonical "0" velocity in the trainset entry HasVelocity = true,
MiniName = _db.ResolveMiniName(texture) MiniName = _db.ResolveMiniName(texture)
}; };
} }
@@ -1079,10 +980,9 @@ public partial class Depot : UserControl
if (ReferenceEquals(_selected, item)) if (ReferenceEquals(_selected, item))
SyncStartingVehicle(); SyncStartingVehicle();
RebuildConsist(); RebuildConsist();
AppState.Instance.NotifyChanged(); // crew change may enable/disable Start AppState.Instance.NotifyChanged();
} }
// Breaks a grouped unit into individual single-car items.
private void SplitItem(ConsistItem item) private void SplitItem(ConsistItem item)
{ {
int i = _consist.IndexOf(item); int i = _consist.IndexOf(item);
@@ -1103,8 +1003,6 @@ public partial class Depot : UserControl
RebuildConsist(); RebuildConsist();
} }
// ----------------------------------------------------------- consist render
private void RebuildConsist() private void RebuildConsist()
{ {
consistStack.Children.Clear(); consistStack.Children.Clear();
@@ -1112,8 +1010,7 @@ public partial class Depot : UserControl
{ {
var item = _consist[i]; var item = _consist[i];
consistStack.Children.Add(BuildCard(item, i)); consistStack.Children.Add(BuildCard(item, i));
// Coupler between units, plus a trailing coupler after the last unit
// so end-of-train coupling / lights can be set (#10).
if (i < _consist.Count - 1) if (i < _consist.Count - 1)
consistStack.Children.Add(BuildCoupler(item)); consistStack.Children.Add(BuildCoupler(item));
else else
@@ -1126,9 +1023,6 @@ public partial class Depot : UserControl
WriteBackToScenery(); WriteBackToScenery();
} }
// Flattens the edited consist back onto the bound scenery trainset so the
// change is reflected on the scenery. Driver goes on each unit's lead car;
// a flipped unit writes its cars in reverse order.
private void WriteBackToScenery() private void WriteBackToScenery()
{ {
if (_editingTrainset is null) if (_editingTrainset is null)
@@ -1148,9 +1042,6 @@ public partial class Depot : UserControl
RefreshConsistListLabels(); RefreshConsistListLabels();
} }
// Resolves the .fiz physics for a consist car: the .fiz is named after the
// model, so try the database model first, then the scenery mmd token, then the
// skin file name.
private Physics? PhysicsFor(Dynamic car) private Physics? PhysicsFor(Dynamic car)
{ {
string? dbModel = _db.TextureForSkin(car.SkinFile)?.Model; string? dbModel = _db.TextureForSkin(car.SkinFile)?.Model;
@@ -1159,10 +1050,6 @@ public partial class Depot : UserControl
?? Physics.For(car.DataFolder, car.SkinFile); ?? Physics.For(car.DataFolder, car.SkinFile);
} }
// Recomputes every coupling from the vehicles' .fiz AllowedFlag, like the
// original Starter's AutoCoupler: the coupling is the bits common to the
// facing ends, minus the multiple-unit (control) bit when the control types
// differ. Called when a vehicle is added or replaced.
private void AutoConnectAll() private void AutoConnectAll()
{ {
var flat = new List<(Dynamic car, bool flipped)>(); var flat = new List<(Dynamic car, bool flipped)>();
@@ -1181,10 +1068,9 @@ public partial class Depot : UserControl
var lp = PhysicsFor(left); var lp = PhysicsFor(left);
var rp = PhysicsFor(right); var rp = PhysicsFor(right);
// GetMaxCoupler: the end facing the neighbour (swapped when flipped)
int leftMax = lp == null ? 3 : (lf ? lp.AllowedFlagA : lp.AllowedFlagB); int leftMax = lp == null ? 3 : (lf ? lp.AllowedFlagA : lp.AllowedFlagB);
int rightMax = rp == null ? 3 : (rf ? rp.AllowedFlagB : rp.AllowedFlagA); int rightMax = rp == null ? 3 : (rf ? rp.AllowedFlagB : rp.AllowedFlagA);
int common = leftMax & rightMax; // CommonCoupler = shared flag bits int common = leftMax & rightMax;
string lct = lp == null ? "" : (lf ? lp.ControlTypeA : lp.ControlTypeB); string lct = lp == null ? "" : (lf ? lp.ControlTypeA : lp.ControlTypeB);
string rct = rp == null ? "" : (rf ? rp.ControlTypeB : rp.ControlTypeA); string rct = rp == null ? "" : (rf ? rp.ControlTypeB : rp.ControlTypeA);
@@ -1196,7 +1082,6 @@ public partial class Depot : UserControl
} }
} }
// Train totals — same Pascal mass rules as Scenarios (IncludeVehicleToMass + 200 t retry).
private void UpdateTrainStats() private void UpdateTrainStats()
{ {
if (_editingTrainset is null) if (_editingTrainset is null)
@@ -1315,16 +1200,13 @@ public partial class Depot : UserControl
}; };
card.PointerPressed += (_, e) => card.PointerPressed += (_, e) =>
{ {
// left button only - right-click is reserved for the context popup
if (!e.GetCurrentPoint(card).Properties.IsLeftButtonPressed) if (!e.GetCurrentPoint(card).Properties.IsLeftButtonPressed)
return; return;
// Buttons on the card (split / remove) handle their own clicks.
if (e.Source is Control src && src.GetVisualAncestors().OfType<Button>().Any()) if (e.Source is Control src && src.GetVisualAncestors().OfType<Button>().Any())
return; return;
// Update selection without RebuildConsist so the pressed card stays
// alive for a possible drag. Browser sync waits until click/release
// so BuildList does not run under an active drag capture.
_selected = item; _selected = item;
SyncStartingVehicle(); SyncStartingVehicle();
RefreshCardSelectionChrome(); RefreshCardSelectionChrome();
@@ -1337,8 +1219,7 @@ public partial class Depot : UserControl
DragDrop.SetAllowDrop(card, true); DragDrop.SetAllowDrop(card, true);
card.AddHandler(DragDrop.DragOverEvent, Consist_OnDragOver); card.AddHandler(DragDrop.DragOverEvent, Consist_OnDragOver);
card.AddHandler(DragDrop.DropEvent, (s, e) => ConsistCard_OnDrop(e, index, card)); card.AddHandler(DragDrop.DropEvent, (s, e) => ConsistCard_OnDrop(e, index, card));
// right-click a vehicle: wagon number + load actions (copy load from the
// previous vehicle, fill to the maximum possible load)
card.ContextMenu = BuildCardMenu(card, item); card.ContextMenu = BuildCardMenu(card, item);
return card; return card;
} }
@@ -1356,16 +1237,12 @@ public partial class Depot : UserControl
} }
} }
// Selects, in the left database browser, the category, class and list entry of
// the given vehicle - so clicking a consist car reveals it in the database.
private void SelectInBrowser(Dynamic car) private void SelectInBrowser(Dynamic car)
{ {
var texture = _db.TextureForSkin(car.SkinFile); var texture = _db.TextureForSkin(car.SkinFile);
if (texture is null) if (texture is null)
return; return;
// Prefer the set lead so multi-unit followers (hidden in the browser) still
// select their lead texture / class.
var set = _db.ResolveSet(texture); var set = _db.ResolveSet(texture);
var browserTex = set is { Count: > 0 } ? set[0] : texture; var browserTex = set is { Count: > 0 } ? set[0] : texture;
@@ -1377,8 +1254,6 @@ public partial class Depot : UserControl
}); });
} }
// Mirrors a list pick in the combos, then restores the selection that the
// rebuilt list dropped.
private void SyncCombosTo(VehicleTexture texture) private void SyncCombosTo(VehicleTexture texture)
{ {
if (_syncingCombos || !ApplyFilters(CategoryOf(texture), ClassOf(texture))) if (_syncingCombos || !ApplyFilters(CategoryOf(texture), ClassOf(texture)))
@@ -1387,8 +1262,6 @@ public partial class Depot : UserControl
PostSyncing(() => { BuildList(); SelectListEntry(texture); }); PostSyncing(() => { BuildList(); SelectListEntry(texture); });
} }
// Runs work with the sync flag up, so the selections it makes do not come
// back as another sync.
private void WhileSyncing(Action work) private void WhileSyncing(Action work)
{ {
_syncingCombos = true; _syncingCombos = true;
@@ -1396,8 +1269,6 @@ public partial class Depot : UserControl
finally { _syncingCombos = false; } finally { _syncingCombos = false; }
} }
// Same, on the next dispatcher pass: clearing Items from inside a
// SelectionChanged throws. False when a sync is already pending.
private bool PostSyncing(Action work) private bool PostSyncing(Action work)
{ {
if (_syncingCombos) if (_syncingCombos)
@@ -1421,7 +1292,6 @@ public partial class Depot : UserControl
} }
} }
// The "#<n>" number written into the node name (e.g. EU07-005#11). 0 = none.
private static int GetWagonNumber(Dynamic car) private static int GetWagonNumber(Dynamic car)
{ {
string name = car.Name ?? ""; string name = car.Name ?? "";
@@ -1443,7 +1313,6 @@ public partial class Depot : UserControl
RebuildConsist(); RebuildConsist();
} }
// Small right-click popup to type a wagon number for the vehicle.
private void ShowWagonNumberFlyout(Control anchor, Dynamic car) private void ShowWagonNumberFlyout(Control anchor, Dynamic car)
{ {
var num = new NumericUpDown var num = new NumericUpDown
@@ -1475,8 +1344,6 @@ public partial class Depot : UserControl
return item.Cars.Count > 1 ? $"{key} [{item.Cars.Count}]" : key; return item.Cars.Count > 1 ? $"{key} [{item.Cars.Count}]" : key;
} }
// Coupling indicator between two units (or trailing after the last unit).
// Click it to open a flyout with the 8-bit coupling editor for the unit's last car.
private Control BuildCoupler(ConsistItem item, bool trailing = false) private Control BuildCoupler(ConsistItem item, bool trailing = false)
{ {
var glyph = new TextBlock var glyph = new TextBlock
@@ -1599,7 +1466,6 @@ public partial class Depot : UserControl
return btn; return btn;
} }
// Brakes / Loads editors for the selected unit (applied to all of its cars).
private void UpdateDetails() private void UpdateDetails()
{ {
brakesPanel.Children.Clear(); brakesPanel.Children.Clear();
@@ -1611,7 +1477,6 @@ public partial class Depot : UserControl
: $"{App.Loc["SelectedVehicle"]}: " + : $"{App.Loc["SelectedVehicle"]}: " +
(_selected.Grouped ? UnitLabel(_selected) : _selected.Cars[0].Name); (_selected.Grouped ? UnitLabel(_selected) : _selected.Cars[0].Name);
// whole-consist load tools sit at the top of the Loads tab, always available
loadsPanel.Children.Add(BuildConsistLoadTools()); loadsPanel.Children.Add(BuildConsistLoadTools());
if (_selected is null) if (_selected is null)
@@ -1627,9 +1492,6 @@ public partial class Depot : UserControl
BuildDamageEditor(_selected); BuildDamageEditor(_selected);
} }
// ---------------------------------------------------------------- load tools
// The per-vehicle right-click menu: wagon number plus the two load shortcuts.
private ContextMenu BuildCardMenu(Control anchor, ConsistItem item) private ContextMenu BuildCardMenu(Control anchor, ConsistItem item)
{ {
var menu = new ContextMenu(); var menu = new ContextMenu();
@@ -1662,7 +1524,6 @@ public partial class Depot : UserControl
return menu; return menu;
} }
// Button bar that applies a load operation to every vehicle in the consist.
private Control BuildConsistLoadTools() private Control BuildConsistLoadTools()
{ {
var panel = new StackPanel { Spacing = 4, Margin = new Thickness(0, 0, 0, 4) }; var panel = new StackPanel { Spacing = 4, Margin = new Thickness(0, 0, 0, 4) };
@@ -1687,15 +1548,12 @@ public partial class Depot : UserControl
return b; return b;
} }
// True when the unit's lead car can actually carry cargo.
private bool IsLoadable(ConsistItem item) private bool IsLoadable(ConsistItem item)
{ {
var p = PhysicsFor(item.Cars[0]); var p = PhysicsFor(item.Cars[0]);
return p != null && (p.MaxLoad > 0 || !string.IsNullOrWhiteSpace(p.LoadAccepted)); return p != null && (p.MaxLoad > 0 || !string.IsNullOrWhiteSpace(p.LoadAccepted));
} }
// The largest cargo this car accepts (.fiz MaxLoad), 1000 as a fallback for a
// car that lists accepted cargo but no explicit maximum, 0 when it can't load.
private int MaxLoadFor(Dynamic car) private int MaxLoadFor(Dynamic car)
{ {
var p = PhysicsFor(car); var p = PhysicsFor(car);
@@ -1704,7 +1562,6 @@ public partial class Depot : UserControl
return string.IsNullOrWhiteSpace(p.LoadAccepted) ? 0 : 1000; return string.IsNullOrWhiteSpace(p.LoadAccepted) ? 0 : 1000;
} }
// Cargo types this car accepts (.fiz LoadAccepted), else the global list.
private List<string> AcceptedTypesFor(Dynamic car) private List<string> AcceptedTypesFor(Dynamic car)
{ {
var p = PhysicsFor(car); var p = PhysicsFor(car);
@@ -1713,8 +1570,6 @@ public partial class Depot : UserControl
return LoadTypes().ToList(); return LoadTypes().ToList();
} }
// Copies the previous unit's load (type + amount) onto this unit, clamped to
// each car's own maximum.
private void CopyLoadFromPrevious(ConsistItem item) private void CopyLoadFromPrevious(ConsistItem item)
{ {
int idx = _consist.IndexOf(item); int idx = _consist.IndexOf(item);
@@ -1731,8 +1586,6 @@ public partial class Depot : UserControl
RebuildConsist(); RebuildConsist();
} }
// Fills one unit's cars to their maximum load (keeping the cargo type, or
// assigning the first accepted one when none is set yet).
private void SetUnitMaxLoad(ConsistItem item) private void SetUnitMaxLoad(ConsistItem item)
{ {
foreach (var c in item.Cars) foreach (var c in item.Cars)
@@ -1740,7 +1593,6 @@ public partial class Depot : UserControl
RebuildConsist(); RebuildConsist();
} }
// Random cargo type for every loadable car in the consist, filled to the max.
private void RandomLoadTypeForConsist() private void RandomLoadTypeForConsist()
{ {
foreach (var c in _consist.SelectMany(u => u.Cars)) foreach (var c in _consist.SelectMany(u => u.Cars))
@@ -1756,7 +1608,6 @@ public partial class Depot : UserControl
RebuildConsist(); RebuildConsist();
} }
// Every loadable car in the consist filled to its maximum.
private void MaxAmountForConsist() private void MaxAmountForConsist()
{ {
foreach (var c in _consist.SelectMany(u => u.Cars)) foreach (var c in _consist.SelectMany(u => u.Cars))
@@ -1764,9 +1615,6 @@ public partial class Depot : UserControl
RebuildConsist(); RebuildConsist();
} }
// Random amount (per car) for every loadable car in the consist, keeping the
// cargo type (assigning the first accepted one when none is set). Lets you roll
// a different tonnage for each wagon with one click.
private void RandomAmountForConsist() private void RandomAmountForConsist()
{ {
foreach (var c in _consist.SelectMany(u => u.Cars)) foreach (var c in _consist.SelectMany(u => u.Cars))
@@ -1783,7 +1631,6 @@ public partial class Depot : UserControl
RebuildConsist(); RebuildConsist();
} }
// Fills a single car to its maximum load (no-op for non-loadable cars).
private void FillToMax(Dynamic car) private void FillToMax(Dynamic car)
{ {
int max = MaxLoadFor(car); int max = MaxLoadFor(car);
@@ -1800,7 +1647,6 @@ public partial class Depot : UserControl
Text = text, Opacity = 0.6, FontSize = 12, TextWrapping = TextWrapping.Wrap Text = text, Opacity = 0.6, FontSize = 12, TextWrapping = TextWrapping.Wrap
}; };
// Row of "<label> <control>".
private static Control LabeledRow(string label, Control control) private static Control LabeledRow(string label, Control control)
{ {
var sp = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; var sp = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
@@ -1816,7 +1662,6 @@ public partial class Depot : UserControl
private static void AddOption(ComboBox combo, string content, string? tag) private static void AddOption(ComboBox combo, string content, string? tag)
=> combo.Items.Add(new ComboBoxItem { Content = content, Tag = tag }); => combo.Items.Add(new ComboBoxItem { Content = content, Tag = tag });
// Brake mode / load adaptation / switch, written into each car's coupling "B" param.
private void BuildBrakesEditor(ConsistItem item) private void BuildBrakesEditor(ConsistItem item)
{ {
var brake = item.Cars[0].Coupling.GetBrake(); var brake = item.Cars[0].Coupling.GetBrake();
@@ -1863,7 +1708,6 @@ public partial class Depot : UserControl
brakesPanel.Children.Add(LabeledRow(App.Loc["BrakeSwitch"], switchCombo)); brakesPanel.Children.Add(LabeledRow(App.Loc["BrakeSwitch"], switchCombo));
} }
// Descriptive labels (what the option does), per the wiki - not the raw code.
private static string BrakeModeLabel(string code) => code switch private static string BrakeModeLabel(string code) => code switch
{ {
"G" => App.Loc["BrakeFreight"], "G" => App.Loc["BrakeFreight"],
@@ -1893,7 +1737,6 @@ public partial class Depot : UserControl
_ => App.Loc["None"] _ => App.Loc["None"]
}; };
// Wheel-damage editor: sway / flat-spot parameters (the "W" coupling prefix).
private void BuildDamageEditor(ConsistItem item) private void BuildDamageEditor(ConsistItem item)
{ {
var w = item.Cars[0].Coupling.GetWheels() ?? new WheelSettings(); var w = item.Cars[0].Coupling.GetWheels() ?? new WheelSettings();
@@ -1962,31 +1805,24 @@ public partial class Depot : UserControl
damagePanel.Children.Add(grid); damagePanel.Children.Add(grid);
} }
// Cargo type + amount, written into each car's node::dynamic trailing params.
private void BuildLoadsEditor(ConsistItem item) private void BuildLoadsEditor(ConsistItem item)
{ {
var lead = item.Cars[0]; var lead = item.Cars[0];
var phys = PhysicsFor(lead); var phys = PhysicsFor(lead);
// Prefer the cargo this vehicle actually accepts (.fiz LoadAccepted);
// otherwise offer everything from data/load_weights.txt.
var available = phys != null && !string.IsNullOrWhiteSpace(phys.LoadAccepted) var available = phys != null && !string.IsNullOrWhiteSpace(phys.LoadAccepted)
? phys.LoadAccepted.Split(',', ';').Select(s => s.Trim()).Where(s => s.Length > 0).ToList() ? phys.LoadAccepted.Split(',', ';').Select(s => s.Trim()).Where(s => s.Length > 0).ToList()
: LoadTypes().ToList(); : LoadTypes().ToList();
// Keep a load already set on the vehicle selectable even when it is not in
// the accepted/known list (e.g. a custom load read from the scenery).
if (!string.IsNullOrEmpty(lead.LoadType) && if (!string.IsNullOrEmpty(lead.LoadType) &&
!available.Contains(lead.LoadType!, StringComparer.OrdinalIgnoreCase)) !available.Contains(lead.LoadType!, StringComparer.OrdinalIgnoreCase))
available.Insert(0, lead.LoadType!); available.Insert(0, lead.LoadType!);
// Dropdown of available cargo: the leading "(none)" entry clears the load.
var typeCombo = new ComboBox { FontSize = 12, MinWidth = 200 }; var typeCombo = new ComboBox { FontSize = 12, MinWidth = 200 };
typeCombo.Items.Add(new ComboBoxItem { Content = App.Loc["None"], Tag = null }); typeCombo.Items.Add(new ComboBoxItem { Content = App.Loc["None"], Tag = null });
foreach (var name in available) foreach (var name in available)
typeCombo.Items.Add(new ComboBoxItem { Content = name, Tag = name }); typeCombo.Items.Add(new ComboBoxItem { Content = name, Tag = name });
// Pre-select the vehicle's current load (or "(none)").
typeCombo.SelectedIndex = 0; typeCombo.SelectedIndex = 0;
if (!string.IsNullOrEmpty(lead.LoadType)) if (!string.IsNullOrEmpty(lead.LoadType))
foreach (var obj in typeCombo.Items) foreach (var obj in typeCombo.Items)
@@ -1997,7 +1833,6 @@ public partial class Depot : UserControl
break; break;
} }
// Cap the amount at the .fiz MaxLoad when it is defined.
int maxLoad = phys != null && phys.MaxLoad > 0 ? phys.MaxLoad : 1000; int maxLoad = phys != null && phys.MaxLoad > 0 ? phys.MaxLoad : 1000;
var countBox = new NumericUpDown var countBox = new NumericUpDown
{ {
@@ -2011,16 +1846,14 @@ public partial class Depot : UserControl
int count = (int)(countBox.Value ?? 0); int count = (int)(countBox.Value ?? 0);
foreach (var c in item.Cars) foreach (var c in item.Cars)
{ {
// "(none)" -> no cargo at all; otherwise store the chosen type/amount.
c.LoadType = string.IsNullOrEmpty(type) ? null : type; c.LoadType = string.IsNullOrEmpty(type) ? null : type;
c.LoadCount = string.IsNullOrEmpty(type) ? 0 : count; c.LoadCount = string.IsNullOrEmpty(type) ? 0 : count;
if (c.LoadCount > 0) if (c.LoadCount > 0)
c.HasVelocity = true; // velocity token must precede loadcount c.HasVelocity = true;
} }
} }
// Wire the handlers only after the initial selection so we don't apply
// (and mark the consist dirty) while merely populating the editor.
typeCombo.SelectionChanged += (_, _) => Apply(); typeCombo.SelectionChanged += (_, _) => Apply();
countBox.ValueChanged += (_, _) => Apply(); countBox.ValueChanged += (_, _) => Apply();
@@ -2028,9 +1861,6 @@ public partial class Depot : UserControl
loadsPanel.Children.Add(LabeledRow(App.Loc["LoadCount"], countBox)); loadsPanel.Children.Add(LabeledRow(App.Loc["LoadCount"], countBox));
} }
// Cargo names + per-unit weights parsed once from data/load_weights.txt (the
// file the simulator and the original Starter use). Each line is
// "<name>: <weight>".
private static List<string>? _loadTypes; private static List<string>? _loadTypes;
private static Dictionary<string, int>? _loadWeights; private static Dictionary<string, int>? _loadWeights;
@@ -2060,7 +1890,7 @@ public partial class Depot : UserControl
} }
} }
} }
catch { /* missing / unreadable - just no suggestions */ } catch { }
_loadTypes = names.Distinct(StringComparer.OrdinalIgnoreCase) _loadTypes = names.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase) .OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
@@ -2074,18 +1904,12 @@ public partial class Depot : UserControl
return _loadTypes!; return _loadTypes!;
} }
// Per-unit weight (kg) of a cargo, defaulting to 1000 like the original Starter.
private static int LoadWeight(string? name) private static int LoadWeight(string? name)
{ {
EnsureLoads(); EnsureLoads();
return !string.IsNullOrEmpty(name) && _loadWeights!.TryGetValue(name!, out int w) ? w : 1000; return !string.IsNullOrEmpty(name) && _loadWeights!.TryGetValue(name!, out int w) ? w : 1000;
} }
// ------------------------------------------------------------------- minis
// Decodes the miniature down to the requested display height and caches it,
// so the same skin is never decoded twice and full-resolution bitmaps never
// stay resident in memory.
private Bitmap? GetMiniBitmap(string? name, int height) private Bitmap? GetMiniBitmap(string? name, int height)
{ {
if (string.IsNullOrEmpty(name)) if (string.IsNullOrEmpty(name))
@@ -2110,8 +1934,6 @@ public partial class Depot : UserControl
return bmp; return bmp;
} }
// ------------------------------------------------------------------ events
private void SearchBox_OnTextChanged(object? sender, TextChangedEventArgs e) private void SearchBox_OnTextChanged(object? sender, TextChangedEventArgs e)
{ {
if (_suppress || _searchTimer is null) return; if (_suppress || _searchTimer is null) return;
@@ -2268,8 +2090,7 @@ public partial class Depot : UserControl
FontWeight = FontWeight.Bold, FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center HorizontalAlignment = HorizontalAlignment.Center
}, },
// DockPanel: in a horizontal StackPanel the caption gets infinite
// width, never wraps and shoves the spinner off the edge.
new DockPanel new DockPanel
{ {
LastChildFill = true, LastChildFill = true,
@@ -2402,7 +2223,6 @@ public partial class Depot : UserControl
private void RandomOrientButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => private void RandomOrientButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) =>
RandomizeOrientation(); RandomizeOrientation();
// Shuffle wagon order from the selected unit to the end (multi-unit groups stay put).
private void RandomizeOrder() private void RandomizeOrder()
{ {
int start = _selected != null ? _consist.IndexOf(_selected) : 0; int start = _selected != null ? _consist.IndexOf(_selected) : 0;
@@ -2433,7 +2253,6 @@ public partial class Depot : UserControl
RebuildConsist(); RebuildConsist();
} }
// Randomly flip units from the selected one to the end of the consist.
private void RandomizeOrientation() private void RandomizeOrientation()
{ {
int start = _selected != null ? _consist.IndexOf(_selected) : 0; int start = _selected != null ? _consist.IndexOf(_selected) : 0;
@@ -2448,8 +2267,6 @@ public partial class Depot : UserControl
RebuildConsist(); RebuildConsist();
} }
// ── drag / drop (browser → consist, reorder inside consist) ────────────
private void MiniPreview_OnPointerPressed(object? sender, PointerPressedEventArgs e) private void MiniPreview_OnPointerPressed(object? sender, PointerPressedEventArgs e)
{ {
if (_browserSelected is null || !e.GetCurrentPoint(miniPreviewPanel).Properties.IsLeftButtonPressed) if (_browserSelected is null || !e.GetCurrentPoint(miniPreviewPanel).Properties.IsLeftButtonPressed)
@@ -2461,7 +2278,7 @@ public partial class Depot : UserControl
{ {
if (!e.GetCurrentPoint(vehicleListBox).Properties.IsLeftButtonPressed) if (!e.GetCurrentPoint(vehicleListBox).Properties.IsLeftButtonPressed)
return; return;
// Resolve the row under the pointer so a drag can start before selection settles.
var hit = e.Source as Control; var hit = e.Source as Control;
while (hit != null && hit is not ListBoxItem) while (hit != null && hit is not ListBoxItem)
hit = hit.Parent as Control; hit = hit.Parent as Control;
@@ -2514,7 +2331,7 @@ public partial class Depot : UserControl
var press = _pendingDragPress; var press = _pendingDragPress;
var texture = _pendingDragTexture; var texture = _pendingDragTexture;
int consistIndex = _pendingDragConsistIndex; int consistIndex = _pendingDragConsistIndex;
// Keep browser sync cancelled for the drag session; click path uses it.
_pendingBrowserSync = null; _pendingBrowserSync = null;
_pendingDragPress = null; _pendingDragPress = null;
_dragSessionActive = true; _dragSessionActive = true;
@@ -2565,14 +2382,14 @@ public partial class Depot : UserControl
{ {
var sync = _pendingBrowserSync; var sync = _pendingBrowserSync;
ClearPendingDrag(); ClearPendingDrag();
// Plain click on a consist card: reveal it in the vehicle browser.
if (sync is { Cars.Count: > 0 }) if (sync is { Cars.Count: > 0 })
SelectInBrowser(sync.Cars[0]); SelectInBrowser(sync.Cars[0]);
} }
private void Consist_OnDragOver(object? sender, DragEventArgs e) private void Consist_OnDragOver(object? sender, DragEventArgs e)
{ {
// Payload is kept in fields set before DoDragDropAsync; formats are markers.
if (_dragTexture != null || _dragConsistIndex >= 0) if (_dragTexture != null || _dragConsistIndex >= 0)
{ {
e.DragEffects = _dragConsistIndex >= 0 ? DragDropEffects.Move : DragDropEffects.Copy; e.DragEffects = _dragConsistIndex >= 0 ? DragDropEffects.Move : DragDropEffects.Copy;
@@ -2621,7 +2438,7 @@ public partial class Depot : UserControl
private void MoveConsistUnit(int from, int to) private void MoveConsistUnit(int from, int to)
{ {
if (from < 0 || from >= _consist.Count) return; if (from < 0 || from >= _consist.Count) return;
if (to > from) to--; // account for removal shifting indices if (to > from) to--;
to = Math.Clamp(to, 0, _consist.Count - 1); to = Math.Clamp(to, 0, _consist.Count - 1);
if (from == to) return; if (from == to) return;

View File

@@ -12,7 +12,7 @@ public enum MessageBoxButtons
public static class MessageBox public static class MessageBox
{ {
/// <summary>Simple Yes/No dialog. Returns true for Yes.</summary>
public static async Task<bool> Show(Window owner, string message, string title, MessageBoxButtons _) public static async Task<bool> Show(Window owner, string message, string title, MessageBoxButtons _)
{ {
var win = new Window var win = new Window

View File

@@ -22,26 +22,20 @@ public partial class Scenarios : UserControl
{ {
public List<Scenery> Sceneries; public List<Scenery> Sceneries;
// scenery whose weather the form is currently editing
private Scenery? _currentScenery; private Scenery? _currentScenery;
// suppresses weather change handlers while the form is being populated
private bool _loadingWeather; private bool _loadingWeather;
// "Today" season: the day field only previews the current day-of-year; the
// stored Day stays 0 so the scenery keeps using the live date.
private bool _todaySeason; private bool _todaySeason;
// suppresses the list-filter handlers while their state is restored at startup
private bool _loadingFilters; private bool _loadingFilters;
public Scenarios() public Scenarios()
{ {
InitializeComponent(); InitializeComponent();
// Sceneries are parsed once at startup (behind the splash).
Sceneries = GameData.Instance.Sceneries; Sceneries = GameData.Instance.Sceneries;
// Restore the persisted list filters before building the tree/list. Guarded
// so assigning IsChecked here doesn't run the rebuild handlers prematurely.
_loadingFilters = true; _loadingFilters = true;
showAiCheck.IsChecked = StarterNG.Classes.Settings.Instance.ShowAiVehicles; showAiCheck.IsChecked = StarterNG.Classes.Settings.Instance.ShowAiVehicles;
drivableOnlyCheck.IsChecked = StarterNG.Classes.Settings.Instance.DrivableOnly; drivableOnlyCheck.IsChecked = StarterNG.Classes.Settings.Instance.DrivableOnly;
@@ -51,9 +45,6 @@ public partial class Scenarios : UserControl
BuildSceneryTree(); BuildSceneryTree();
RestoreLastScenery(); RestoreLastScenery();
// refresh the consist preview when the view is shown again (e.g. after
// editing the consist in the depot). Switching tabs only toggles IsVisible
// (the view stays in the tree), so listen for that too.
AttachedToVisualTree += (_, _) => OnReentered(); AttachedToVisualTree += (_, _) => OnReentered();
PropertyChanged += (_, e) => PropertyChanged += (_, e) =>
{ {
@@ -62,20 +53,12 @@ public partial class Scenarios : UserControl
}; };
} }
// Called whenever this view is shown again (tab switch / re-attach). Edits made
// in the depot mutate the same Trainset objects, so refresh both the consist
// preview and the vehicle-list captions that depend on them.
private void OnReentered() private void OnReentered()
{ {
RefreshVehicleLabels(); RefreshVehicleLabels();
RefreshSelectedConsist(); RefreshSelectedConsist();
} }
// (Re)builds the scenery tree, honouring the "show archival" switch. Archival
// sceneries (those declaring //$a) are skipped when the switch is off; group
// folders that end up empty are omitted. The Tag keeps each node's original
// scenery index, so the vehicles list stays aligned. Top-level nodes and group
// children are sorted alphabetically.
private void BuildSceneryTree() private void BuildSceneryTree()
{ {
sceneryList.Items.Clear(); sceneryList.Items.Clear();
@@ -136,8 +119,6 @@ public partial class Scenarios : UserControl
sceneryList.Items.Add(item); sceneryList.Items.Add(item);
} }
// Restores the scenery selected last session (starter.last.scenery).
// Like Pascal ScenariosList: fall back to the first leaf when nothing matches.
private void RestoreLastScenery() private void RestoreLastScenery()
{ {
string last = StarterNG.Classes.Settings.Instance.LastScenery; string last = StarterNG.Classes.Settings.Instance.LastScenery;
@@ -208,7 +189,7 @@ public partial class Scenarios : UserControl
private void PersistLastScenery(Scenery scenery) private void PersistLastScenery(Scenery scenery)
{ {
// Pascal InitSCN stores the tree node text (//$n or file name).
string name = scenery.DisplayName; string name = scenery.DisplayName;
if (string.Equals(StarterNG.Classes.Settings.Instance.LastScenery, name, StringComparison.OrdinalIgnoreCase)) if (string.Equals(StarterNG.Classes.Settings.Instance.LastScenery, name, StringComparison.OrdinalIgnoreCase))
return; return;
@@ -228,10 +209,6 @@ public partial class Scenarios : UserControl
TextWrapping = Avalonia.Media.TextWrapping.NoWrap TextWrapping = Avalonia.Media.TextWrapping.NoWrap
}; };
// Fills the Vehicles list for a scenery, honouring the two filters:
// - "AI vehicles" off hides consists whose //$o description starts with '-';
// - "Drivable only" on hides decoration consists (the //$decor flag).
// Empty trainsets stay visible (label shows track), matching the Pascal starter.
private void PopulateVehicleList(Scenery scn) private void PopulateVehicleList(Scenery scn)
{ {
vehicleList.Items.Clear(); vehicleList.Items.Clear();
@@ -251,7 +228,7 @@ public partial class Scenarios : UserControl
Content = TrainsetListContent(trainset), Content = TrainsetListContent(trainset),
Tag = i Tag = i
}; };
// right-click: save/load to the warehouse + clipboard copy/paste
listItem.ContextMenu = ConsistContextMenu.Build( listItem.ContextMenu = ConsistContextMenu.Build(
listItem, listItem,
() => trainset, () => trainset,
@@ -263,13 +240,10 @@ public partial class Scenarios : UserControl
vehicleList.SelectedIndex = 0; vehicleList.SelectedIndex = 0;
} }
// A computer-driven consist marks its //$o description with a leading '-'.
private static bool IsAiTrainset(Trainset trainset) => private static bool IsAiTrainset(Trainset trainset) =>
!string.IsNullOrEmpty(trainset.Description) && !string.IsNullOrEmpty(trainset.Description) &&
trainset.Description.TrimStart().StartsWith("-", StringComparison.Ordinal); trainset.Description.TrimStart().StartsWith("-", StringComparison.Ordinal);
// AI/player vehicle filter toggled: persist the choice and rebuild the list
// for the currently selected scenery.
private void VehicleFilter_OnChanged(object? sender, RoutedEventArgs e) private void VehicleFilter_OnChanged(object? sender, RoutedEventArgs e)
{ {
if (_loadingFilters) return; if (_loadingFilters) return;
@@ -282,7 +256,6 @@ public partial class Scenarios : UserControl
PopulateVehicleList(Sceneries[tag]); PopulateVehicleList(Sceneries[tag]);
} }
// Archival-scenery switch toggled: persist the choice and rebuild the tree.
private void ArchivalSwitch_OnChanged(object? sender, RoutedEventArgs e) private void ArchivalSwitch_OnChanged(object? sender, RoutedEventArgs e)
{ {
if (_loadingFilters) return; if (_loadingFilters) return;
@@ -294,8 +267,6 @@ public partial class Scenarios : UserControl
RestoreLastScenery(); RestoreLastScenery();
} }
// Replaces a consist's vehicles (from a loaded warehouse preset or a pasted
// clipboard consist) and refreshes the row caption / consist preview.
private void ApplyConsist(ListBoxItem item, Trainset trainset, List<Dynamic> vehicles) private void ApplyConsist(ListBoxItem item, Trainset trainset, List<Dynamic> vehicles)
{ {
trainset.Vehicles = vehicles; trainset.Vehicles = vehicles;
@@ -304,9 +275,6 @@ public partial class Scenarios : UserControl
ShowConsist(trainset); ShowConsist(trainset);
} }
// Rebuilds the caption of each entry in the Vehicles list from its trainset's
// current vehicles, so a consist edited in the depot is reflected here too.
// Selection and order are preserved (only the text is updated).
private void RefreshVehicleLabels() private void RefreshVehicleLabels()
{ {
if (sceneryList.SelectedItem is not TreeViewItem { Tag: int tag } || tag >= Sceneries.Count) if (sceneryList.SelectedItem is not TreeViewItem { Tag: int tag } || tag >= Sceneries.Count)
@@ -339,20 +307,16 @@ public partial class Scenarios : UserControl
} }
Scenery selectedScn = Sceneries[tag]; Scenery selectedScn = Sceneries[tag];
// scenery-level info (distinct from the per-consist scenario description)
ShowSceneryInfo(selectedScn); ShowSceneryInfo(selectedScn);
ShowWeather(selectedScn); ShowWeather(selectedScn);
PersistLastScenery(selectedScn); PersistLastScenery(selectedScn);
// no consist chosen yet for this scenery
missionDescription.Text = ""; missionDescription.Text = "";
timetableContent.Text = App.Loc["NoTimetable"]; timetableContent.Text = App.Loc["NoTimetable"];
// add the (filtered) trainsets to the Vehicles list
PopulateVehicleList(selectedScn); PopulateVehicleList(selectedScn);
} }
// Shows the selected scenery's description (//$d) and 1:1 image (//$i).
private void ShowSceneryInfo(Scenery scenery) private void ShowSceneryInfo(Scenery scenery)
{ {
SceneryI18n.LoadFor(scenery, App.Loc.CurrentLangCode); SceneryI18n.LoadFor(scenery, App.Loc.CurrentLangCode);
@@ -471,10 +435,9 @@ public partial class Scenarios : UserControl
{ {
Process.Start(new ProcessStartInfo(full) { UseShellExecute = true }); Process.Start(new ProcessStartInfo(full) { UseShellExecute = true });
} }
catch { /* no handler */ } catch { }
} }
/// <summary>Rebuild tree labels after UI / scenery i18n language change.</summary>
public void RebuildAfterLanguageChange() public void RebuildAfterLanguageChange()
{ {
string? selected = AppState.Instance.CurrentScenery?.Path; string? selected = AppState.Instance.CurrentScenery?.Path;
@@ -487,7 +450,7 @@ public partial class Scenarios : UserControl
} }
if (AppState.Instance.CurrentScenery != null) if (AppState.Instance.CurrentScenery != null)
ShowSceneryInfo(AppState.Instance.CurrentScenery); ShowSceneryInfo(AppState.Instance.CurrentScenery);
RefreshSelectedConsist(); // stats labels (length / mass / track) RefreshSelectedConsist();
} }
private async void ReloadSceneries_OnClick(object? sender, RoutedEventArgs e) private async void ReloadSceneries_OnClick(object? sender, RoutedEventArgs e)
@@ -528,19 +491,17 @@ public partial class Scenarios : UserControl
private void VehicleList_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) private void VehicleList_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
{ {
// get selected scenery
var tItem = sceneryList.SelectedItem as TreeViewItem; var tItem = sceneryList.SelectedItem as TreeViewItem;
if (tItem?.Tag is not int tTag) if (tItem?.Tag is not int tTag)
return; return;
Scenery selectedScn = Sceneries[tTag]; 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) if (vItem?.Tag is not int vTag)
return; return;
Trainset selectedTrainset = selectedScn.Trainsets[vTag]; Trainset selectedTrainset = selectedScn.Trainsets[vTag];
// share the selection so the depot edits this consist in place
AppState.Instance.CurrentScenery = selectedScn; AppState.Instance.CurrentScenery = selectedScn;
AppState.Instance.CurrentTrainset = selectedTrainset; AppState.Instance.CurrentTrainset = selectedTrainset;
AppState.Instance.StartingVehicleName = TrainsetDisplay.DefaultStartingVehicle(selectedTrainset); AppState.Instance.StartingVehicleName = TrainsetDisplay.DefaultStartingVehicle(selectedTrainset);
@@ -549,9 +510,6 @@ public partial class Scenarios : UserControl
ShowTimetable(selectedScn, selectedTrainset); ShowTimetable(selectedScn, selectedTrainset);
} }
// Loads the scenery's weather into the editable form (Weather tab). Editing a
// control writes the value back onto the scenery and marks it dirty, so the
// change is injected into the exported .scn's config block on launch.
private void ShowWeather(Scenery scenery) private void ShowWeather(Scenery scenery)
{ {
_currentScenery = scenery; _currentScenery = scenery;
@@ -565,7 +523,7 @@ public partial class Scenarios : UserControl
weatherFog.Value = System.Math.Clamp(scenery.FogEnd, 0, 10000); weatherFog.Value = System.Math.Clamp(scenery.FogEnd, 0, 10000);
SelectByTag(weatherSeason, scenery.Day.ToString()); SelectByTag(weatherSeason, scenery.Day.ToString());
SelectByTag(weatherOvercast, scenery.Overcast.ToString(CultureInfo.InvariantCulture)); SelectByTag(weatherOvercast, scenery.Overcast.ToString(CultureInfo.InvariantCulture));
// Day 0 maps to the "Today" season entry: preview the live day-of-year.
ApplyTodayPreview(scenery.Day == 0); ApplyTodayPreview(scenery.Day == 0);
UpdateWeatherLabels(); UpdateWeatherLabels();
} }
@@ -575,7 +533,6 @@ public partial class Scenarios : UserControl
} }
} }
// Reads the form back into the current scenery and flags it for export rewrite.
private void CaptureWeather() private void CaptureWeather()
{ {
if (_loadingWeather || _currentScenery is null) if (_loadingWeather || _currentScenery is null)
@@ -584,8 +541,7 @@ public partial class Scenarios : UserControl
var scn = _currentScenery; var scn = _currentScenery;
if (weatherTime.SelectedTime is { } t) if (weatherTime.SelectedTime is { } t)
scn.ScenarioTimeOverride = $"{t.Hours:D2}:{t.Minutes:D2}"; scn.ScenarioTimeOverride = $"{t.Hours:D2}:{t.Minutes:D2}";
// In "Today" mode the day field is only a preview, so keep the stored day
// at 0 (the scenery then follows the live date).
scn.Day = _todaySeason ? 0 : (int)(weatherDay.Value ?? 0); scn.Day = _todaySeason ? 0 : (int)(weatherDay.Value ?? 0);
scn.Temperature = weatherTemp.Value; scn.Temperature = weatherTemp.Value;
scn.FogEnd = (int)weatherFog.Value; scn.FogEnd = (int)weatherFog.Value;
@@ -614,18 +570,14 @@ public partial class Scenarios : UserControl
combo.SelectedItem = null; combo.SelectedItem = null;
} }
// --- weather form event handlers ---
private void WeatherTime_OnChanged(object? sender, TimePickerSelectedValueChangedEventArgs e) => CaptureWeather(); private void WeatherTime_OnChanged(object? sender, TimePickerSelectedValueChangedEventArgs e) => CaptureWeather();
// "Current time" button: drop the live wall-clock time into the picker (the
// SelectedTimeChanged handler then captures it onto the scenery).
private void WeatherTimeNow_OnClick(object? sender, RoutedEventArgs e) private void WeatherTimeNow_OnClick(object? sender, RoutedEventArgs e)
{ {
var now = DateTime.Now; var now = DateTime.Now;
weatherTime.SelectedTime = new TimeSpan(now.Hour, now.Minute, 0); weatherTime.SelectedTime = new TimeSpan(now.Hour, now.Minute, 0);
} }
// Pascal actRestoreWeather — reload weather fields from the SCN snapshot.
private void WeatherRestore_OnClick(object? sender, RoutedEventArgs e) private void WeatherRestore_OnClick(object? sender, RoutedEventArgs e)
{ {
if (_currentScenery is null) return; if (_currentScenery is null) return;
@@ -633,8 +585,6 @@ public partial class Scenarios : UserControl
ShowWeather(_currentScenery); ShowWeather(_currentScenery);
} }
// Parses a stored "hh:mm" string into a TimeSpan, or null when it is missing
// or malformed (so the picker simply shows no selection).
private static TimeSpan? ParseTime(string? text) private static TimeSpan? ParseTime(string? text)
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
@@ -647,8 +597,6 @@ public partial class Scenarios : UserControl
return null; return null;
} }
// Toggles "Today" preview: the day field shows the live day-of-year (read-only)
// and CaptureWeather keeps the stored Day at 0; any other season re-enables it.
private void ApplyTodayPreview(bool today) private void ApplyTodayPreview(bool today)
{ {
_todaySeason = today; _todaySeason = today;
@@ -668,7 +616,6 @@ public partial class Scenarios : UserControl
private void WeatherDay_OnChanged(object? sender, NumericUpDownValueChangedEventArgs e) => CaptureWeather(); private void WeatherDay_OnChanged(object? sender, NumericUpDownValueChangedEventArgs e) => CaptureWeather();
// Picking a season presets the day-of-year, then captures the form.
private void WeatherSeason_OnChanged(object? sender, SelectionChangedEventArgs e) private void WeatherSeason_OnChanged(object? sender, SelectionChangedEventArgs e)
{ {
if (_loadingWeather) if (_loadingWeather)
@@ -676,7 +623,7 @@ public partial class Scenarios : UserControl
if ((weatherSeason.SelectedItem as ComboBoxItem)?.Tag is string tag && if ((weatherSeason.SelectedItem as ComboBoxItem)?.Tag is string tag &&
int.TryParse(tag, out int day)) int.TryParse(tag, out int day))
{ {
if (day == 0) // "Today" - preview the live day-of-year, keep stored day at 0 if (day == 0)
{ {
ApplyTodayPreview(true); ApplyTodayPreview(true);
} }
@@ -691,9 +638,6 @@ public partial class Scenarios : UserControl
CaptureWeather(); CaptureWeather();
} }
// Reads and shows the timetable file referenced by the trainset (its first
// "trainset" token is the timetable name). Shows a note when none is set or
// the file cannot be found.
private void ShowTimetable(Scenery scenery, Trainset trainset) private void ShowTimetable(Scenery scenery, Trainset trainset)
{ {
string? path = ResolveTimetablePath(scenery, trainset.Name); string? path = ResolveTimetablePath(scenery, trainset.Name);
@@ -713,15 +657,14 @@ public partial class Scenarios : UserControl
} }
} }
// Probes the usual locations for a timetable file named after the trainset.
private static string? ResolveTimetablePath(Scenery scenery, string? name) private static string? ResolveTimetablePath(Scenery scenery, string? name)
{ {
if (string.IsNullOrWhiteSpace(name) || if (string.IsNullOrWhiteSpace(name) ||
name.Equals("none", System.StringComparison.OrdinalIgnoreCase)) name.Equals("none", System.StringComparison.OrdinalIgnoreCase))
return null; return null;
string scnDir = Path.GetDirectoryName(scenery.Path) ?? "."; // scenery/ string scnDir = Path.GetDirectoryName(scenery.Path) ?? ".";
string root = Path.GetDirectoryName(scnDir) ?? "."; // MaSzyna root string root = Path.GetDirectoryName(scnDir) ?? ".";
var candidates = new[] var candidates = new[]
{ {
@@ -734,8 +677,6 @@ public partial class Scenarios : UserControl
return candidates.FirstOrDefault(File.Exists); return candidates.FirstOrDefault(File.Exists);
} }
// Renders the consist preview for a trainset (re-reads its current vehicles
// so depot edits are reflected).
private void ShowConsist(Trainset trainset) private void ShowConsist(Trainset trainset)
{ {
consistStack.Children.Clear(); consistStack.Children.Clear();
@@ -743,11 +684,11 @@ public partial class Scenarios : UserControl
var db = GameData.Instance.Vehicles; var db = GameData.Instance.Vehicles;
foreach (var train in trainset.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 miniName = db.MiniForSkin(train.SkinFile) ?? train.SkinFile;
string? path = VehicleDatabase.MiniPath(miniName); string? path = VehicleDatabase.MiniPath(miniName);
if (path is null) if (path is null)
continue; // fallback: no mini continue;
int thumbH = StarterNG.Classes.Settings.Instance.LargeThumbnails ? 64 : 32; int thumbH = StarterNG.Classes.Settings.Instance.LargeThumbnails ? 64 : 32;
consistStack.Children.Add(new Image consistStack.Children.Add(new Image
@@ -783,8 +724,6 @@ public partial class Scenarios : UserControl
car => db.TextureForSkin(car.SkinFile)?.ResolvedCategory); car => db.TextureForSkin(car.SkinFile)?.ResolvedCategory);
} }
// When returning to this view, refresh the preview of the selected consist
// in case it was modified in the depot.
private void RefreshSelectedConsist() private void RefreshSelectedConsist()
{ {
if (AppState.Instance.CurrentTrainset is { } trainset) if (AppState.Instance.CurrentTrainset is { } trainset)

View File

@@ -13,7 +13,7 @@ using Avalonia.Markup.Xaml.Styling;
using Avalonia.Media; using Avalonia.Media;
using StarterNG.Classes; using StarterNG.Classes;
using StarterNG.Services; using StarterNG.Services;
// Disambiguate from Avalonia.Input.KeyBinding (a command-input binding) imported above.
using KeyBinding = StarterNG.Classes.KeyBinding; using KeyBinding = StarterNG.Classes.KeyBinding;
namespace StarterNG.Views; namespace StarterNG.Views;
@@ -36,23 +36,17 @@ public partial class Settings : UserControl
}; };
FillLanguageCombo(); FillLanguageCombo();
// The other picker is in the main window's top bar; both follow whatever
// language ends up loaded.
App.Loc.LanguageChanged += SelectActiveLanguage; App.Loc.LanguageChanged += SelectActiveLanguage;
FeedbackCb.SelectionChanged += FeedbackCb_OnSelectionChanged; FeedbackCb.SelectionChanged += FeedbackCb_OnSelectionChanged;
FpsLimitEnableCb.IsCheckedChanged += (_, _) => FpsLimitSlider.IsEnabled = IsChecked(FpsLimitEnableCb); FpsLimitEnableCb.IsCheckedChanged += (_, _) => FpsLimitSlider.IsEnabled = IsChecked(FpsLimitEnableCb);
// The settings instance pulls live values from here whenever the app
// closes or the game is launched.
StarterNG.Classes.Settings.Instance.CaptureFromUi = ReadFromUi; StarterNG.Classes.Settings.Instance.CaptureFromUi = ReadFromUi;
PopulateProfileCombo(); PopulateProfileCombo();
ApplyToUi(); ApplyToUi();
// Controls tab: load the key bindings and build the editor + on-screen
// keyboard. The tunnelling handler lets us capture a key press before the
// focused button consumes it (Space/Enter would otherwise click it).
KeyboardConfig.Instance.Load(); KeyboardConfig.Instance.Load();
BuildControlsTab(); BuildControlsTab();
AddHandler(KeyDownEvent, OnControlsKeyDown, RoutingStrategies.Tunnel); AddHandler(KeyDownEvent, OnControlsKeyDown, RoutingStrategies.Tunnel);
@@ -130,7 +124,6 @@ public partial class Settings : UserControl
} }
} }
// ── Settings instance → controls ──────────────────────────────────────
public void ReloadFromSettings() => ApplyToUi(); public void ReloadFromSettings() => ApplyToUi();
private void ApplyToUi() private void ApplyToUi()
@@ -142,7 +135,6 @@ public partial class Settings : UserControl
PopulateProfileCombo(); PopulateProfileCombo();
PopulateExeCombo(); PopulateExeCombo();
// General
SelectActiveLanguage(); SelectActiveLanguage();
FullscreenCb.IsChecked = s.Fullscreen; FullscreenCb.IsChecked = s.Fullscreen;
PauseInactiveCb.IsChecked = s.PauseWhenInactive; PauseInactiveCb.IsChecked = s.PauseWhenInactive;
@@ -151,7 +143,6 @@ public partial class Settings : UserControl
MouseHorInvertCb.IsChecked = s.InvertMouseHorizontal; MouseHorInvertCb.IsChecked = s.InvertMouseHorizontal;
MouseVertInvertCb.IsChecked = s.InvertMouseVertical; MouseVertInvertCb.IsChecked = s.InvertMouseVertical;
// Communication
GamepadIgnoreCb.IsChecked = s.IgnoreGamepad; GamepadIgnoreCb.IsChecked = s.IgnoreGamepad;
FeedbackCb.SelectedIndex = s.FeedbackMode; FeedbackCb.SelectedIndex = s.FeedbackMode;
FeedbackPortNud.Value = (decimal)s.FeedbackPort; FeedbackPortNud.Value = (decimal)s.FeedbackPort;
@@ -167,7 +158,6 @@ public partial class Settings : UserControl
UartRadioChannelCb.IsChecked = s.UartRadioChannel; UartRadioChannelCb.IsChecked = s.UartRadioChannel;
UpdateFeedbackDetailVisibility(); UpdateFeedbackDetailVisibility();
// Other
SelectExeCb.SelectedIndex = s.SelectExeAutomatically SelectExeCb.SelectedIndex = s.SelectExeAutomatically
? 0 ? 0
: Math.Max(0, FindComboIndexByContent(SelectExeCb, s.ExecutablePath)); : Math.Max(0, FindComboIndexByContent(SelectExeCb, s.ExecutablePath));
@@ -175,7 +165,6 @@ public partial class Settings : UserControl
VirtualShuntingCb.IsChecked = s.VirtualShunting; VirtualShuntingCb.IsChecked = s.VirtualShunting;
LogMissingVehicleFilesCb.IsChecked = s.LogMissingVehicleFiles; LogMissingVehicleFilesCb.IsChecked = s.LogMissingVehicleFiles;
// Graphics
RenderEngineCb.SelectedIndex = s.RenderEngine; RenderEngineCb.SelectedIndex = s.RenderEngine;
SelectResolution(s.Width, s.Height); SelectResolution(s.Width, s.Height);
bufferScale.Value = s.BufferScalePercent; bufferScale.Value = s.BufferScalePercent;
@@ -210,7 +199,6 @@ public partial class Settings : UserControl
FullscreenWindowedCb.IsChecked = s.FullscreenWindowed; FullscreenWindowedCb.IsChecked = s.FullscreenWindowed;
RenderAngleVulkanCb.IsChecked = s.RenderAngleVulkan; RenderAngleVulkanCb.IsChecked = s.RenderAngleVulkan;
// Physics
TrackCurvesSlider.Value = s.SplineFidelity; TrackCurvesSlider.Value = s.SplineFidelity;
PhysicsAccuracyCb.IsChecked = s.FullPhysics; PhysicsAccuracyCb.IsChecked = s.FullPhysics;
PantographBreakCb.IsChecked = s.EnableTraction; PantographBreakCb.IsChecked = s.EnableTraction;
@@ -224,7 +212,6 @@ public partial class Settings : UserControl
BrakeStepSlider.Value = s.BrakeStep; BrakeStepSlider.Value = s.BrakeStep;
BrakeSpeedSlider.Value = s.BrakeSpeed; BrakeSpeedSlider.Value = s.BrakeSpeed;
// Sound
EnableSoundsCb.IsChecked = s.SoundEnabled; EnableSoundsCb.IsChecked = s.SoundEnabled;
VolumeSlider.Value = s.Volume; VolumeSlider.Value = s.Volume;
RadioVolumeSlider.Value = s.RadioVolume; RadioVolumeSlider.Value = s.RadioVolume;
@@ -232,7 +219,6 @@ public partial class Settings : UserControl
PositionalVolumeSlider.Value = s.PositionalVolume; PositionalVolumeSlider.Value = s.PositionalVolume;
AmbientVolumeSlider.Value = s.AmbientVolume; AmbientVolumeSlider.Value = s.AmbientVolume;
// Advanced
CompressTexturesCb.IsChecked = s.CompressTextures; CompressTexturesCb.IsChecked = s.CompressTextures;
ScaleSpecularsCb.IsChecked = s.ScaleSpeculars; ScaleSpecularsCb.IsChecked = s.ScaleSpeculars;
UseGLESCb.IsChecked = s.UseGLES; UseGLESCb.IsChecked = s.UseGLES;
@@ -244,7 +230,6 @@ public partial class Settings : UserControl
TrainPhysicsThreadsSlider.Value = s.TrainPhysicsThreads; TrainPhysicsThreadsSlider.Value = s.TrainPhysicsThreads;
IgnoreIrrelevantTrainsCb.IsChecked = s.IgnoreIrrelevantTrains; IgnoreIrrelevantTrainsCb.IsChecked = s.IgnoreIrrelevantTrains;
// Starter
AutoCloseStarterCb.IsChecked = s.AutoCloseStarter; AutoCloseStarterCb.IsChecked = s.AutoCloseStarter;
LargeThumbnailsCb.IsChecked = s.LargeThumbnails; LargeThumbnailsCb.IsChecked = s.LargeThumbnails;
AutoExpandTreeCb.IsChecked = s.AutoExpandSceneryTree; AutoExpandTreeCb.IsChecked = s.AutoExpandSceneryTree;
@@ -257,14 +242,10 @@ public partial class Settings : UserControl
} }
} }
// ── controls → Settings instance ──────────────────────────────────────
private void ReadFromUi() private void ReadFromUi()
{ {
var s = StarterNG.Classes.Settings.Instance; var s = StarterNG.Classes.Settings.Instance;
// General
// Keep the loaded language rather than writing a wrong one over it when
// the combo has no valid pick.
s.Language = (ChangeLanguageCb.SelectedItem as ComboBoxItem)?.Content?.ToString() s.Language = (ChangeLanguageCb.SelectedItem as ComboBoxItem)?.Content?.ToString()
?? App.Loc.CurrentLanguage; ?? App.Loc.CurrentLanguage;
s.Fullscreen = IsChecked(FullscreenCb); s.Fullscreen = IsChecked(FullscreenCb);
@@ -274,7 +255,6 @@ public partial class Settings : UserControl
s.InvertMouseHorizontal = IsChecked(MouseHorInvertCb); s.InvertMouseHorizontal = IsChecked(MouseHorInvertCb);
s.InvertMouseVertical = IsChecked(MouseVertInvertCb); s.InvertMouseVertical = IsChecked(MouseVertInvertCb);
// Communication
s.IgnoreGamepad = IsChecked(GamepadIgnoreCb); s.IgnoreGamepad = IsChecked(GamepadIgnoreCb);
s.FeedbackMode = Math.Max(0, FeedbackCb.SelectedIndex); s.FeedbackMode = Math.Max(0, FeedbackCb.SelectedIndex);
s.FeedbackPort = (int)(FeedbackPortNud.Value ?? 888); s.FeedbackPort = (int)(FeedbackPortNud.Value ?? 888);
@@ -289,7 +269,6 @@ public partial class Settings : UserControl
s.UartRadioVolume = IsChecked(UartRadioVolumeCb); s.UartRadioVolume = IsChecked(UartRadioVolumeCb);
s.UartRadioChannel = IsChecked(UartRadioChannelCb); s.UartRadioChannel = IsChecked(UartRadioChannelCb);
// Other
s.SelectExeAutomatically = SelectExeCb.SelectedIndex == 0; s.SelectExeAutomatically = SelectExeCb.SelectedIndex == 0;
s.ExecutablePath = s.SelectExeAutomatically ? "eu07.exe" s.ExecutablePath = s.SelectExeAutomatically ? "eu07.exe"
: (SelectExeCb.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "eu07.exe"; : (SelectExeCb.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "eu07.exe";
@@ -297,7 +276,6 @@ public partial class Settings : UserControl
s.VirtualShunting = IsChecked(VirtualShuntingCb); s.VirtualShunting = IsChecked(VirtualShuntingCb);
s.LogMissingVehicleFiles = IsChecked(LogMissingVehicleFilesCb); s.LogMissingVehicleFiles = IsChecked(LogMissingVehicleFilesCb);
// Graphics
s.RenderEngine = Math.Max(0, RenderEngineCb.SelectedIndex); s.RenderEngine = Math.Max(0, RenderEngineCb.SelectedIndex);
ReadResolution(s); ReadResolution(s);
s.BufferScalePercent = (int)bufferScale.Value; s.BufferScalePercent = (int)bufferScale.Value;
@@ -332,7 +310,6 @@ public partial class Settings : UserControl
s.FullscreenWindowed = IsChecked(FullscreenWindowedCb); s.FullscreenWindowed = IsChecked(FullscreenWindowedCb);
s.RenderAngleVulkan = IsChecked(RenderAngleVulkanCb); s.RenderAngleVulkan = IsChecked(RenderAngleVulkanCb);
// Physics
s.SplineFidelity = (int)TrackCurvesSlider.Value; s.SplineFidelity = (int)TrackCurvesSlider.Value;
s.FullPhysics = IsChecked(PhysicsAccuracyCb); s.FullPhysics = IsChecked(PhysicsAccuracyCb);
s.EnableTraction = IsChecked(PantographBreakCb); s.EnableTraction = IsChecked(PantographBreakCb);
@@ -346,7 +323,6 @@ public partial class Settings : UserControl
s.BrakeStep = BrakeStepSlider.Value; s.BrakeStep = BrakeStepSlider.Value;
s.BrakeSpeed = BrakeSpeedSlider.Value; s.BrakeSpeed = BrakeSpeedSlider.Value;
// Sound
s.SoundEnabled = IsChecked(EnableSoundsCb); s.SoundEnabled = IsChecked(EnableSoundsCb);
s.Volume = (int)VolumeSlider.Value; s.Volume = (int)VolumeSlider.Value;
s.RadioVolume = (int)RadioVolumeSlider.Value; s.RadioVolume = (int)RadioVolumeSlider.Value;
@@ -354,7 +330,6 @@ public partial class Settings : UserControl
s.PositionalVolume = (int)PositionalVolumeSlider.Value; s.PositionalVolume = (int)PositionalVolumeSlider.Value;
s.AmbientVolume = (int)AmbientVolumeSlider.Value; s.AmbientVolume = (int)AmbientVolumeSlider.Value;
// Advanced
s.CompressTextures = IsChecked(CompressTexturesCb); s.CompressTextures = IsChecked(CompressTexturesCb);
s.ScaleSpeculars = IsChecked(ScaleSpecularsCb); s.ScaleSpeculars = IsChecked(ScaleSpecularsCb);
s.UseGLES = IsChecked(UseGLESCb); s.UseGLES = IsChecked(UseGLESCb);
@@ -366,7 +341,6 @@ public partial class Settings : UserControl
s.TrainPhysicsThreads = (int)TrainPhysicsThreadsSlider.Value; s.TrainPhysicsThreads = (int)TrainPhysicsThreadsSlider.Value;
s.IgnoreIrrelevantTrains = IsChecked(IgnoreIrrelevantTrainsCb); s.IgnoreIrrelevantTrains = IsChecked(IgnoreIrrelevantTrainsCb);
// Starter
s.AutoCloseStarter = IsChecked(AutoCloseStarterCb); s.AutoCloseStarter = IsChecked(AutoCloseStarterCb);
s.LargeThumbnails = IsChecked(LargeThumbnailsCb); s.LargeThumbnails = IsChecked(LargeThumbnailsCb);
s.AutoExpandSceneryTree = IsChecked(AutoExpandTreeCb); s.AutoExpandSceneryTree = IsChecked(AutoExpandTreeCb);
@@ -395,7 +369,6 @@ public partial class Settings : UserControl
SaveStatus.Text = string.Empty; SaveStatus.Text = string.Empty;
} }
// ── resolution combo helpers ──────────────────────────────────────────
private void SelectResolution(int width, int height) private void SelectResolution(int width, int height)
{ {
string target = $"{width}x{height}"; string target = $"{width}x{height}";
@@ -407,7 +380,7 @@ public partial class Settings : UserControl
return; return;
} }
} }
// Not in the predefined list: add and select it.
var added = new ComboBoxItem { Content = target }; var added = new ComboBoxItem { Content = target };
ResolutionCb.Items.Add(added); ResolutionCb.Items.Add(added);
ResolutionCb.SelectedItem = added; ResolutionCb.SelectedItem = added;
@@ -427,7 +400,6 @@ public partial class Settings : UserControl
} }
} }
// ── existing slider readouts ──────────────────────────────────────────
private void TextureResolutionSlider_OnValueChanged(object? sender, RangeBaseValueChangedEventArgs e) private void TextureResolutionSlider_OnValueChanged(object? sender, RangeBaseValueChangedEventArgs e)
{ {
if (texResolution is null || textureResolutionSlider is null) if (texResolution is null || textureResolutionSlider is null)
@@ -455,8 +427,6 @@ public partial class Settings : UserControl
shaderResolution.Text = $"{resolution} px"; shaderResolution.Text = $"{resolution} px";
} }
// One entry per file in the lang/ folder, so every translation is reachable
// (the list used to be a hard-coded Polski / English pair).
private void FillLanguageCombo() private void FillLanguageCombo()
{ {
ChangeLanguageCb.Items.Clear(); ChangeLanguageCb.Items.Clear();
@@ -467,8 +437,6 @@ public partial class Settings : UserControl
SelectActiveLanguage(); SelectActiveLanguage();
} }
// Points the combo at the language currently loaded. The selection change
// lands in the handler below, which sees it is already loaded and stops.
private void SelectActiveLanguage() private void SelectActiveLanguage()
{ {
var item = ChangeLanguageCb.Items.OfType<ComboBoxItem>() var item = ChangeLanguageCb.Items.OfType<ComboBoxItem>()
@@ -499,7 +467,6 @@ public partial class Settings : UserControl
UartExpander.IsVisible = FeedbackCb.SelectedIndex == 5; UartExpander.IsVisible = FeedbackCb.SelectedIndex == 5;
} }
// ── tiny helpers ──────────────────────────────────────────────────────
private static bool IsChecked(CheckBox cb) => cb.IsChecked == true; private static bool IsChecked(CheckBox cb) => cb.IsChecked == true;
private static int Clamp(int v, int lo, int hi) => v < lo ? lo : (v > hi ? hi : v); private static int Clamp(int v, int lo, int hi) => v < lo ? lo : (v > hi ? hi : v);
@@ -517,29 +484,20 @@ public partial class Settings : UserControl
for (int i = 0; i < steps.Length; i++) for (int i = 0; i < steps.Length; i++)
if (steps[i] == anisotropy) if (steps[i] == anisotropy)
return i + 1; return i + 1;
return 4; // default → 8x return 4;
} }
// ══════════════════════════════════════════════════════════════════════
// Controls tab — key-binding editor and on-screen keyboard
// ══════════════════════════════════════════════════════════════════════
private TextBox? _controlsSearch; private TextBox? _controlsSearch;
private StackPanel? _bindingListPanel; private StackPanel? _bindingListPanel;
private StackPanel? _keyboardPanel; private StackPanel? _keyboardPanel;
// Binding currently waiting for a key press, and the button that launched it.
private KeyBinding? _capturing; private KeyBinding? _capturing;
private Button? _capturingButton; private Button? _capturingButton;
// On-screen key cells, keyed by token, so colours can be refreshed in place
// (rebuilding the whole keyboard would detach an open assignment flyout).
private readonly Dictionary<string, Grid> _keyCells = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, Grid> _keyCells = new(StringComparer.OrdinalIgnoreCase);
// Guards the assignment flyout's combo boxes against feedback during programmatic updates.
private bool _flyoutLoading; private bool _flyoutLoading;
// State colours (match the legend): unassigned/filler, plain, +shift, +ctrl.
private static readonly IBrush KeyUnassignedBrush = new SolidColorBrush(Color.Parse("#2A3036")); private static readonly IBrush KeyUnassignedBrush = new SolidColorBrush(Color.Parse("#2A3036"));
private static readonly IBrush KeyFillerBrush = new SolidColorBrush(Color.Parse("#21262B")); private static readonly IBrush KeyFillerBrush = new SolidColorBrush(Color.Parse("#21262B"));
private static readonly IBrush KeyPlainBrush = new SolidColorBrush(Color.Parse("#2E9E1F")); private static readonly IBrush KeyPlainBrush = new SolidColorBrush(Color.Parse("#2E9E1F"));
@@ -550,7 +508,7 @@ public partial class Settings : UserControl
private static readonly IBrush FgDimBrush = new SolidColorBrush(Color.Parse("#9098A0")); private static readonly IBrush FgDimBrush = new SolidColorBrush(Color.Parse("#9098A0"));
private static readonly IBrush ConflictBrush = new SolidColorBrush(Color.Parse("#E06C5A")); private static readonly IBrush ConflictBrush = new SolidColorBrush(Color.Parse("#E06C5A"));
private const double KeyUnit = 30; // px per relative width unit private const double KeyUnit = 30;
private const double KeyHeight = 34; private const double KeyHeight = 34;
private const double KeyGap = 4; private const double KeyGap = 4;
@@ -559,7 +517,6 @@ public partial class Settings : UserControl
if (ControlsHost is null) if (ControlsHost is null)
return; return;
// Row 0 — search / restore bar.
var topBar = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Margin = new Thickness(0, 0, 0, 8) }; var topBar = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Margin = new Thickness(0, 0, 0, 8) };
topBar.Children.Add(new TextBlock topBar.Children.Add(new TextBlock
{ {
@@ -585,7 +542,6 @@ public partial class Settings : UserControl
Grid.SetRow(topBar, 0); Grid.SetRow(topBar, 0);
ControlsHost.Children.Add(topBar); ControlsHost.Children.Add(topBar);
// Row 1 — scrollable binding list.
_bindingListPanel = new StackPanel { Spacing = 3 }; _bindingListPanel = new StackPanel { Spacing = 3 };
var listScroll = new ScrollViewer var listScroll = new ScrollViewer
{ {
@@ -595,7 +551,6 @@ public partial class Settings : UserControl
Grid.SetRow(listScroll, 1); Grid.SetRow(listScroll, 1);
ControlsHost.Children.Add(listScroll); ControlsHost.Children.Add(listScroll);
// Row 2 — legend + on-screen keyboard.
var bottom = new StackPanel { Spacing = 8, Margin = new Thickness(0, 10, 0, 0) }; var bottom = new StackPanel { Spacing = 8, Margin = new Thickness(0, 10, 0, 0) };
bottom.Children.Add(BuildLegend()); bottom.Children.Add(BuildLegend());
_keyboardPanel = new StackPanel(); _keyboardPanel = new StackPanel();
@@ -612,7 +567,6 @@ public partial class Settings : UserControl
RebuildKeyboard(); RebuildKeyboard();
} }
// ── binding list ───────────────────────────────────────────────────────
private void RebuildBindingList() private void RebuildBindingList()
{ {
if (_bindingListPanel is null) if (_bindingListPanel is null)
@@ -707,17 +661,16 @@ public partial class Settings : UserControl
}; };
} }
// ── key capture ──────────────────────────────────────────────────────--
private void StartCapture(KeyBinding b, Button button) private void StartCapture(KeyBinding b, Button button)
{ {
// Restore the previously listening button, if any, before switching.
if (_capturing is not null && _capturingButton is not null) if (_capturing is not null && _capturingButton is not null)
_capturingButton.Content = ComboText(_capturing); _capturingButton.Content = ComboText(_capturing);
_capturing = b; _capturing = b;
_capturingButton = button; _capturingButton = button;
button.Content = App.Loc["PressKey"]; button.Content = App.Loc["PressKey"];
button.Focus(); // keep focus inside the view so the tunnel handler sees keys button.Focus();
} }
private void CancelCapture() private void CancelCapture()
@@ -740,14 +693,13 @@ public partial class Settings : UserControl
return; return;
} }
// Wait for a real key while only modifiers are held.
if (KeyMap.IsModifierKey(e.Key)) if (KeyMap.IsModifierKey(e.Key))
return; return;
string? token = KeyMap.FromInput(e.Key, e.PhysicalKey); string? token = KeyMap.FromInput(e.Key, e.PhysicalKey);
if (token is null) if (token is null)
{ {
e.Handled = true; // swallow unsupported keys instead of clicking the button e.Handled = true;
return; return;
} }
@@ -763,7 +715,6 @@ public partial class Settings : UserControl
RebuildKeyboard(); RebuildKeyboard();
} }
// ── on-screen keyboard ───────────────────────────────────────────────--
private void RebuildKeyboard() private void RebuildKeyboard()
{ {
if (_keyboardPanel is null) if (_keyboardPanel is null)
@@ -780,15 +731,13 @@ public partial class Settings : UserControl
_keyboardPanel.Children.Add(row); _keyboardPanel.Children.Add(row);
} }
// Recolours every key in place without rebuilding the tree, so an open
// assignment flyout keeps its anchor.
private void UpdateKeyboardColors() private void UpdateKeyboardColors()
{ {
var states = ComputeKeyStates(); var states = ComputeKeyStates();
foreach (var (token, content) in _keyCells) foreach (var (token, content) in _keyCells)
{ {
if (content.Children.Count > 0) if (content.Children.Count > 0)
content.Children.RemoveAt(0); // drop the old background, keep the label content.Children.RemoveAt(0);
content.Children.Insert(0, BuildKeyBackground(token, states)); content.Children.Insert(0, BuildKeyBackground(token, states));
states.TryGetValue(token, out var state); states.TryGetValue(token, out var state);
ToolTip.SetTip(content, BuildKeyTooltip(token, state)); ToolTip.SetTip(content, BuildKeyTooltip(token, state));
@@ -824,7 +773,7 @@ public partial class Settings : UserControl
if (cap.Token is null) if (cap.Token is null)
{ {
// Reserved key (Esc, F1-F12, modifiers …): greyed out and not editable.
content.Children.Add(new Border { Background = KeyFillerBrush }); content.Children.Add(new Border { Background = KeyFillerBrush });
content.Children.Add(new TextBlock content.Children.Add(new TextBlock
{ {
@@ -885,7 +834,6 @@ public partial class Settings : UserControl
return head + "\n" + string.Join("\n", state.Tips); return head + "\n" + string.Join("\n", state.Tips);
} }
// ── key assignment flyout (one combo per modifier combination) ──────────
private void ShowKeyFlyout(string token, Control anchor) private void ShowKeyFlyout(string token, Control anchor)
{ {
CancelCapture(); CancelCapture();
@@ -987,7 +935,6 @@ public partial class Settings : UserControl
{ {
KeyBinding? chosen = selectedIndex <= 0 ? null : commands[selectedIndex - 1]; KeyBinding? chosen = selectedIndex <= 0 ? null : commands[selectedIndex - 1];
// Free the slot: unbind any other command currently sitting on it.
foreach (var b in KeyboardConfig.Instance.Bindings) foreach (var b in KeyboardConfig.Instance.Bindings)
{ {
if (!ReferenceEquals(b, chosen) && b.IsAssigned && if (!ReferenceEquals(b, chosen) && b.IsAssigned &&
@@ -1048,7 +995,6 @@ public partial class Settings : UserControl
return sp; return sp;
} }
// ── shared helpers ─────────────────────────────────────────────────────
private sealed class KeyState private sealed class KeyState
{ {
public bool Plain; public bool Plain;
@@ -1106,8 +1052,6 @@ public partial class Settings : UserControl
return string.Join(" + ", parts); return string.Join(" + ", parts);
} }
// Display label for a command: its (capitalised) description, or the raw
// command token when the file has no comment for it.
private static string CommandLabel(KeyBinding b) => private static string CommandLabel(KeyBinding b) =>
string.IsNullOrEmpty(b.Description) ? b.Command : Capitalize(b.Description); string.IsNullOrEmpty(b.Description) ? b.Command : Capitalize(b.Description);

View File

@@ -16,7 +16,6 @@ public partial class TextureBaseWindow : Window
private readonly VehicleDatabase _db = GameData.Instance.Vehicles; private readonly VehicleDatabase _db = GameData.Instance.Vehicles;
private readonly List<Row> _all = new(); private readonly List<Row> _all = new();
/// <summary>Texture chosen by double-click (null if the window was just closed).</summary>
public VehicleTexture? Picked { get; private set; } public VehicleTexture? Picked { get; private set; }
public TextureBaseWindow() public TextureBaseWindow()