Physics and linux compilation support

This commit is contained in:
2026-06-24 00:33:24 +02:00
parent d932761786
commit 60f61a122d
14 changed files with 706 additions and 48 deletions

View File

@@ -25,4 +25,8 @@ Global
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
GlobalSection(RiderSharedRunConfigurations) = postSolution
File = linuix.run\StarterNG (linux64).run.xml
File = windows.run\StarterNG (win64).run.xml
EndGlobalSection
EndGlobal EndGlobal

View File

@@ -62,6 +62,10 @@ public sealed class GameData
// first thumbnail render doesn't scan textures/mini/ on the UI thread. // 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();
// Phase 2 - sceneries // Phase 2 - sceneries
foreach (string file in scnFiles) foreach (string file in scnFiles)
{ {

View File

@@ -0,0 +1,230 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
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 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<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;
/// <summary>Builds the .fiz index up front (call from the startup load).</summary>
public static void PreloadIndex(string dynamicRoot = "dynamic") => FizIndex(dynamicRoot);
private static Dictionary<string, string> FizIndex(string dynamicRoot)
{
if (_fizIndex != null)
return _fizIndex;
var index = new Dictionary<string, string>(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;
}
/// <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")
{
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<string> 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 <file> <params...> 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<string>();
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<string> 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();
}
}

View File

@@ -153,6 +153,53 @@ public sealed class Settings
private static string DefaultConfigPath() => private static string DefaultConfigPath() =>
Path.Combine(Directory.GetCurrentDirectory(), "eu07.ini"); Path.Combine(Directory.GetCurrentDirectory(), "eu07.ini");
/// <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()
{
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 readonly Encoding FileEncoding = ResolveEncoding();
private static Encoding ResolveEncoding() private static Encoding ResolveEncoding()

View File

@@ -395,6 +395,19 @@ 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() =>
WheelSettings.FromParameter(
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)
{
Parameters.RemoveAll(p => p.StartsWith("W", StringComparison.Ordinal));
if (wheels != null && !wheels.IsEmpty)
Parameters.Add(wheels.ToParameter());
}
public Coupling Clone() => new() public Coupling Clone() => new()
{ {
Flags = Flags, Flags = Flags,
@@ -451,4 +464,61 @@ 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
{
/// <summary>H — sway ("wężykowanie") probability, %.</summary>
public int Sway;
/// <summary>F — flat spot ("podkucie") of this size, mm.</summary>
public int Flatness;
/// <summary>R — additional random flat spot, 0..x mm.</summary>
public int FlatnessRand;
/// <summary>P — flat-spot probability, % (guaranteed when omitted).</summary>
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();
}
} }

View File

@@ -122,14 +122,19 @@ public partial class MainWindow : Window
Settings.Instance.CaptureAndSave(); Settings.Instance.CaptureAndSave();
// launch the game: -s $<name>.scn -v <vehicle>. Keep the handle so we can // launch the game: -s $<name>.scn -v <vehicle>. 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; Process? sim;
try 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}", Arguments = $"-s {exportName} -v {vehicle}",
UseShellExecute = true WorkingDirectory = Path.GetDirectoryName(exe) ?? Directory.GetCurrentDirectory(),
UseShellExecute = false
}); });
} }
catch catch
@@ -162,7 +167,7 @@ public partial class MainWindow : Window
{ {
if (sim is null) if (sim is null)
{ {
string name = Path.GetFileNameWithoutExtension(Settings.Instance.ExecutablePath); string name = Path.GetFileNameWithoutExtension(Settings.Instance.ResolveExecutable());
sim = Process.GetProcessesByName(name).FirstOrDefault(); sim = Process.GetProcessesByName(name).FirstOrDefault();
} }

View File

@@ -23,7 +23,7 @@ public class LocalizationService : INotifyPropertyChanged
{ {
/// <summary>Folder holding the language files, resolved as starter/lang next to the executable.</summary> /// <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, "starter", "lang"); Path.Combine(AppContext.BaseDirectory, "startercfg", "lang");
private Dictionary<string, string> _strings = new(); private Dictionary<string, string> _strings = new();

View File

@@ -2,14 +2,15 @@
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<PublishAot>true</PublishAot> <PublishAot>true</PublishAot>
<PublishTrimmed>true</PublishTrimmed>
<IsAotCompatible>true</IsAotCompatible> <IsAotCompatible>true</IsAotCompatible>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport> <BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<AssemblyVersion>1.0.0.1</AssemblyVersion> <AssemblyVersion>1.1.0.3</AssemblyVersion>
<FileVersion>1.0.0.1</FileVersion> <FileVersion>1.1.0.3</FileVersion>
<AssemblyName>Starter</AssemblyName> <AssemblyName>Starter</AssemblyName>
<Company>eu07.pl</Company> <Company>eu07.pl</Company>
</PropertyGroup> </PropertyGroup>
@@ -54,9 +55,9 @@
compiled into the assembly. They are copied verbatim into a starter/lang/ 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 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. --> rebuild. XmlReader parsing is AOT-safe, unlike the Avalonia XAML loader. -->
<Content Include="starter\lang\*.xml"> <Content Include="startercfg\lang\*.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>starter\lang\%(Filename)%(Extension)</Link> <Link>startercfg\lang\%(Filename)%(Extension)</Link>
</Content> </Content>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -86,6 +86,11 @@
<StackPanel Name="loadsPanel" Margin="4" Spacing="8" /> <StackPanel Name="loadsPanel" Margin="4" Spacing="8" />
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
<TabItem Header="{Binding [Damage], Source={x:Static starterNg:App.Loc}}">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Name="damagePanel" Margin="4" Spacing="8" />
</ScrollViewer>
</TabItem>
</TabControl> </TabControl>
</DockPanel> </DockPanel>
</HeaderedContentControl> </HeaderedContentControl>
@@ -93,15 +98,20 @@
<!-- Consist (full-width bottom, horizontally scrollable) --> <!-- Consist (full-width bottom, horizontally scrollable) -->
<HeaderedContentControl Grid.Row="2" Grid.ColumnSpan="2" Theme="{StaticResource GroupBox}" <HeaderedContentControl Grid.Row="2" Grid.ColumnSpan="2" Theme="{StaticResource GroupBox}"
Header="{Binding [Consist], Source={x:Static starterNg:App.Loc}}"> Header="{Binding [Consist], Source={x:Static starterNg:App.Loc}}">
<Panel> <DockPanel LastChildFill="True">
<TextBlock Name="emptyHint" VerticalAlignment="Center" HorizontalAlignment="Center" <!-- Train totals computed from the .fiz files -->
TextWrapping="Wrap" MaxWidth="520" Opacity="0.6" TextAlignment="Center" <TextBlock Name="trainStats" DockPanel.Dock="Top" FontSize="12"
Text="{Binding [ConsistEmpty], Source={x:Static starterNg:App.Loc}}" /> Opacity="0.85" Margin="0,0,0,6" />
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"> <Panel>
<StackPanel Name="consistStack" Orientation="Horizontal" <TextBlock Name="emptyHint" VerticalAlignment="Center" HorizontalAlignment="Center"
VerticalAlignment="Center" Spacing="0" /> TextWrapping="Wrap" MaxWidth="520" Opacity="0.6" TextAlignment="Center"
</ScrollViewer> Text="{Binding [ConsistEmpty], Source={x:Static starterNg:App.Loc}}" />
</Panel> <ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel Name="consistStack" Orientation="Horizontal"
VerticalAlignment="Center" Spacing="0" />
</ScrollViewer>
</Panel>
</DockPanel>
</HeaderedContentControl> </HeaderedContentControl>
</Grid> </Grid>
</UserControl> </UserControl>

View File

@@ -118,11 +118,11 @@ public partial class Depot : UserControl
// Car-type suffixes (longest first) used only for the consist unit label. // 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 labels in mask-bit order (bit i = value 1<<i), per the wiki: // Coupling-bit localisation keys in mask-bit order (bit i = value 1<<i), per
// https://wiki.eu07.pl/index.php?title=Wpisy_hamulca_dla_pojazdow // the wiki: https://wiki.eu07.pl/index.php?title=Wpisy_hamulca_dla_pojazdow
private static readonly string[] CouplingBits = private static readonly string[] CouplingBitKeys =
{ "Mechanical", "Brake pipe", "Control (MU)", "High voltage", { "CplMechanical", "CplBrake", "CplControl", "CplHighVoltage",
"Gangway", "Aux pneumatic", "Heating", "Workshop lock" }; "CplGangway", "CplAuxPneumatic", "CplHeating", "CplWorkshop" };
public Depot() public Depot()
{ {
@@ -463,6 +463,7 @@ public partial class Depot : UserControl
}; };
_consist[i] = item; _consist[i] = item;
_selected = item; _selected = item;
AutoConnectAll();
RebuildConsist(); RebuildConsist();
} }
@@ -503,6 +504,7 @@ public partial class Depot : UserControl
} }
_consist.Insert(at, item); _consist.Insert(at, item);
_selected = item; _selected = item;
AutoConnectAll();
RebuildConsist(); RebuildConsist();
} }
@@ -686,6 +688,7 @@ public partial class Depot : UserControl
emptyHint.IsVisible = _consist.Count == 0; emptyHint.IsVisible = _consist.Count == 0;
UpdateDetails(); UpdateDetails();
UpdateTrainStats();
WriteBackToScenery(); WriteBackToScenery();
} }
@@ -710,6 +713,89 @@ public partial class Depot : UserControl
_editingTrainset.Vehicles = flat; _editingTrainset.Vehicles = flat;
} }
// 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)
{
string? dbModel = _db.TextureForSkin(car.SkinFile)?.Model;
return Physics.For(car.DataFolder, dbModel)
?? Physics.For(car.DataFolder, car.MmdFile)
?? 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()
{
var flat = new List<(Dynamic car, bool flipped)>();
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) private Control BuildCard(ConsistItem item)
{ {
bool selected = ReferenceEquals(_selected, item); bool selected = ReferenceEquals(_selected, item);
@@ -961,12 +1047,12 @@ public partial class Depot : UserControl
Margin = new Thickness(0, 0, 0, 2) 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; int bit = 1 << i;
var check = new CheckBox var check = new CheckBox
{ {
Content = CouplingBits[i], Content = App.Loc[CouplingBitKeys[i]],
IsChecked = d.Coupling.Has(bit), IsChecked = d.Coupling.Has(bit),
FontSize = 11 FontSize = 11
}; };
@@ -1016,16 +1102,19 @@ public partial class Depot : UserControl
{ {
brakesPanel.Children.Clear(); brakesPanel.Children.Clear();
loadsPanel.Children.Clear(); loadsPanel.Children.Clear();
damagePanel.Children.Clear();
if (_selected is null) if (_selected is null)
{ {
brakesPanel.Children.Add(DetailHint(App.Loc["SelectVehicleHint"])); brakesPanel.Children.Add(DetailHint(App.Loc["SelectVehicleHint"]));
loadsPanel.Children.Add(DetailHint(App.Loc["SelectVehicleHint"])); loadsPanel.Children.Add(DetailHint(App.Loc["SelectVehicleHint"]));
damagePanel.Children.Add(DetailHint(App.Loc["SelectVehicleHint"]));
return; return;
} }
BuildBrakesEditor(_selected); BuildBrakesEditor(_selected);
BuildLoadsEditor(_selected); BuildLoadsEditor(_selected);
BuildDamageEditor(_selected);
} }
private static TextBlock DetailHint(string text) => new() private static TextBlock DetailHint(string text) => new()
@@ -1060,20 +1149,20 @@ public partial class Depot : UserControl
{ {
var brake = item.Cars[0].Coupling.GetBrake(); 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); 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 modeCombo.SelectedIndex = brake?.Mode is { } mode
? Array.IndexOf(BrakeSetting.Modes, mode) + 1 : 0; ? Array.IndexOf(BrakeSetting.Modes, mode) + 1 : 0;
string?[] loads = { null, "T", "H", "F", "A" }; string?[] loads = { null, "T", "H", "F", "A" };
var loadCombo = new ComboBox { FontSize = 12, MinWidth = 150 }; var loadCombo = new ComboBox { FontSize = 12, MinWidth = 280 };
foreach (var l in loads) AddOption(loadCombo, l ?? App.Loc["None"], l); foreach (var l in loads) AddOption(loadCombo, LoadLabel(l), l);
loadCombo.SelectedIndex = Math.Max(0, Array.IndexOf(loads, brake?.Load)); loadCombo.SelectedIndex = Math.Max(0, Array.IndexOf(loads, brake?.Load));
string?[] switches = { null, "0", "1", "A" }; string?[] switches = { null, "0", "1", "A" };
var switchCombo = new ComboBox { FontSize = 12, MinWidth = 150 }; var switchCombo = new ComboBox { FontSize = 12, MinWidth = 280 };
foreach (var s in switches) AddOption(switchCombo, s ?? App.Loc["None"], s); foreach (var s in switches) AddOption(switchCombo, SwitchLabel(s), s);
switchCombo.SelectedIndex = Math.Max(0, Array.IndexOf(switches, brake?.Switch)); switchCombo.SelectedIndex = Math.Max(0, Array.IndexOf(switches, brake?.Switch));
void Apply() void Apply()
@@ -1103,25 +1192,105 @@ 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
{
"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. // 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);
// 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 var typeBox = new AutoCompleteBox
{ {
FontSize = 12, FontSize = 12,
MinWidth = 200, MinWidth = 200,
Text = lead.LoadType ?? "", Text = lead.LoadType ?? "",
ItemsSource = LoadTypes(), ItemsSource = accepted ?? (IEnumerable<string>)LoadTypes(),
FilterMode = AutoCompleteFilterMode.ContainsOrdinal 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 var countBox = new NumericUpDown
{ {
FontSize = 12, Minimum = 0, Maximum = 1000, Increment = 1, FontSize = 12, Minimum = 0, Maximum = maxLoad, Increment = 1,
Value = lead.LoadCount, MinWidth = 120, FormatString = "0" Value = Math.Min(lead.LoadCount, maxLoad), MinWidth = 120, FormatString = "0"
}; };
void Apply() void Apply()
@@ -1149,16 +1318,19 @@ public partial class Depot : UserControl
}); });
} }
// Cargo names parsed once from data/load_weights.txt (the file the simulator // Cargo names + per-unit weights parsed once from data/load_weights.txt (the
// and the original Starter use), for the load-type suggestions. Each line is // file the simulator and the original Starter use). Each line is
// "<name>: <weight>"; only the name (before the colon) is used here. // "<name>: <weight>".
private static List<string>? _loadTypes; private static List<string>? _loadTypes;
private static IReadOnlyList<string> LoadTypes() private static Dictionary<string, int>? _loadWeights;
private static void EnsureLoads()
{ {
if (_loadTypes != null) if (_loadTypes != null)
return _loadTypes; return;
var list = new List<string>(); var names = new List<string>();
var weights = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
try try
{ {
string path = Path.Combine("data", "load_weights.txt"); string path = Path.Combine("data", "load_weights.txt");
@@ -1171,17 +1343,32 @@ public partial class Depot : UserControl
continue; continue;
int colon = line.IndexOf(':'); int colon = line.IndexOf(':');
string name = (colon >= 0 ? line[..colon] : line).Trim(); string name = (colon >= 0 ? line[..colon] : line).Trim();
if (name.Length > 0) if (name.Length == 0) continue;
list.Add(name); names.Add(name);
if (colon >= 0 && int.TryParse(line[(colon + 1)..].Trim(), out int w))
weights[name] = w;
} }
} }
} }
catch { /* missing / unreadable - just no suggestions */ } catch { /* missing / unreadable - just no suggestions */ }
_loadTypes = list.Distinct(StringComparer.OrdinalIgnoreCase) _loadTypes = names.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase) .OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
return _loadTypes; _loadWeights = weights;
}
private static IReadOnlyList<string> 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 // ------------------------------------------------------------------- minis

View File

@@ -121,11 +121,51 @@
<String key="BrakeLoad">Load setting</String> <String key="BrakeLoad">Load setting</String>
<String key="BrakeSwitch">Brake switch</String> <String key="BrakeSwitch">Brake switch</String>
<String key="None"></String> <String key="None"></String>
<!-- Coupling mask bits -->
<String key="CplMechanical">Mechanical</String>
<String key="CplBrake">Pneumatic 5 atm (brakes)</String>
<String key="CplControl">Multiple-unit control</String>
<String key="CplHighVoltage">High voltage</String>
<String key="CplGangway">Gangway between vehicles</String>
<String key="CplAuxPneumatic">Auxiliary pneumatic 8 atm</String>
<String key="CplHeating">Heating</String>
<String key="CplWorkshop">Workshop coupling (uncouple lock)</String>
<!-- Brake modes -->
<String key="BrakeFreight">Freight (G)</String>
<String key="BrakePassenger">Passenger (P)</String>
<String key="BrakeExpress">Express (R)</String>
<String key="BrakeExpressMg">Express + magnetic rail (R+Mg)</String>
<String key="BrakeNoAir">Start without air (Q)</String>
<String key="BrakeOff">Brakes disconnected (O)</String>
<String key="BrakeAuto">Automatic (A)</String>
<!-- Load adaptation -->
<String key="LoadEmpty">Empty (T)</String>
<String key="LoadMedium">Loaded I — medium (H)</String>
<String key="LoadFull">Loaded II (F)</String>
<String key="LoadAuto">Automatic (A)</String>
<!-- Brake switch -->
<String key="SwitchOff">Off (0)</String>
<String key="SwitchOff10">Off with 10% chance (1)</String>
<String key="SwitchOn">On (A)</String>
<!-- Damage (wheel parameters) -->
<String key="Damage">Damage</String>
<String key="DamageSway">Sway — probability (%)</String>
<String key="DamageFlatness">Flat spot (mm)</String>
<String key="DamageFlatnessRand">Random flat spot (0x mm)</String>
<String key="DamageFlatnessProb">Flat-spot probability (%)</String>
<String key="LoadType">Load type</String> <String key="LoadType">Load type</String>
<String key="LoadCount">Amount</String> <String key="LoadCount">Amount</String>
<String key="LoadHint">Set an amount above 0 to load the vehicle.</String> <String key="LoadHint">Set an amount above 0 to load the vehicle.</String>
<String key="WagonNumber">Wagon number</String> <String key="WagonNumber">Wagon number</String>
<String key="Set">Set</String> <String key="Set">Set</String>
<String key="Length">Length</String>
<String key="Mass">Mass</String>
<String key="Load">Load</String>
<String key="SelectVehicleHint">Select a vehicle in the consist to edit it.</String> <String key="SelectVehicleHint">Select a vehicle in the consist to edit it.</String>
<String key="ComingSoon">Editing options coming soon.</String> <String key="ComingSoon">Editing options coming soon.</String>
<String key="Split">Split</String> <String key="Split">Split</String>

View File

@@ -121,11 +121,51 @@
<String key="BrakeLoad">Nastawa ładunkowa</String> <String key="BrakeLoad">Nastawa ładunkowa</String>
<String key="BrakeSwitch">Włącznik hamulca</String> <String key="BrakeSwitch">Włącznik hamulca</String>
<String key="None"></String> <String key="None"></String>
<!-- Bity maski sprzęgu -->
<String key="CplMechanical">Mechaniczny</String>
<String key="CplBrake">Pneumatyczny 5 atm (hamulce)</String>
<String key="CplControl">Ukrotnienie (sterowanie wielokrotne)</String>
<String key="CplHighVoltage">Wysokie napięcie</String>
<String key="CplGangway">Przejście między pojazdami</String>
<String key="CplAuxPneumatic">Pneumatyczny pomocniczy 8 atm</String>
<String key="CplHeating">Ogrzewanie</String>
<String key="CplWorkshop">Sprzęg warsztatowy (blokada rozłączania)</String>
<!-- Tryby nastawy hamulca -->
<String key="BrakeFreight">Towarowa (G)</String>
<String key="BrakePassenger">Osobowa (P)</String>
<String key="BrakeExpress">Pospieszna (R)</String>
<String key="BrakeExpressMg">Pospieszna + szynowy magnetyczny (R+Mg)</String>
<String key="BrakeNoAir">Start bez powietrza (Q)</String>
<String key="BrakeOff">Odłączone hamulce (O)</String>
<String key="BrakeAuto">Wybór automatyczny (A)</String>
<!-- Nastawa ładunkowa -->
<String key="LoadEmpty">Próżny (T)</String>
<String key="LoadMedium">Ładowny I — średni (H)</String>
<String key="LoadFull">Ładowny II (F)</String>
<String key="LoadAuto">Wybór automatyczny (A)</String>
<!-- Włącznik hamulca -->
<String key="SwitchOff">Wyłączony (0)</String>
<String key="SwitchOff10">Wyłączony z prawd. 10% (1)</String>
<String key="SwitchOn">Włączony (A)</String>
<!-- Uszkodzenia (parametry kół) -->
<String key="Damage">Uszkodzenia</String>
<String key="DamageSway">Wężykowanie — prawdop. (%)</String>
<String key="DamageFlatness">Podkucie (mm)</String>
<String key="DamageFlatnessRand">Podkucie losowe (0x mm)</String>
<String key="DamageFlatnessProb">Prawdop. podkucia (%)</String>
<String key="LoadType">Rodzaj ładunku</String> <String key="LoadType">Rodzaj ładunku</String>
<String key="LoadCount">Ilość</String> <String key="LoadCount">Ilość</String>
<String key="LoadHint">Ustaw ilość większą od 0, aby załadować pojazd.</String> <String key="LoadHint">Ustaw ilość większą od 0, aby załadować pojazd.</String>
<String key="WagonNumber">Numer wagonu</String> <String key="WagonNumber">Numer wagonu</String>
<String key="Set">Ustaw</String> <String key="Set">Ustaw</String>
<String key="Length">Długość</String>
<String key="Mass">Masa</String>
<String key="Load">Ładunek</String>
<String key="SelectVehicleHint">Wybierz pojazd w składzie, aby go edytować.</String> <String key="SelectVehicleHint">Wybierz pojazd w składzie, aby go edytować.</String>
<String key="ComingSoon">Opcje edycji wkrótce.</String> <String key="ComingSoon">Opcje edycji wkrótce.</String>
<String key="Split">Rozdziel</String> <String key="Split">Rozdziel</String>

View File

@@ -0,0 +1,10 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="StarterNG (linux64)" type="DotNetFolderPublish" factoryName="Publish to folder">
<riderPublish configuration="Release" delete_existing_files="true" platform="x64" produce_single_file="true" ready_to_run="true" self_contained="true" target_folder="$PROJECT_DIR$/StarterNG/bin/Release/net9.0/linux-x64/publish" target_framework="net9.0" trim_unused_assemblies="true" uuid_high="-3321224803864985254" uuid_low="-7733288644066679555">
<runtimes>
<item value="linux-x64" />
</runtimes>
</riderPublish>
<method v="2" />
</configuration>
</component>

View File

@@ -0,0 +1,10 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="StarterNG (win64)" type="DotNetFolderPublish" factoryName="Publish to folder">
<riderPublish configuration="Release" delete_existing_files="true" platform="x64" produce_single_file="true" ready_to_run="true" self_contained="true" target_folder="$PROJECT_DIR$/StarterNG/bin/Release/net9.0/win-x64/publish" target_framework="net9.0" trim_unused_assemblies="true" uuid_high="-3321224803864985254" uuid_low="-7733288644066679555">
<runtimes>
<item value="win-x64" />
</runtimes>
</riderPublish>
<method v="2" />
</configuration>
</component>