diff --git a/StarterNG.sln b/StarterNG.sln
index 4165ee9..7afd782 100644
--- a/StarterNG.sln
+++ b/StarterNG.sln
@@ -25,4 +25,8 @@ Global
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
+ GlobalSection(RiderSharedRunConfigurations) = postSolution
+ File = linuix.run\StarterNG (linux64).run.xml
+ File = windows.run\StarterNG (win64).run.xml
+ EndGlobalSection
EndGlobal
diff --git a/StarterNG/Classes/GameData.cs b/StarterNG/Classes/GameData.cs
index f846389..ae17654 100644
--- a/StarterNG/Classes/GameData.cs
+++ b/StarterNG/Classes/GameData.cs
@@ -62,6 +62,10 @@ public sealed class GameData
// first thumbnail render doesn't scan textures/mini/ on the UI thread.
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();
+
// Phase 2 - sceneries
foreach (string file in scnFiles)
{
diff --git a/StarterNG/Classes/Physics.cs b/StarterNG/Classes/Physics.cs
new file mode 100644
index 0000000..3d65f63
--- /dev/null
+++ b/StarterNG/Classes/Physics.cs
@@ -0,0 +1,230 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+
+namespace StarterNG.Classes;
+
+///
+/// Vehicle physics read from a .fiz file (dynamic/<dir>/<model>.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.
+///
+public sealed class Physics
+{
+ public double Mass; // kg, as written in the .fiz
+ public double VMax; // km/h
+ public double Length; // metres
+ public string LoadAccepted = "";
+ public int MaxLoad;
+ public int AllowedFlagA = 3;
+ public int AllowedFlagB = 3;
+ public string ControlTypeA = "";
+ public string ControlTypeB = "";
+
+ private static readonly Dictionary 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? _fizIndex;
+
+ /// Builds the .fiz index up front (call from the startup load).
+ public static void PreloadIndex(string dynamicRoot = "dynamic") => FizIndex(dynamicRoot);
+
+ private static Dictionary FizIndex(string dynamicRoot)
+ {
+ if (_fizIndex != null)
+ return _fizIndex;
+
+ var index = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ try
+ {
+ if (Directory.Exists(dynamicRoot))
+ foreach (string f in Directory.EnumerateFiles(dynamicRoot, "*.fiz", SearchOption.AllDirectories))
+ index[Path.GetFileNameWithoutExtension(f)] = f;
+ }
+ catch { /* unreadable dynamic/ - no physics */ }
+
+ _fizIndex = index;
+ return index;
+ }
+
+ ///
+ /// Loads (and caches) the physics for a model name (the .fiz is located by name
+ /// anywhere under dynamic/). The is unused now but
+ /// kept for call-site clarity. Returns null when nothing matches. Never throws.
+ ///
+ public static Physics? For(string? dataFolder, string? model, string dynamicRoot = "dynamic")
+ {
+ if (string.IsNullOrEmpty(model))
+ return null;
+
+ string m = Path.GetFileNameWithoutExtension(model!); // strip any .mmd/.t3d/etc.
+ if (m.Length == 0)
+ return null;
+
+ if (Cache.TryGetValue(m, out var cached))
+ return cached;
+
+ var index = FizIndex(dynamicRoot);
+ string? path = null;
+ if (index.TryGetValue(m, out var p1)) path = p1;
+ else if (index.TryGetValue(m + "dumb", out var p2)) path = p2;
+
+ Physics? phys = null;
+ if (path != null)
+ {
+ phys = new Physics { AllowedFlagB = -1 }; // -1 = "B end not declared yet"
+ try { Parse(phys, path, Path.GetDirectoryName(path) ?? "", null, 0); }
+ catch { /* partial data still useful */ }
+
+ // The original copies the A-end coupler onto the B-end when only one
+ // BuffCoupl section is declared.
+ if (phys.AllowedFlagB == -1)
+ {
+ phys.AllowedFlagB = phys.AllowedFlagA;
+ if (string.IsNullOrEmpty(phys.ControlTypeB))
+ phys.ControlTypeB = phys.ControlTypeA;
+ }
+ }
+
+ Cache[m] = phys;
+ return phys;
+ }
+
+ private static readonly HashSet KnownKeys = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "M", "Vmax", "L", "LoadAccepted", "MaxLoad", "AllowedFlag", "ControlType"
+ };
+
+ private static void Parse(Physics p, string path, string dir, string[]? prms, int depth)
+ {
+ if (depth > 6 || !File.Exists(path))
+ return;
+
+ var tokens = Tokenize(File.ReadAllText(path, Encoding.GetEncoding(1250)));
+
+ string section = "";
+ string? pendingKey = null;
+
+ for (int i = 0; i < tokens.Count; i++)
+ {
+ string tok = tokens[i];
+ if (tok == "=")
+ continue;
+
+ // include end -> parse the included physics
+ if (tok.Equals("include", StringComparison.OrdinalIgnoreCase))
+ {
+ if (++i >= tokens.Count) break;
+ string incFile = Resolve(tokens[i], prms);
+ var incParams = new List();
+ i++;
+ while (i < tokens.Count && !tokens[i].Equals("end", StringComparison.OrdinalIgnoreCase))
+ incParams.Add(Resolve(tokens[i++], prms));
+ Parse(p, Path.Combine(dir, incFile), dir, incParams.ToArray(), depth + 1);
+ pendingKey = null;
+ continue;
+ }
+
+ int eq = tok.IndexOf('=');
+ if (eq >= 0)
+ {
+ string k = tok[..eq].TrimStart('.').Trim();
+ string v = tok[(eq + 1)..].Trim();
+ if (v.Length == 0) { pendingKey = k; continue; }
+ Apply(p, section, k, Resolve(v, prms));
+ pendingKey = null;
+ continue;
+ }
+
+ string t = tok.TrimStart('.').Trim();
+ if (pendingKey != null)
+ {
+ Apply(p, section, pendingKey, Resolve(t, prms));
+ pendingKey = null;
+ }
+ else if (KnownKeys.Contains(t))
+ {
+ pendingKey = t; // value comes in a following token (spaced "Key = value")
+ }
+ 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();
+ }
+ }
+ }
+
+ private static void Apply(Physics p, string section, string key, string value)
+ {
+ switch (section)
+ {
+ case "param":
+ if (key.Equals("M", StringComparison.OrdinalIgnoreCase)) p.Mass = ParseD(value, p.Mass);
+ else if (key.Equals("Vmax", StringComparison.OrdinalIgnoreCase)) p.VMax = ParseD(value, p.VMax);
+ break;
+ case "load":
+ if (key.Equals("LoadAccepted", StringComparison.OrdinalIgnoreCase)) p.LoadAccepted = value;
+ else if (key.Equals("MaxLoad", StringComparison.OrdinalIgnoreCase)) p.MaxLoad = (int)ParseD(value, p.MaxLoad);
+ break;
+ case "dimensions":
+ if (key.Equals("L", StringComparison.OrdinalIgnoreCase)) p.Length = ParseD(value, p.Length);
+ break;
+ case "buffcoupl":
+ case "buffcoupl1":
+ if (key.Equals("AllowedFlag", StringComparison.OrdinalIgnoreCase)) p.AllowedFlagA = Flag(value, p.AllowedFlagA);
+ else if (key.Equals("ControlType", StringComparison.OrdinalIgnoreCase)) p.ControlTypeA = value;
+ break;
+ case "buffcoupl2":
+ if (key.Equals("AllowedFlag", StringComparison.OrdinalIgnoreCase)) p.AllowedFlagB = Flag(value, p.AllowedFlagB);
+ else if (key.Equals("ControlType", StringComparison.OrdinalIgnoreCase)) p.ControlTypeB = value;
+ break;
+ }
+ }
+
+ // A negative AllowedFlag means a permanent coupling: abs + 128 (workshop-lock bit).
+ private static int Flag(string v, int fallback)
+ => int.TryParse(v, System.Globalization.NumberStyles.Integer,
+ System.Globalization.CultureInfo.InvariantCulture, out int f)
+ ? (f < 0 ? -f + 128 : f)
+ : fallback;
+
+ private static double ParseD(string v, double fallback)
+ => double.TryParse(v, System.Globalization.NumberStyles.Float,
+ System.Globalization.CultureInfo.InvariantCulture, out double d)
+ ? d
+ : 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)
+ {
+ if (prms == null || token.Length < 3 || token[0] != '(' || token[^1] != ')')
+ return token;
+ string digits = new string(token.Where(char.IsDigit).ToArray());
+ return int.TryParse(digits, out int idx) && idx >= 1 && idx <= prms.Length
+ ? prms[idx - 1]
+ : token;
+ }
+
+ private static List Tokenize(string text)
+ {
+ var sb = new StringBuilder();
+ foreach (var line in text.Split('\n'))
+ {
+ string l = line;
+ int hash = l.IndexOf('#');
+ if (hash >= 0) l = l[..hash];
+ sb.Append(l).Append(' ');
+ }
+ return sb.ToString()
+ .Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
+ .ToList();
+ }
+}
diff --git a/StarterNG/Classes/Settings.cs b/StarterNG/Classes/Settings.cs
index b2c79be..4e4ccaa 100644
--- a/StarterNG/Classes/Settings.cs
+++ b/StarterNG/Classes/Settings.cs
@@ -153,6 +153,53 @@ public sealed class Settings
private static string DefaultConfigPath() =>
Path.Combine(Directory.GetCurrentDirectory(), "eu07.ini");
+ ///
+ /// The simulator executable to launch. With auto-selection on, the working
+ /// directory is scanned for an eu07* binary - eu07 on Linux/macOS,
+ /// eu07.exe on Windows - so the same procedure works on every OS. With it
+ /// off, the explicitly chosen path is used.
+ ///
+ public string ResolveExecutable()
+ {
+ if (!SelectExeAutomatically && !string.IsNullOrWhiteSpace(ExecutablePath))
+ return ExecutablePath;
+
+ string canonical = OperatingSystem.IsWindows() ? "eu07.exe" : "eu07";
+ try
+ {
+ string? best = null;
+ foreach (string path in Directory.GetFiles(Directory.GetCurrentDirectory(), "eu07*"))
+ {
+ if (!IsExecutableCandidate(path))
+ continue;
+ string file = Path.GetFileName(path);
+ if (string.Equals(file, canonical, StringComparison.OrdinalIgnoreCase))
+ return path; // exact canonical name wins
+ if (best == null || file.Length < Path.GetFileName(best).Length)
+ best = path; // otherwise prefer the shortest eu07* name
+ }
+ if (best != null)
+ return best;
+ }
+ catch { /* fall through to the canonical name */ }
+
+ return canonical;
+ }
+
+ // 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)
+ {
+ string ext = Path.GetExtension(path).ToLowerInvariant();
+ if (ext is ".ini" or ".log" or ".txt" or ".cfg" or ".config" or ".json"
+ or ".dll" or ".so" or ".dat" or ".bak" or ".csv" or ".xml")
+ return false;
+ if (OperatingSystem.IsWindows())
+ return ext == ".exe";
+ // Linux / macOS: no extension (eu07) or a known binary suffix
+ return ext.Length == 0 || ext is ".x86_64" or ".run" or ".appimage";
+ }
+
private static readonly Encoding FileEncoding = ResolveEncoding();
private static Encoding ResolveEncoding()
diff --git a/StarterNG/Classes/Trainset.cs b/StarterNG/Classes/Trainset.cs
index 783bc3f..73f19cb 100644
--- a/StarterNG/Classes/Trainset.cs
+++ b/StarterNG/Classes/Trainset.cs
@@ -395,6 +395,19 @@ public sealed class Coupling
Parameters.Insert(0, brake.ToParameter());
}
+ /// The wheel/damage setting carried in the "W" parameter, or null.
+ public WheelSettings? GetWheels() =>
+ WheelSettings.FromParameter(
+ Parameters.FirstOrDefault(p => p.StartsWith("W", StringComparison.Ordinal)));
+
+ /// Replaces (or clears, when empty/null) the "W" wheel parameter.
+ public void SetWheels(WheelSettings? wheels)
+ {
+ Parameters.RemoveAll(p => p.StartsWith("W", StringComparison.Ordinal));
+ if (wheels != null && !wheels.IsEmpty)
+ Parameters.Add(wheels.ToParameter());
+ }
+
public Coupling Clone() => new()
{
Flags = Flags,
@@ -451,4 +464,61 @@ public sealed class BrakeSetting
}
public string ToParameter() => "B" + Mode + (Load ?? "") + (Switch ?? "");
+}
+
+///
+/// Wheel-damage settings carried in the "W" parameter of a vehicle's coupling
+/// field: W[H<sway%>][F<flat mm>][R<random flat mm>][P<flat prob%>]
+/// (e.g. "WH25F5R10P8"). See https://wiki.eu07.pl/index.php?title=Wpisy_hamulca_dla_pojazdow
+///
+public sealed class WheelSettings
+{
+ /// H — sway ("wężykowanie") probability, %.
+ public int Sway;
+
+ /// F — flat spot ("podkucie") of this size, mm.
+ public int Flatness;
+
+ /// R — additional random flat spot, 0..x mm.
+ public int FlatnessRand;
+
+ /// P — flat-spot probability, % (guaranteed when omitted).
+ public int FlatnessProb;
+
+ public bool IsEmpty => Sway <= 0 && Flatness <= 0 && FlatnessRand <= 0 && FlatnessProb <= 0;
+
+ public static WheelSettings? FromParameter(string? param)
+ {
+ if (string.IsNullOrEmpty(param) || param![0] != 'W')
+ return null;
+
+ var w = new WheelSettings();
+ string body = param.Substring(1);
+ int i = 0;
+ while (i < body.Length)
+ {
+ char code = body[i++];
+ int start = i;
+ while (i < body.Length && char.IsDigit(body[i])) i++;
+ if (!int.TryParse(body[start..i], out int val)) continue;
+ switch (char.ToUpperInvariant(code))
+ {
+ case 'H': w.Sway = val; break;
+ case 'F': w.Flatness = val; break;
+ case 'R': w.FlatnessRand = val; break;
+ case 'P': w.FlatnessProb = val; break;
+ }
+ }
+ return w;
+ }
+
+ public string ToParameter()
+ {
+ var sb = new StringBuilder("W");
+ if (Sway > 0) sb.Append('H').Append(Sway);
+ if (Flatness > 0) sb.Append('F').Append(Flatness);
+ if (FlatnessRand > 0) sb.Append('R').Append(FlatnessRand);
+ if (FlatnessProb > 0) sb.Append('P').Append(FlatnessProb);
+ return sb.ToString();
+ }
}
\ No newline at end of file
diff --git a/StarterNG/MainWindow.axaml.cs b/StarterNG/MainWindow.axaml.cs
index 9844043..7e92cfe 100644
--- a/StarterNG/MainWindow.axaml.cs
+++ b/StarterNG/MainWindow.axaml.cs
@@ -122,14 +122,19 @@ public partial class MainWindow : Window
Settings.Instance.CaptureAndSave();
// launch the game: -s $.scn -v . Keep the handle so we can
- // watch for the simulator exiting.
+ // 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;
try
{
- sim = Process.Start(new ProcessStartInfo(Settings.Instance.ExecutablePath)
+ string exe = Path.GetFullPath(Settings.Instance.ResolveExecutable());
+ sim = Process.Start(new ProcessStartInfo
{
+ FileName = exe,
Arguments = $"-s {exportName} -v {vehicle}",
- UseShellExecute = true
+ WorkingDirectory = Path.GetDirectoryName(exe) ?? Directory.GetCurrentDirectory(),
+ UseShellExecute = false
});
}
catch
@@ -162,7 +167,7 @@ public partial class MainWindow : Window
{
if (sim is null)
{
- string name = Path.GetFileNameWithoutExtension(Settings.Instance.ExecutablePath);
+ string name = Path.GetFileNameWithoutExtension(Settings.Instance.ResolveExecutable());
sim = Process.GetProcessesByName(name).FirstOrDefault();
}
diff --git a/StarterNG/Services/LocalizationService.cs b/StarterNG/Services/LocalizationService.cs
index 7f67e68..72179ec 100644
--- a/StarterNG/Services/LocalizationService.cs
+++ b/StarterNG/Services/LocalizationService.cs
@@ -23,7 +23,7 @@ public class LocalizationService : INotifyPropertyChanged
{
/// Folder holding the language files, resolved as starter/lang next to the executable.
public static string LangDirectory =>
- Path.Combine(AppContext.BaseDirectory, "starter", "lang");
+ Path.Combine(AppContext.BaseDirectory, "startercfg", "lang");
private Dictionary _strings = new();
diff --git a/StarterNG/StarterNG.csproj b/StarterNG/StarterNG.csproj
index 5371485..19c128e 100644
--- a/StarterNG/StarterNG.csproj
+++ b/StarterNG/StarterNG.csproj
@@ -2,14 +2,15 @@
WinExe
true
+ true
true
net9.0
enable
true
app.manifest
true
- 1.0.0.1
- 1.0.0.1
+ 1.1.0.3
+ 1.1.0.3
Starter
eu07.pl
@@ -54,9 +55,9 @@
compiled into the assembly. They are copied verbatim into a starter/lang/
folder next to the executable so they can be edited or extended without a
rebuild. XmlReader parsing is AOT-safe, unlike the Avalonia XAML loader. -->
-
+
PreserveNewest
- starter\lang\%(Filename)%(Extension)
+ startercfg\lang\%(Filename)%(Extension)
diff --git a/StarterNG/Views/Depot.axaml b/StarterNG/Views/Depot.axaml
index d5ce7e2..b294469 100644
--- a/StarterNG/Views/Depot.axaml
+++ b/StarterNG/Views/Depot.axaml
@@ -86,6 +86,11 @@
+
+
+
+
+
@@ -93,15 +98,20 @@
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/StarterNG/Views/Depot.axaml.cs b/StarterNG/Views/Depot.axaml.cs
index bc88f8e..120d05b 100644
--- a/StarterNG/Views/Depot.axaml.cs
+++ b/StarterNG/Views/Depot.axaml.cs
@@ -118,11 +118,11 @@ public partial class Depot : UserControl
// Car-type suffixes (longest first) used only for the consist unit label.
private static readonly string[] CarSuffixes = { "sa", "sb", "ra", "rb", "s" };
- // Coupling-bit labels in mask-bit order (bit i = value 1<();
+ foreach (var item in _consist)
+ {
+ var cars = item.Flipped ? item.Cars.AsEnumerable().Reverse() : item.Cars;
+ foreach (var c in cars)
+ flat.Add((c, item.Flipped));
+ }
+
+ for (int i = 0; i < flat.Count - 1; i++)
+ {
+ var (left, lf) = flat[i];
+ var (right, rf) = flat[i + 1];
+
+ var lp = PhysicsFor(left);
+ var rp = PhysicsFor(right);
+
+ // GetMaxCoupler: the end facing the neighbour (swapped when flipped)
+ int leftMax = lp == null ? 3 : (lf ? lp.AllowedFlagA : lp.AllowedFlagB);
+ int rightMax = rp == null ? 3 : (rf ? rp.AllowedFlagB : rp.AllowedFlagA);
+ int common = leftMax & rightMax; // CommonCoupler = shared flag bits
+
+ string lct = lp == null ? "" : (lf ? lp.ControlTypeA : lp.ControlTypeB);
+ string rct = rp == null ? "" : (rf ? rp.ControlTypeB : rp.ControlTypeA);
+ if ((common & Coupling.ControlMU) != 0 &&
+ !string.Equals(lct, rct, StringComparison.OrdinalIgnoreCase))
+ common &= ~Coupling.ControlMU;
+
+ left.Coupling.Flags = left.Coupling.Locked ? -common : common;
+ }
+ }
+
+ // Train totals (length / mass / Vmax / load) read from the .fiz files.
+ private void UpdateTrainStats()
+ {
+ if (_consist.Count == 0)
+ {
+ trainStats.Text = "";
+ return;
+ }
+
+ double lengthM = 0, massKg = 0, loadKg = 0;
+ double vmax = double.PositiveInfinity;
+
+ foreach (var item in _consist)
+ foreach (var c in item.Cars)
+ {
+ var p = PhysicsFor(c);
+ if (p != null)
+ {
+ lengthM += p.Length;
+ massKg += p.Mass;
+ if (p.VMax > 0) vmax = Math.Min(vmax, p.VMax);
+ }
+ if (c.LoadCount > 0 && !string.IsNullOrEmpty(c.LoadType))
+ loadKg += (double)c.LoadCount * LoadWeight(c.LoadType);
+ }
+
+ var inv = System.Globalization.CultureInfo.InvariantCulture;
+ string v = double.IsInfinity(vmax) ? "—" : $"{vmax.ToString("0", inv)} km/h";
+ trainStats.Text =
+ $"{App.Loc["Length"]}: {lengthM.ToString("0.#", inv)} m · " +
+ $"{App.Loc["Mass"]}: {(massKg / 1000.0).ToString("0.#", inv)} t · " +
+ $"Vmax: {v} · " +
+ $"{App.Loc["Load"]}: {(loadKg / 1000.0).ToString("0.#", inv)} t";
+ }
+
private Control BuildCard(ConsistItem item)
{
bool selected = ReferenceEquals(_selected, item);
@@ -961,12 +1047,12 @@ public partial class Depot : UserControl
Margin = new Thickness(0, 0, 0, 2)
});
- for (int i = 0; i < CouplingBits.Length; i++)
+ for (int i = 0; i < CouplingBitKeys.Length; i++)
{
int bit = 1 << i;
var check = new CheckBox
{
- Content = CouplingBits[i],
+ Content = App.Loc[CouplingBitKeys[i]],
IsChecked = d.Coupling.Has(bit),
FontSize = 11
};
@@ -1016,16 +1102,19 @@ public partial class Depot : UserControl
{
brakesPanel.Children.Clear();
loadsPanel.Children.Clear();
+ damagePanel.Children.Clear();
if (_selected is null)
{
brakesPanel.Children.Add(DetailHint(App.Loc["SelectVehicleHint"]));
loadsPanel.Children.Add(DetailHint(App.Loc["SelectVehicleHint"]));
+ damagePanel.Children.Add(DetailHint(App.Loc["SelectVehicleHint"]));
return;
}
BuildBrakesEditor(_selected);
BuildLoadsEditor(_selected);
+ BuildDamageEditor(_selected);
}
private static TextBlock DetailHint(string text) => new()
@@ -1060,20 +1149,20 @@ public partial class Depot : UserControl
{
var brake = item.Cars[0].Coupling.GetBrake();
- var modeCombo = new ComboBox { FontSize = 12, MinWidth = 150 };
+ var modeCombo = new ComboBox { FontSize = 12, MinWidth = 280 };
AddOption(modeCombo, App.Loc["None"], null);
- foreach (var m in BrakeSetting.Modes) AddOption(modeCombo, m, m);
+ foreach (var m in BrakeSetting.Modes) AddOption(modeCombo, BrakeModeLabel(m), m);
modeCombo.SelectedIndex = brake?.Mode is { } mode
? Array.IndexOf(BrakeSetting.Modes, mode) + 1 : 0;
string?[] loads = { null, "T", "H", "F", "A" };
- var loadCombo = new ComboBox { FontSize = 12, MinWidth = 150 };
- foreach (var l in loads) AddOption(loadCombo, l ?? App.Loc["None"], l);
+ var loadCombo = new ComboBox { FontSize = 12, MinWidth = 280 };
+ foreach (var l in loads) AddOption(loadCombo, LoadLabel(l), l);
loadCombo.SelectedIndex = Math.Max(0, Array.IndexOf(loads, brake?.Load));
string?[] switches = { null, "0", "1", "A" };
- var switchCombo = new ComboBox { FontSize = 12, MinWidth = 150 };
- foreach (var s in switches) AddOption(switchCombo, s ?? App.Loc["None"], s);
+ var switchCombo = new ComboBox { FontSize = 12, MinWidth = 280 };
+ foreach (var s in switches) AddOption(switchCombo, SwitchLabel(s), s);
switchCombo.SelectedIndex = Math.Max(0, Array.IndexOf(switches, brake?.Switch));
void Apply()
@@ -1103,25 +1192,105 @@ public partial class Depot : UserControl
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
+ {
+ "G" => App.Loc["BrakeFreight"],
+ "P" => App.Loc["BrakePassenger"],
+ "R" => App.Loc["BrakeExpress"],
+ "R+Mg" => App.Loc["BrakeExpressMg"],
+ "Q" => App.Loc["BrakeNoAir"],
+ "O" => App.Loc["BrakeOff"],
+ "A" => App.Loc["BrakeAuto"],
+ _ => code
+ };
+
+ private static string LoadLabel(string? code) => code switch
+ {
+ "T" => App.Loc["LoadEmpty"],
+ "H" => App.Loc["LoadMedium"],
+ "F" => App.Loc["LoadFull"],
+ "A" => App.Loc["LoadAuto"],
+ _ => App.Loc["None"]
+ };
+
+ private static string SwitchLabel(string? code) => code switch
+ {
+ "0" => App.Loc["SwitchOff"],
+ "1" => App.Loc["SwitchOff10"],
+ "A" => App.Loc["SwitchOn"],
+ _ => App.Loc["None"]
+ };
+
+ // Wheel-damage editor: sway / flat-spot parameters (the "W" coupling prefix).
+ private void BuildDamageEditor(ConsistItem item)
+ {
+ var w = item.Cars[0].Coupling.GetWheels() ?? new WheelSettings();
+
+ NumericUpDown Spin(int value, int max) => new()
+ {
+ FontSize = 12, Minimum = 0, Maximum = max, Increment = 1,
+ Value = value, MinWidth = 120, FormatString = "0"
+ };
+
+ var sway = Spin(w.Sway, 100);
+ var flat = Spin(w.Flatness, 100);
+ var flatRand = Spin(w.FlatnessRand, 100);
+ var flatProb = Spin(w.FlatnessProb, 100);
+
+ void Apply()
+ {
+ var ws = new WheelSettings
+ {
+ Sway = (int)(sway.Value ?? 0),
+ Flatness = (int)(flat.Value ?? 0),
+ FlatnessRand = (int)(flatRand.Value ?? 0),
+ FlatnessProb = (int)(flatProb.Value ?? 0)
+ };
+ foreach (var c in item.Cars)
+ c.Coupling.SetWheels(ws);
+ }
+
+ sway.ValueChanged += (_, _) => Apply();
+ flat.ValueChanged += (_, _) => Apply();
+ flatRand.ValueChanged += (_, _) => Apply();
+ flatProb.ValueChanged += (_, _) => Apply();
+
+ damagePanel.Children.Add(UnitTitle(item));
+ damagePanel.Children.Add(LabeledRow(App.Loc["DamageSway"], sway));
+ damagePanel.Children.Add(LabeledRow(App.Loc["DamageFlatness"], flat));
+ damagePanel.Children.Add(LabeledRow(App.Loc["DamageFlatnessRand"], flatRand));
+ damagePanel.Children.Add(LabeledRow(App.Loc["DamageFlatnessProb"], flatProb));
+ }
+
// Cargo type + amount, written into each car's node::dynamic trailing params.
private void BuildLoadsEditor(ConsistItem item)
{
var lead = item.Cars[0];
+ var phys = PhysicsFor(lead);
+
+ // Prefer the cargo this vehicle actually accepts (.fiz LoadAccepted);
+ // otherwise suggest everything from data/load_weights.txt. Free text is
+ // still allowed.
+ var accepted = phys != null && !string.IsNullOrWhiteSpace(phys.LoadAccepted)
+ ? phys.LoadAccepted.Split(',', ';').Select(s => s.Trim()).Where(s => s.Length > 0).ToList()
+ : null;
- // Suggest cargo names from data/load_weights.txt (same source the original
- // Starter uses), while still allowing a free-typed value.
var typeBox = new AutoCompleteBox
{
FontSize = 12,
MinWidth = 200,
Text = lead.LoadType ?? "",
- ItemsSource = LoadTypes(),
+ ItemsSource = accepted ?? (IEnumerable)LoadTypes(),
FilterMode = AutoCompleteFilterMode.ContainsOrdinal
};
+
+ // Cap the amount at the .fiz MaxLoad when it is defined.
+ int maxLoad = phys != null && phys.MaxLoad > 0 ? phys.MaxLoad : 1000;
var countBox = new NumericUpDown
{
- FontSize = 12, Minimum = 0, Maximum = 1000, Increment = 1,
- Value = lead.LoadCount, MinWidth = 120, FormatString = "0"
+ FontSize = 12, Minimum = 0, Maximum = maxLoad, Increment = 1,
+ Value = Math.Min(lead.LoadCount, maxLoad), MinWidth = 120, FormatString = "0"
};
void Apply()
@@ -1149,16 +1318,19 @@ public partial class Depot : UserControl
});
}
- // Cargo names parsed once from data/load_weights.txt (the file the simulator
- // and the original Starter use), for the load-type suggestions. Each line is
- // ": "; only the name (before the colon) is used here.
+ // Cargo names + per-unit weights parsed once from data/load_weights.txt (the
+ // file the simulator and the original Starter use). Each line is
+ // ": ".
private static List? _loadTypes;
- private static IReadOnlyList LoadTypes()
+ private static Dictionary? _loadWeights;
+
+ private static void EnsureLoads()
{
if (_loadTypes != null)
- return _loadTypes;
+ return;
- var list = new List();
+ var names = new List();
+ var weights = new Dictionary(StringComparer.OrdinalIgnoreCase);
try
{
string path = Path.Combine("data", "load_weights.txt");
@@ -1171,17 +1343,32 @@ public partial class Depot : UserControl
continue;
int colon = line.IndexOf(':');
string name = (colon >= 0 ? line[..colon] : line).Trim();
- if (name.Length > 0)
- list.Add(name);
+ if (name.Length == 0) continue;
+ names.Add(name);
+ if (colon >= 0 && int.TryParse(line[(colon + 1)..].Trim(), out int w))
+ weights[name] = w;
}
}
}
catch { /* missing / unreadable - just no suggestions */ }
- _loadTypes = list.Distinct(StringComparer.OrdinalIgnoreCase)
- .OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
- .ToList();
- return _loadTypes;
+ _loadTypes = names.Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ _loadWeights = weights;
+ }
+
+ private static IReadOnlyList LoadTypes()
+ {
+ EnsureLoads();
+ return _loadTypes!;
+ }
+
+ // Per-unit weight (kg) of a cargo, defaulting to 1000 like the original Starter.
+ private static int LoadWeight(string? name)
+ {
+ EnsureLoads();
+ return !string.IsNullOrEmpty(name) && _loadWeights!.TryGetValue(name!, out int w) ? w : 1000;
}
// ------------------------------------------------------------------- minis
diff --git a/StarterNG/starter/lang/en.xml b/StarterNG/startercfg/lang/en.xml
similarity index 87%
rename from StarterNG/starter/lang/en.xml
rename to StarterNG/startercfg/lang/en.xml
index f13304c..743fb20 100644
--- a/StarterNG/starter/lang/en.xml
+++ b/StarterNG/startercfg/lang/en.xml
@@ -121,11 +121,51 @@
Load setting
Brake switch
—
+
+
+ Mechanical
+ Pneumatic 5 atm (brakes)
+ Multiple-unit control
+ High voltage
+ Gangway between vehicles
+ Auxiliary pneumatic 8 atm
+ Heating
+ Workshop coupling (uncouple lock)
+
+
+ Freight (G)
+ Passenger (P)
+ Express (R)
+ Express + magnetic rail (R+Mg)
+ Start without air (Q)
+ Brakes disconnected (O)
+ Automatic (A)
+
+
+ Empty (T)
+ Loaded I — medium (H)
+ Loaded II (F)
+ Automatic (A)
+
+
+ Off (0)
+ Off with 10% chance (1)
+ On (A)
+
+
+ Damage
+ Sway — probability (%)
+ Flat spot (mm)
+ Random flat spot (0–x mm)
+ Flat-spot probability (%)
Load type
Amount
Set an amount above 0 to load the vehicle.
Wagon number
Set
+ Length
+ Mass
+ Load
Select a vehicle in the consist to edit it.
Editing options coming soon.
Split
diff --git a/StarterNG/starter/lang/pl.xml b/StarterNG/startercfg/lang/pl.xml
similarity index 86%
rename from StarterNG/starter/lang/pl.xml
rename to StarterNG/startercfg/lang/pl.xml
index 5224df0..6672adc 100644
--- a/StarterNG/starter/lang/pl.xml
+++ b/StarterNG/startercfg/lang/pl.xml
@@ -121,11 +121,51 @@
Nastawa ładunkowa
Włącznik hamulca
—
+
+
+ Mechaniczny
+ Pneumatyczny 5 atm (hamulce)
+ Ukrotnienie (sterowanie wielokrotne)
+ Wysokie napięcie
+ Przejście między pojazdami
+ Pneumatyczny pomocniczy 8 atm
+ Ogrzewanie
+ Sprzęg warsztatowy (blokada rozłączania)
+
+
+ Towarowa (G)
+ Osobowa (P)
+ Pospieszna (R)
+ Pospieszna + szynowy magnetyczny (R+Mg)
+ Start bez powietrza (Q)
+ Odłączone hamulce (O)
+ Wybór automatyczny (A)
+
+
+ Próżny (T)
+ Ładowny I — średni (H)
+ Ładowny II (F)
+ Wybór automatyczny (A)
+
+
+ Wyłączony (0)
+ Wyłączony z prawd. 10% (1)
+ Włączony (A)
+
+
+ Uszkodzenia
+ Wężykowanie — prawdop. (%)
+ Podkucie (mm)
+ Podkucie losowe (0–x mm)
+ Prawdop. podkucia (%)
Rodzaj ładunku
Ilość
Ustaw ilość większą od 0, aby załadować pojazd.
Numer wagonu
Ustaw
+ Długość
+ Masa
+ Ładunek
Wybierz pojazd w składzie, aby go edytować.
Opcje edycji wkrótce.
Rozdziel
diff --git a/linuix.run/StarterNG (linux64).run.xml b/linuix.run/StarterNG (linux64).run.xml
new file mode 100644
index 0000000..ebe9f91
--- /dev/null
+++ b/linuix.run/StarterNG (linux64).run.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/windows.run/StarterNG (win64).run.xml b/windows.run/StarterNG (win64).run.xml
new file mode 100644
index 0000000..541d912
--- /dev/null
+++ b/windows.run/StarterNG (win64).run.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file