mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-11-03 21:02:06 +01:00
Move rolling stock and physics into the domain
VehicleDatabase becomes VehicleCatalog, Physics becomes VehiclePhysics, and the textures.txt and .fiz parsers move to Infrastructure. GameLibrary replaces the GameData singleton.
This commit is contained in:
@@ -125,7 +125,7 @@ public partial class App : Avalonia.Application
|
||||
});
|
||||
});
|
||||
|
||||
GameData.Instance.Load(progress);
|
||||
AppServices.Current.Library.Load(progress);
|
||||
}).ContinueWith(load =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(async () =>
|
||||
|
||||
@@ -51,5 +51,11 @@ public interface IFileSystem
|
||||
/// <summary>Top-level directories, empty when the directory does not exist.</summary>
|
||||
IReadOnlyList<string> GetDirectories(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Files matching the pattern anywhere below the path. Empty when the
|
||||
/// directory does not exist.
|
||||
/// </summary>
|
||||
IReadOnlyList<string> GetFilesRecursive(string path, string searchPattern);
|
||||
|
||||
DateTime GetLastWriteTimeUtc(string path);
|
||||
}
|
||||
|
||||
19
StarterNG/Application/Abstractions/IMiniTextureIndex.cs
Normal file
19
StarterNG/Application/Abstractions/IMiniTextureIndex.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace StarterNG.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// The thumbnails in textures/mini, looked up by name. Backed by one directory
|
||||
/// scan; a name with no bitmap falls back to the generic "other" thumbnail.
|
||||
/// </summary>
|
||||
public interface IMiniTextureIndex
|
||||
{
|
||||
/// <summary>Builds the index up front, off the UI thread.</summary>
|
||||
void Preload();
|
||||
|
||||
bool Has(string? miniName);
|
||||
|
||||
/// <summary>Bitmap for the name, the fallback thumbnail, or null when neither exists.</summary>
|
||||
string? PathFor(string? miniName);
|
||||
|
||||
/// <summary>The generic thumbnail shown for unknown stock.</summary>
|
||||
string? FallbackPath { get; }
|
||||
}
|
||||
21
StarterNG/Application/Abstractions/IPhysicsRepository.cs
Normal file
21
StarterNG/Application/Abstractions/IPhysicsRepository.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using StarterNG.Domain.Vehicles;
|
||||
|
||||
namespace StarterNG.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Looks up the .fiz physics of a vehicle type by model name.
|
||||
/// </summary>
|
||||
public interface IPhysicsRepository
|
||||
{
|
||||
/// <summary>Indexes the installation's .fiz files up front, off the UI thread.</summary>
|
||||
void Preload();
|
||||
|
||||
/// <summary>How many vehicle types were indexed; zero means a broken installation.</summary>
|
||||
int IndexedCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Physics for a model name (with or without extension), or null when the
|
||||
/// installation has no .fiz for it.
|
||||
/// </summary>
|
||||
VehiclePhysics? For(string? dataFolder, string? model);
|
||||
}
|
||||
17
StarterNG/Application/Abstractions/IVehicleRepository.cs
Normal file
17
StarterNG/Application/Abstractions/IVehicleRepository.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using StarterNG.Domain.Vehicles;
|
||||
|
||||
namespace StarterNG.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the installation's rolling stock. The only route from the dynamic/
|
||||
/// folder to a <see cref="VehicleCatalog"/>.
|
||||
/// </summary>
|
||||
public interface IVehicleRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Fills the catalogue from every textures.txt under dynamic/. Returns the
|
||||
/// number of liveries read, which the caller uses to spot an installation
|
||||
/// with no rolling stock at all.
|
||||
/// </summary>
|
||||
int Load(VehicleCatalog catalog);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
using StarterNG.Infrastructure.Sceneries;
|
||||
using StarterNG.Infrastructure.Vehicles;
|
||||
using StarterNG.Services;
|
||||
|
||||
namespace StarterNG.Application;
|
||||
@@ -39,6 +40,11 @@ public sealed class AppServices
|
||||
Localization = new LocalizationService(log);
|
||||
Sceneries = new SceneryRepository(files, paths, log, new SceneryParser(clock, Random));
|
||||
SceneryImages = new SceneryImageLocator(files);
|
||||
MiniTextures = new MiniTextureIndex(files, paths);
|
||||
Vehicles = new TexturesTxtVehicleRepository(files, paths, log, new TexturesTxtParser());
|
||||
Physics = new FizPhysicsRepository(files, paths, log);
|
||||
MissingAssets = new MissingAssetScanner(files, paths);
|
||||
Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, log);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -76,6 +82,17 @@ public sealed class AppServices
|
||||
|
||||
public SceneryImageLocator SceneryImages { get; }
|
||||
|
||||
public IMiniTextureIndex MiniTextures { get; }
|
||||
|
||||
public IVehicleRepository Vehicles { get; }
|
||||
|
||||
public IPhysicsRepository Physics { get; }
|
||||
|
||||
public MissingAssetScanner MissingAssets { get; }
|
||||
|
||||
/// <summary>Everything read out of the installation: rolling stock and scenarios.</summary>
|
||||
public GameLibrary Library { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Exposed as the concrete service rather than <see cref="ILocalizedStrings"/>
|
||||
/// because the XAML binds to it as a change-notifying source; code should take
|
||||
|
||||
88
StarterNG/Application/GameLibrary.cs
Normal file
88
StarterNG/Application/GameLibrary.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
|
||||
namespace StarterNG.Application;
|
||||
|
||||
/// <summary>Which part of the installation the loader is working through.</summary>
|
||||
public enum LoadPhase
|
||||
{
|
||||
Vehicles,
|
||||
Sceneries,
|
||||
Done
|
||||
}
|
||||
|
||||
/// <summary>How far the load has got, for the splash screen.</summary>
|
||||
public readonly record struct LoadStatus(double Fraction, LoadPhase Phase, string? Detail);
|
||||
|
||||
/// <summary>
|
||||
/// Everything the starter reads out of the installation once: the rolling stock
|
||||
/// catalogue and the list of scenarios.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Was the <c>GameData</c> singleton, which also owned directory enumeration and
|
||||
/// parallel parsing. It is now an ordinary object built by the composition root,
|
||||
/// delegating to the repositories; the loading policy (what is read, in what
|
||||
/// order, what counts as progress) is all that is left here.
|
||||
/// </remarks>
|
||||
public sealed class GameLibrary
|
||||
{
|
||||
private readonly IVehicleRepository _vehicles;
|
||||
private readonly ISceneryRepository _sceneries;
|
||||
private readonly IMiniTextureIndex _minis;
|
||||
private readonly IPhysicsRepository _physics;
|
||||
private readonly IDiagnosticsLog _log;
|
||||
|
||||
public GameLibrary(IVehicleRepository vehicles, ISceneryRepository sceneries, IMiniTextureIndex minis,
|
||||
IPhysicsRepository physics, IDiagnosticsLog log)
|
||||
{
|
||||
_vehicles = vehicles;
|
||||
_sceneries = sceneries;
|
||||
_minis = minis;
|
||||
_physics = physics;
|
||||
_log = log;
|
||||
Vehicles = new VehicleCatalog(minis);
|
||||
}
|
||||
|
||||
public VehicleCatalog Vehicles { get; }
|
||||
|
||||
public List<Scenery> Sceneries { get; } = new();
|
||||
|
||||
public bool Loaded { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reads the whole installation. Vehicles first, because the scenery list
|
||||
/// resolves consists against them; the thumbnail and physics indexes are
|
||||
/// built alongside since they are independent directory scans.
|
||||
/// </summary>
|
||||
public void Load(IProgress<LoadStatus>? progress = null)
|
||||
{
|
||||
if (Loaded)
|
||||
return;
|
||||
|
||||
progress?.Report(new LoadStatus(0, LoadPhase.Vehicles, "textures.txt"));
|
||||
if (_vehicles.Load(Vehicles) == 0)
|
||||
_log.Log("dynamic: brak plikow textures.txt");
|
||||
|
||||
Parallel.Invoke(_minis.Preload, _physics.Preload);
|
||||
|
||||
ReloadSceneries(progress is null
|
||||
? null
|
||||
: new Progress<SceneryLoadProgress>(step => progress.Report(
|
||||
new LoadStatus((double)step.Loaded / step.Total, LoadPhase.Sceneries, step.FileName))));
|
||||
|
||||
progress?.Report(new LoadStatus(1.0, LoadPhase.Done, null));
|
||||
Loaded = true;
|
||||
}
|
||||
|
||||
/// <summary>Re-reads the scenery folder, picking up files added since startup.</summary>
|
||||
public void ReloadSceneries(IProgress<SceneryLoadProgress>? progress = null)
|
||||
{
|
||||
var reloaded = _sceneries.LoadAll(progress);
|
||||
Sceneries.Clear();
|
||||
Sceneries.AddRange(reloaded);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StarterNG.Application;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
|
||||
namespace StarterNG.Classes;
|
||||
|
||||
public enum LoadPhase
|
||||
{
|
||||
Vehicles,
|
||||
Sceneries,
|
||||
Done
|
||||
}
|
||||
|
||||
public readonly record struct LoadStatus(double Fraction, LoadPhase Phase, string? Detail);
|
||||
|
||||
public sealed class GameData
|
||||
{
|
||||
public static GameData Instance { get; } = new();
|
||||
|
||||
public VehicleDatabase Vehicles { get; } = new();
|
||||
public List<Scenery> Sceneries { get; } = new();
|
||||
|
||||
public bool Loaded { get; private set; }
|
||||
|
||||
public void Load(IProgress<LoadStatus>? progress = null,
|
||||
string miniDir = "textures/mini/",
|
||||
string dynamicRoot = "dynamic")
|
||||
{
|
||||
if (Loaded)
|
||||
return;
|
||||
|
||||
progress?.Report(new LoadStatus(0, LoadPhase.Vehicles, "textures.txt"));
|
||||
int vehicles = Vehicles.LoadFromTexturesTxt(dynamicRoot);
|
||||
if (vehicles == 0)
|
||||
Infrastructure.Diagnostics.Log($"{dynamicRoot}: brak plikow textures.txt");
|
||||
|
||||
Parallel.Invoke(
|
||||
() => VehicleDatabase.PreloadMiniIndex(miniDir),
|
||||
() => Physics.PreloadIndex());
|
||||
|
||||
var sceneryProgress = progress is null
|
||||
? null
|
||||
: new Progress<SceneryLoadProgress>(step => progress.Report(
|
||||
new LoadStatus((double)step.Loaded / step.Total, LoadPhase.Sceneries, step.FileName)));
|
||||
ReloadSceneries(sceneryProgress);
|
||||
|
||||
progress?.Report(new LoadStatus(1.0, LoadPhase.Done, null));
|
||||
Loaded = true;
|
||||
}
|
||||
|
||||
public void ReloadSceneries(IProgress<SceneryLoadProgress>? progress = null)
|
||||
{
|
||||
Sceneries.Clear();
|
||||
Sceneries.AddRange(AppServices.Current.Sceneries.LoadAll(progress));
|
||||
}
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace StarterNG.Classes;
|
||||
|
||||
public sealed class Physics
|
||||
{
|
||||
public double Mass;
|
||||
public double VMax;
|
||||
public double Length;
|
||||
public string LoadAccepted = "";
|
||||
public string LoadQ = "";
|
||||
public int MaxLoad;
|
||||
public int AllowedFlagA = 3;
|
||||
public int AllowedFlagB = 3;
|
||||
public string ControlTypeA = "";
|
||||
public string ControlTypeB = "";
|
||||
|
||||
public string EngineType = "";
|
||||
|
||||
/// <summary>
|
||||
/// True for the engine types the simulator actually runs its diesel heat model for.
|
||||
/// Mover.cpp calls dizel_Update - and through it dizel_Heat - only for DieselEngine
|
||||
/// and DieselElectric; LoadFIZ_EngineDecode maps the legacy "DumbDE" onto
|
||||
/// DieselElectric. On anything else the coolant temperature struct is written and
|
||||
/// never read.
|
||||
/// </summary>
|
||||
public bool IsDieselEngine =>
|
||||
EngineType.Equals("DieselEngine", StringComparison.OrdinalIgnoreCase)
|
||||
|| EngineType.Equals("DieselElectric", StringComparison.OrdinalIgnoreCase)
|
||||
|| EngineType.Equals("DumbDE", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static readonly Dictionary<string, Physics?> Cache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static Dictionary<string, string>? _fizIndex;
|
||||
|
||||
public static void PreloadIndex(string dynamicRoot = "dynamic") => FizIndex(dynamicRoot);
|
||||
|
||||
public static int IndexedCount => _fizIndex?.Count ?? 0;
|
||||
|
||||
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 (Exception ex)
|
||||
{
|
||||
StarterNG.Infrastructure.Diagnostics.Log($"indexing {dynamicRoot}", ex);
|
||||
}
|
||||
|
||||
_fizIndex = index;
|
||||
return index;
|
||||
}
|
||||
|
||||
public static Physics? For(string? dataFolder, string? model, string dynamicRoot = "dynamic")
|
||||
{
|
||||
if (string.IsNullOrEmpty(model))
|
||||
return null;
|
||||
|
||||
string m = Path.GetFileNameWithoutExtension(model!);
|
||||
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 };
|
||||
try { Parse(phys, path, Path.GetDirectoryName(path) ?? "", null, 0); }
|
||||
catch (Exception ex) { StarterNG.Infrastructure.Diagnostics.Log($"physics/{Path.GetFileName(path)}", ex); }
|
||||
|
||||
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", "LoadQ", "MaxLoad", "AllowedFlag", "ControlType",
|
||||
"EngineType"
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
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("LoadQ", StringComparison.OrdinalIgnoreCase)) p.LoadQ = value;
|
||||
else if (key.Equals("MaxLoad", StringComparison.OrdinalIgnoreCase)) p.MaxLoad = (int)ParseD(value, p.MaxLoad);
|
||||
break;
|
||||
case "engine":
|
||||
if (key.Equals("EngineType", StringComparison.OrdinalIgnoreCase)) p.EngineType = value;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using StarterNG.Application;
|
||||
|
||||
namespace StarterNG.Classes;
|
||||
|
||||
@@ -395,7 +396,7 @@ public sealed class Settings
|
||||
if (!LogMissingVehicleFiles) return;
|
||||
try
|
||||
{
|
||||
var lines = GameData.Instance.Vehicles.CollectMissingAssetLines();
|
||||
var lines = AppServices.Current.MissingAssets.Scan(AppServices.Current.Library.Vehicles);
|
||||
if (lines.Count == 0) return;
|
||||
Infrastructure.Diagnostics.Log(lines);
|
||||
}
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace StarterNG.Classes;
|
||||
|
||||
public static class TexturesTxt
|
||||
{
|
||||
public static List<string> EnumerateFiles(string dynamicRoot = "dynamic")
|
||||
{
|
||||
var files = new List<string>();
|
||||
if (!Directory.Exists(dynamicRoot))
|
||||
return files;
|
||||
|
||||
foreach (string group in Directory.EnumerateDirectories(dynamicRoot))
|
||||
foreach (string sub in Directory.EnumerateDirectories(group))
|
||||
{
|
||||
string path = Path.Combine(sub, "textures.txt");
|
||||
if (File.Exists(path))
|
||||
files.Add(path);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
public static VehicleEntry? Parse(string path, string dynamicRoot = "dynamic")
|
||||
{
|
||||
string[] lines;
|
||||
try
|
||||
{
|
||||
lines = File.ReadAllLines(path, Encoding.GetEncoding(1250));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Infrastructure.Diagnostics.Log($"textures.txt {path}", ex);
|
||||
return null;
|
||||
}
|
||||
|
||||
string dir = RelativeDirectory(path, dynamicRoot);
|
||||
var entry = new VehicleEntry { Uuid = "legacy:" + dir };
|
||||
|
||||
bool archived = false;
|
||||
string categorySign = "*";
|
||||
int setSize = 0;
|
||||
var pending = new List<string>();
|
||||
|
||||
foreach (string raw in lines)
|
||||
{
|
||||
string line = raw.Trim();
|
||||
if (line.Length == 0) continue;
|
||||
|
||||
char c = line[0];
|
||||
if (c is '#' or '@' or '*') continue;
|
||||
|
||||
if (line.StartsWith("$a", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
archived = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '^')
|
||||
{
|
||||
FlushSet(entry, pending, setSize);
|
||||
setSize = line.Length > 1 && char.IsDigit(line[1]) ? line[1] - '0' : 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("!=", StringComparison.Ordinal))
|
||||
{
|
||||
categorySign = line.Length > 2 ? line[2].ToString() : "*";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.IndexOf('=') < 0) continue;
|
||||
if (line.StartsWith("//", StringComparison.Ordinal)) continue;
|
||||
|
||||
var texture = ParseEntryLine(line, dir, categorySign, archived, entry);
|
||||
if (texture is null) continue;
|
||||
|
||||
entry.Textures.Add(texture);
|
||||
|
||||
if (setSize > 1)
|
||||
{
|
||||
pending.Add(texture.Uuid!);
|
||||
if (pending.Count == setSize)
|
||||
FlushSet(entry, pending, setSize);
|
||||
}
|
||||
}
|
||||
|
||||
FlushSet(entry, pending, setSize);
|
||||
return entry.Textures.Count > 0 ? entry : null;
|
||||
}
|
||||
|
||||
private static void FlushSet(VehicleEntry entry, List<string> pending, int size)
|
||||
{
|
||||
if (pending.Count > 1)
|
||||
entry.Sets.Add(new VehicleSet
|
||||
{
|
||||
Uuid = "legacy-set:" + pending[0],
|
||||
Mode = "sequence",
|
||||
Count = size > 0 ? size : pending.Count,
|
||||
TextureRefs = new List<string>(pending)
|
||||
});
|
||||
pending.Clear();
|
||||
}
|
||||
|
||||
private static VehicleTexture? ParseEntryLine(string line, string dir,
|
||||
string categorySign, bool archived, VehicleEntry entry)
|
||||
{
|
||||
string desc = "";
|
||||
int comment = line.IndexOf("//", StringComparison.Ordinal);
|
||||
if (comment >= 0)
|
||||
{
|
||||
desc = line[(comment + 2)..].Trim();
|
||||
line = line[..comment].Trim();
|
||||
}
|
||||
|
||||
var parts = line.Split('=');
|
||||
if (parts.Length < 2) return null;
|
||||
|
||||
string skin = StripExtension(parts[0].Trim());
|
||||
if (skin.Length == 0) return null;
|
||||
|
||||
var specs = new List<(string Model, string Mini, string MiniD)>();
|
||||
for (int i = 1; i < parts.Length; i++)
|
||||
{
|
||||
var f = parts[i].Split(',');
|
||||
if (f.Length < 2) continue;
|
||||
specs.Add((f[0].Trim(), f[1].Trim(), f.Length > 2 ? f[2].Trim() : ""));
|
||||
}
|
||||
if (specs.Count == 0) return null;
|
||||
|
||||
var first = specs[0];
|
||||
string groupId = $"legacy:{dir}:{first.Mini}:{categorySign}:{(archived ? 1 : 0)}";
|
||||
|
||||
if (!entry.Groups.Exists(g => g.Id == groupId))
|
||||
entry.Groups.Add(new VehicleGroup
|
||||
{
|
||||
Id = groupId,
|
||||
Category = categorySign,
|
||||
Mini = first.Mini,
|
||||
Archived = archived
|
||||
});
|
||||
|
||||
var texture = new VehicleTexture
|
||||
{
|
||||
Uuid = $"legacy:{dir}{skin}",
|
||||
Directory = dir,
|
||||
Skinfile = skin,
|
||||
Wreck = skin.Contains("wreck", StringComparison.OrdinalIgnoreCase) ||
|
||||
skin.Contains("wrak", StringComparison.OrdinalIgnoreCase),
|
||||
Model = first.Model,
|
||||
Group = groupId,
|
||||
MiniRef = first.Mini,
|
||||
TextureMini = string.IsNullOrEmpty(first.MiniD) ? null : first.MiniD,
|
||||
Meta = ParseDescription(desc)
|
||||
};
|
||||
|
||||
for (int i = 1; i < specs.Count; i++)
|
||||
texture.Aliases.Add(new VehicleAlias
|
||||
{
|
||||
Model = specs[i].Model,
|
||||
MiniRef = specs[i].Mini,
|
||||
TextureMini = string.IsNullOrEmpty(specs[i].MiniD) ? null : specs[i].MiniD
|
||||
});
|
||||
|
||||
return texture;
|
||||
}
|
||||
|
||||
private static VehicleMeta? ParseDescription(string desc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(desc)) return null;
|
||||
|
||||
var f = desc.Replace('_', ' ').Split(',');
|
||||
string At(int i) => i < f.Length ? f[i].Trim() : "";
|
||||
|
||||
return new VehicleMeta
|
||||
{
|
||||
Raw = desc,
|
||||
Version = At(0),
|
||||
Vehicle = At(1),
|
||||
Operator = At(2),
|
||||
Depot = At(3),
|
||||
RevisionDate = At(4),
|
||||
RevisionPlace = At(5),
|
||||
TextureAuthor = At(6),
|
||||
PhotoAuthor = At(7)
|
||||
};
|
||||
}
|
||||
|
||||
private static string StripExtension(string name)
|
||||
{
|
||||
int dot = name.LastIndexOf('.');
|
||||
return dot > 0 ? name[..dot] : name;
|
||||
}
|
||||
|
||||
private static string RelativeDirectory(string texturesPath, string dynamicRoot)
|
||||
{
|
||||
string dir = Path.GetDirectoryName(texturesPath) ?? "";
|
||||
string full = Path.GetFullPath(dir);
|
||||
string root = Path.GetFullPath(dynamicRoot);
|
||||
|
||||
string rel = full.StartsWith(root, StringComparison.OrdinalIgnoreCase)
|
||||
? full[root.Length..].TrimStart(Path.DirectorySeparatorChar, '/')
|
||||
: dir;
|
||||
|
||||
return rel.Replace('\\', '/').TrimEnd('/') + "/";
|
||||
}
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StarterNG.Classes;
|
||||
|
||||
public class VehicleEntry
|
||||
{
|
||||
public string? Uuid { get; set; }
|
||||
public List<VehicleGroup> Groups { get; set; } = new();
|
||||
public List<VehicleTexture> Textures { get; set; } = new();
|
||||
public List<VehicleSet> Sets { get; set; } = new();
|
||||
}
|
||||
|
||||
public class VehicleGroup
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string? Category { get; set; }
|
||||
public string? Mini { get; set; }
|
||||
public bool Archived { get; set; }
|
||||
}
|
||||
|
||||
public class VehicleTexture
|
||||
{
|
||||
public string? Uuid { get; set; }
|
||||
public string Directory { get; set; } = "";
|
||||
public string Skinfile { get; set; } = "";
|
||||
public string? Model { get; set; }
|
||||
public string? Group { get; set; }
|
||||
public string? MiniRef { get; set; }
|
||||
public string? TextureMini { get; set; }
|
||||
public bool Wreck { get; set; }
|
||||
public List<VehicleAlias> Aliases { get; set; } = new();
|
||||
public VehicleMeta? Meta { get; set; }
|
||||
|
||||
public string FullPath => Directory + Skinfile;
|
||||
|
||||
public string ResolvedClass { get; internal set; } = "";
|
||||
|
||||
public string? ResolvedCategory { get; internal set; }
|
||||
|
||||
public bool ResolvedArchived { get; internal set; }
|
||||
}
|
||||
|
||||
public class VehicleAlias
|
||||
{
|
||||
public string? Model { get; set; }
|
||||
public string? Group { get; set; }
|
||||
public string? MiniRef { get; set; }
|
||||
public string? TextureMini { get; set; }
|
||||
}
|
||||
|
||||
public class VehicleMeta
|
||||
{
|
||||
public string? Raw { get; set; }
|
||||
public string? Version { get; set; }
|
||||
public string? Vehicle { get; set; }
|
||||
public string? Operator { get; set; }
|
||||
public string? Depot { get; set; }
|
||||
public string? RevisionDate { get; set; }
|
||||
public string? RevisionPlace { get; set; }
|
||||
public string? TextureAuthor { get; set; }
|
||||
public string? PhotoAuthor { get; set; }
|
||||
public List<string> Extra { get; set; } = new();
|
||||
}
|
||||
|
||||
public class VehicleDatabase
|
||||
{
|
||||
public List<VehicleTexture> Textures { get; } = new();
|
||||
public Dictionary<string, VehicleGroup> GroupsById { get; } = new();
|
||||
public List<VehicleSet> Sets { get; } = new();
|
||||
|
||||
public Dictionary<string, VehicleSet> SetByTextureUuid { get; } = new();
|
||||
|
||||
public Dictionary<string, VehicleTexture> TextureByUuid { get; } = new();
|
||||
|
||||
public Dictionary<string, VehicleTexture> TextureBySkin { get; } = new();
|
||||
|
||||
public int LoadFromTexturesTxt(string dynamicRoot = "dynamic")
|
||||
{
|
||||
BeginLoad();
|
||||
|
||||
int count = 0;
|
||||
foreach (string file in TexturesTxt.EnumerateFiles(dynamicRoot))
|
||||
{
|
||||
var entry = TexturesTxt.Parse(file, dynamicRoot);
|
||||
if (entry is null) continue;
|
||||
|
||||
count += entry.Textures.Count;
|
||||
Ingest(entry);
|
||||
}
|
||||
|
||||
EndLoad();
|
||||
return count;
|
||||
}
|
||||
|
||||
public void BeginLoad()
|
||||
{
|
||||
Textures.Clear();
|
||||
GroupsById.Clear();
|
||||
Sets.Clear();
|
||||
SetByTextureUuid.Clear();
|
||||
TextureByUuid.Clear();
|
||||
TextureBySkin.Clear();
|
||||
}
|
||||
|
||||
public void EndLoad()
|
||||
{
|
||||
BuildSetIndex();
|
||||
BuildTextureIndex();
|
||||
}
|
||||
|
||||
private void Ingest(VehicleEntry entry)
|
||||
{
|
||||
foreach (var group in entry.Groups)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(group.Id))
|
||||
GroupsById.TryAdd(group.Id, group);
|
||||
}
|
||||
|
||||
foreach (var texture in entry.Textures)
|
||||
{
|
||||
|
||||
if (!string.IsNullOrEmpty(texture.Uuid))
|
||||
TextureByUuid[texture.Uuid!] = texture;
|
||||
if (!string.IsNullOrEmpty(texture.Skinfile))
|
||||
TextureBySkin.TryAdd(Path.GetFileNameWithoutExtension(texture.Skinfile).ToLowerInvariant(), texture);
|
||||
|
||||
if (texture.Wreck) continue;
|
||||
Textures.Add(texture);
|
||||
}
|
||||
|
||||
Sets.AddRange(entry.Sets);
|
||||
}
|
||||
|
||||
public List<VehicleTexture>? ResolveSet(VehicleTexture texture)
|
||||
{
|
||||
if (string.IsNullOrEmpty(texture.Uuid)) return null;
|
||||
if (!SetByTextureUuid.TryGetValue(texture.Uuid!, out var set) || set.TextureRefs is null)
|
||||
return null;
|
||||
|
||||
var cars = new List<VehicleTexture>();
|
||||
foreach (string uuid in set.TextureRefs)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(uuid) && TextureByUuid.TryGetValue(uuid, out var tex))
|
||||
cars.Add(tex);
|
||||
}
|
||||
return cars.Count > 0 ? cars : null;
|
||||
}
|
||||
|
||||
private void BuildSetIndex()
|
||||
{
|
||||
foreach (var set in Sets)
|
||||
{
|
||||
if (set.TextureRefs is null) continue;
|
||||
foreach (string uuid in set.TextureRefs)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(uuid))
|
||||
SetByTextureUuid[uuid] = set;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildTextureIndex()
|
||||
{
|
||||
foreach (var t in Textures)
|
||||
{
|
||||
VehicleGroup? g = null;
|
||||
if (t.Group != null)
|
||||
GroupsById.TryGetValue(t.Group, out g);
|
||||
|
||||
string mini = g != null && !string.IsNullOrEmpty(g.Mini)
|
||||
? g.Mini!
|
||||
: t.MiniRef ?? "";
|
||||
|
||||
string? category = g?.Category;
|
||||
|
||||
if (category == "*")
|
||||
{
|
||||
string source = !string.IsNullOrEmpty(mini) ? mini : (t.TextureMini ?? "");
|
||||
if (source.Length > 0)
|
||||
category = char.ToUpperInvariant(source[0]).ToString();
|
||||
}
|
||||
|
||||
t.ResolvedCategory = category;
|
||||
t.ResolvedClass = mini;
|
||||
t.ResolvedArchived = g?.Archived ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsSetFollower(VehicleTexture texture)
|
||||
{
|
||||
if (string.IsNullOrEmpty(texture.Uuid)) return false;
|
||||
if (!SetByTextureUuid.TryGetValue(texture.Uuid!, out var set) ||
|
||||
set.TextureRefs is null || set.TextureRefs.Count < 2)
|
||||
return false;
|
||||
|
||||
string? lead = set.TextureRefs.FirstOrDefault(r => !string.IsNullOrEmpty(r));
|
||||
return lead != null &&
|
||||
!string.Equals(lead, texture.Uuid, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public string? ResolveMiniName(VehicleTexture texture)
|
||||
{
|
||||
if (HasMini(texture.TextureMini))
|
||||
return texture.TextureMini;
|
||||
|
||||
if (texture.Group != null && GroupsById.TryGetValue(texture.Group, out var grp)
|
||||
&& !string.IsNullOrEmpty(grp.Mini))
|
||||
return grp.Mini;
|
||||
|
||||
return texture.TextureMini;
|
||||
}
|
||||
|
||||
public string? MiniForSkin(string? skinFile)
|
||||
{
|
||||
if (string.IsNullOrEmpty(skinFile)) return null;
|
||||
string key = Path.GetFileNameWithoutExtension(skinFile).ToLowerInvariant();
|
||||
return TextureBySkin.TryGetValue(key, out var tex) ? ResolveMiniName(tex) : null;
|
||||
}
|
||||
|
||||
public VehicleTexture? TextureForSkin(string? skinFile)
|
||||
{
|
||||
if (string.IsNullOrEmpty(skinFile)) return null;
|
||||
string key = Path.GetFileNameWithoutExtension(skinFile).ToLowerInvariant();
|
||||
return TextureBySkin.TryGetValue(key, out var tex) ? tex : null;
|
||||
}
|
||||
|
||||
public List<string> CollectMissingAssetLines()
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var t in Textures)
|
||||
{
|
||||
string dir = Path.Combine(Directory.GetCurrentDirectory(), "dynamic",
|
||||
t.Directory.Replace('/', Path.DirectorySeparatorChar).TrimEnd('\\', '/'));
|
||||
string skin = Path.Combine(dir, t.Skinfile);
|
||||
if (!string.IsNullOrEmpty(t.Skinfile) && !File.Exists(skin) &&
|
||||
!File.Exists(Path.ChangeExtension(skin, ".mat")) &&
|
||||
!File.Exists(Path.ChangeExtension(skin, ".bmp")))
|
||||
lines.Add($"# no file: {t.Directory}{t.Skinfile}");
|
||||
|
||||
if (!string.IsNullOrEmpty(t.Model))
|
||||
{
|
||||
string model = Path.Combine(dir, t.Model!);
|
||||
if (!File.Exists(model) && !File.Exists(Path.ChangeExtension(model, ".t3d")) &&
|
||||
!File.Exists(Path.ChangeExtension(model, ".e3d")))
|
||||
lines.Add($"# no model: {t.Directory}{t.Model}");
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
public string GroupHeader(string? groupId)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(groupId) && GroupsById.TryGetValue(groupId, out var grp))
|
||||
{
|
||||
string mini = string.IsNullOrEmpty(grp.Mini) ? grp.Id : grp.Mini!;
|
||||
return string.IsNullOrEmpty(grp.Category) ? mini : $"{mini} ({grp.Category})";
|
||||
}
|
||||
return groupId ?? "";
|
||||
}
|
||||
|
||||
private static Dictionary<string, string>? _miniIndex;
|
||||
|
||||
public static void PreloadMiniIndex(string miniDir = "textures/mini/") =>
|
||||
_miniIndex ??= BuildMiniIndex(miniDir);
|
||||
|
||||
public static string? MiniPath(string? miniName, string miniDir = "textures/mini/")
|
||||
{
|
||||
var index = _miniIndex ??= BuildMiniIndex(miniDir);
|
||||
|
||||
if (!string.IsNullOrEmpty(miniName) &&
|
||||
index.TryGetValue(miniName!.ToLowerInvariant(), out var path))
|
||||
return path;
|
||||
|
||||
return FallbackMiniPath(miniDir);
|
||||
}
|
||||
|
||||
public static bool HasMini(string? miniName, string miniDir = "textures/mini/")
|
||||
{
|
||||
if (string.IsNullOrEmpty(miniName)) return false;
|
||||
var index = _miniIndex ??= BuildMiniIndex(miniDir);
|
||||
return index.ContainsKey(miniName!.ToLowerInvariant());
|
||||
}
|
||||
|
||||
public static string? FallbackMiniPath(string miniDir = "textures/mini/")
|
||||
{
|
||||
var index = _miniIndex ??= BuildMiniIndex(miniDir);
|
||||
return index.TryGetValue("other", out var other) ? other : null;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildMiniIndex(string miniDir)
|
||||
{
|
||||
var index = new Dictionary<string, string>();
|
||||
if (!Directory.Exists(miniDir))
|
||||
return index;
|
||||
|
||||
foreach (string file in Directory.GetFiles(miniDir, "*.bmp"))
|
||||
index[Path.GetFileNameWithoutExtension(file).ToLowerInvariant()] = file;
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
public class VehicleSet
|
||||
{
|
||||
public string? Uuid { get; set; }
|
||||
public string? Mode { get; set; }
|
||||
public int Count { get; set; }
|
||||
public List<string> TextureRefs { get; set; } = new();
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System.Linq;
|
||||
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
|
||||
namespace StarterNG.Domain;
|
||||
|
||||
@@ -12,12 +13,12 @@ public sealed class Consist : IReadOnlyList<ConsistItem>
|
||||
{
|
||||
private static readonly string[] CarSuffixes = { "sa", "sb", "ra", "rb", "s" };
|
||||
|
||||
private readonly VehicleDatabase _db;
|
||||
private readonly VehicleCatalog _db;
|
||||
private readonly VehicleInfo _info;
|
||||
private readonly List<ConsistItem> _items = new();
|
||||
private int _nameCounter;
|
||||
|
||||
public Consist(VehicleDatabase db, VehicleInfo info)
|
||||
public Consist(VehicleCatalog db, VehicleInfo info)
|
||||
{
|
||||
_db = db;
|
||||
_info = info;
|
||||
|
||||
@@ -3,12 +3,13 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
|
||||
namespace StarterNG.Domain;
|
||||
|
||||
public sealed class TexRandomizer
|
||||
{
|
||||
private readonly VehicleDatabase _db;
|
||||
private readonly VehicleCatalog _db;
|
||||
private readonly Random _rng;
|
||||
private readonly List<string[]> _stock = new();
|
||||
|
||||
@@ -29,7 +30,7 @@ public sealed class TexRandomizer
|
||||
}
|
||||
}
|
||||
|
||||
public TexRandomizer(VehicleDatabase db, Random? rng = null)
|
||||
public TexRandomizer(VehicleCatalog db, Random? rng = null)
|
||||
{
|
||||
_db = db;
|
||||
_rng = rng ?? Random.Shared;
|
||||
|
||||
@@ -6,6 +6,8 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
using StarterNG.Application;
|
||||
|
||||
namespace StarterNG.Domain;
|
||||
|
||||
@@ -65,14 +67,14 @@ public static class TrainsetDisplay
|
||||
|
||||
public static string VehicleLabel(Dynamic v, bool head = false)
|
||||
{
|
||||
var tex = GameData.Instance.Vehicles.TextureForSkin(v.SkinFile);
|
||||
var tex = AppServices.Current.Library.Vehicles.TextureForSkin(v.SkinFile);
|
||||
string? cls = string.IsNullOrEmpty(tex?.ResolvedClass) ? null : tex!.ResolvedClass;
|
||||
string? name = string.IsNullOrWhiteSpace(v.Name) ? null : v.Name;
|
||||
return (head ? name ?? cls : cls ?? name) ?? BaseSkin(v);
|
||||
}
|
||||
|
||||
public static bool IsPowered(Dynamic v) =>
|
||||
GameData.Instance.Vehicles.TextureForSkin(v.SkinFile)?.ResolvedCategory
|
||||
AppServices.Current.Library.Vehicles.TextureForSkin(v.SkinFile)?.ResolvedCategory
|
||||
is "e" or "s" or "p" or "z" or "a";
|
||||
|
||||
private static string BaseSkin(Dynamic v) =>
|
||||
@@ -94,7 +96,7 @@ public static class TrainsetDisplay
|
||||
}
|
||||
|
||||
public static List<(string Label, string Value)> StatsFields(
|
||||
Trainset? trainset, Func<Dynamic, Physics?> physicsFor,
|
||||
Trainset? trainset, Func<Dynamic, VehiclePhysics?> physicsFor,
|
||||
Func<Dynamic, string?>? categoryOf = null,
|
||||
Func<string?, int>? loadWeightKg = null)
|
||||
{
|
||||
@@ -120,7 +122,7 @@ public static class TrainsetDisplay
|
||||
return fields;
|
||||
}
|
||||
|
||||
public static string FormatStats(Trainset? trainset, Func<Dynamic, Physics?> physicsFor,
|
||||
public static string FormatStats(Trainset? trainset, Func<Dynamic, VehiclePhysics?> physicsFor,
|
||||
string lengthLabel, string massLabel, string vehiclesLabel, string trackLabel,
|
||||
Func<Dynamic, string?>? categoryOf = null,
|
||||
Func<string?, int>? loadWeightKg = null)
|
||||
@@ -149,7 +151,7 @@ public static class TrainsetDisplay
|
||||
|
||||
private static (double lengthM, double massKg, double loadKg) RecalcParams(
|
||||
Trainset trainset,
|
||||
Func<Dynamic, Physics?> physicsFor,
|
||||
Func<Dynamic, VehiclePhysics?> physicsFor,
|
||||
Func<Dynamic, string?>? categoryOf,
|
||||
Func<string?, int>? loadWeightKg,
|
||||
bool allVehicles)
|
||||
@@ -176,7 +178,7 @@ public static class TrainsetDisplay
|
||||
return (lengthM, massKg, loadKg);
|
||||
}
|
||||
|
||||
private static bool IncludeVehicleToMass(Dynamic car, Physics? p, string? category, bool allVehicles)
|
||||
private static bool IncludeVehicleToMass(Dynamic car, VehiclePhysics? p, string? category, bool allVehicles)
|
||||
{
|
||||
if (p == null) return false;
|
||||
if (allVehicles) return true;
|
||||
@@ -184,7 +186,7 @@ public static class TrainsetDisplay
|
||||
return car.DriverType is eDriverType.Passenger or eDriverType.Nobody;
|
||||
}
|
||||
|
||||
private static bool LoadAccepted(Physics? p, string loadType)
|
||||
private static bool LoadAccepted(VehiclePhysics? p, string loadType)
|
||||
{
|
||||
if (p == null || string.IsNullOrWhiteSpace(p.LoadAccepted))
|
||||
return false;
|
||||
@@ -194,7 +196,7 @@ public static class TrainsetDisplay
|
||||
public static string? UniquifyForLaunch(Trainset trainset, Scenery scenery, string? startName)
|
||||
{
|
||||
var used = CollectNames(scenery);
|
||||
var db = GameData.Instance.Vehicles;
|
||||
var db = AppServices.Current.Library.Vehicles;
|
||||
|
||||
bool mu = trainset.Vehicles.Any(v =>
|
||||
{
|
||||
@@ -224,7 +226,7 @@ public static class TrainsetDisplay
|
||||
|
||||
public static string PreferredBaseName(Dynamic v)
|
||||
{
|
||||
var tex = GameData.Instance.Vehicles.TextureForSkin(v.SkinFile);
|
||||
var tex = AppServices.Current.Library.Vehicles.TextureForSkin(v.SkinFile);
|
||||
if (tex != null)
|
||||
{
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
|
||||
namespace StarterNG.Domain;
|
||||
|
||||
@@ -9,7 +10,7 @@ public static class UData
|
||||
{
|
||||
public readonly record struct TrainParams(double Length, double Mass, double LoadMass, int CountVehicles);
|
||||
|
||||
public static int GetMaxCoupler(Dynamic v, Func<Dynamic, Physics?> physicsFor, bool leftCoupler = true)
|
||||
public static int GetMaxCoupler(Dynamic v, Func<Dynamic, VehiclePhysics?> physicsFor, bool leftCoupler = true)
|
||||
{
|
||||
var fiz = physicsFor(v);
|
||||
if (fiz == null) return 3;
|
||||
@@ -18,7 +19,7 @@ public static class UData
|
||||
: (v.Offset >= 0 ? fiz.AllowedFlagB : fiz.AllowedFlagA);
|
||||
}
|
||||
|
||||
public static string GetControlType(Dynamic v, Func<Dynamic, Physics?> physicsFor, bool leftCoupler = true)
|
||||
public static string GetControlType(Dynamic v, Func<Dynamic, VehiclePhysics?> physicsFor, bool leftCoupler = true)
|
||||
{
|
||||
var fiz = physicsFor(v);
|
||||
if (fiz == null) return "";
|
||||
@@ -43,7 +44,7 @@ public static class UData
|
||||
}
|
||||
|
||||
public static void AutoConnect(IList<Dynamic> vehicles, int leftVehicle,
|
||||
Func<Dynamic, Physics?> physicsFor)
|
||||
Func<Dynamic, VehiclePhysics?> physicsFor)
|
||||
{
|
||||
if (leftVehicle < 0 || vehicles.Count - 1 <= leftVehicle) return;
|
||||
|
||||
@@ -98,7 +99,7 @@ public static class UData
|
||||
d is eDriverType.Headdriver or eDriverType.Reardriver;
|
||||
|
||||
public static bool IncludeVehicleToMass(Dynamic v, bool allVehicles,
|
||||
Func<Dynamic, Physics?> physicsFor, Func<Dynamic, string?> categoryOf)
|
||||
Func<Dynamic, VehiclePhysics?> physicsFor, Func<Dynamic, string?> categoryOf)
|
||||
{
|
||||
if (physicsFor(v) == null) return false;
|
||||
if (allVehicles) return true;
|
||||
@@ -109,7 +110,7 @@ public static class UData
|
||||
}
|
||||
|
||||
public static TrainParams RecalcTrainParams(Trainset? train, bool allVehicles,
|
||||
Func<Dynamic, Physics?> physicsFor, Func<Dynamic, string?> categoryOf,
|
||||
Func<Dynamic, VehiclePhysics?> physicsFor, Func<Dynamic, string?> categoryOf,
|
||||
Func<string?, int> loadWeight)
|
||||
{
|
||||
double length = 0, mass = 0, loadMass = 0;
|
||||
@@ -138,7 +139,7 @@ public static class UData
|
||||
}
|
||||
|
||||
public static void RandomLoad(IEnumerable<Dynamic> vehicles,
|
||||
Func<Dynamic, Physics?> physicsFor, Random rng)
|
||||
Func<Dynamic, VehiclePhysics?> physicsFor, Random rng)
|
||||
{
|
||||
foreach (var v in vehicles)
|
||||
{
|
||||
@@ -157,7 +158,7 @@ public static class UData
|
||||
}
|
||||
|
||||
public static void ReverseMultiple(IList<Dynamic> vehicles, int position,
|
||||
Func<Dynamic, string?> setIdOf, Func<Dynamic, Physics?> physicsFor)
|
||||
Func<Dynamic, string?> setIdOf, Func<Dynamic, VehiclePhysics?> physicsFor)
|
||||
{
|
||||
var indexes = GetMultiple(vehicles, position, setIdOf);
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
using StarterNG.Application;
|
||||
|
||||
namespace StarterNG.Domain;
|
||||
|
||||
public sealed class VehicleInfo
|
||||
{
|
||||
private readonly VehicleDatabase _db;
|
||||
private readonly VehicleCatalog _db;
|
||||
|
||||
public VehicleInfo(VehicleDatabase db) => _db = db;
|
||||
public VehicleInfo(VehicleCatalog db) => _db = db;
|
||||
|
||||
public static string ClassOf(VehicleTexture t) => t.ResolvedClass;
|
||||
|
||||
@@ -20,11 +22,11 @@ public sealed class VehicleInfo
|
||||
public string? CategoryOf(Dynamic car) =>
|
||||
TextureFor(car) is { } t ? CategoryOf(t) : null;
|
||||
|
||||
public Physics? PhysicsFor(Dynamic car)
|
||||
public VehiclePhysics? PhysicsFor(Dynamic car)
|
||||
{
|
||||
string? dbModel = TextureFor(car)?.Model;
|
||||
return Physics.For(car.DataFolder, dbModel)
|
||||
?? Physics.For(car.DataFolder, car.MmdFile)
|
||||
?? Physics.For(car.DataFolder, car.SkinFile);
|
||||
return AppServices.Current.Physics.For(car.DataFolder, dbModel)
|
||||
?? AppServices.Current.Physics.For(car.DataFolder, car.MmdFile)
|
||||
?? AppServices.Current.Physics.For(car.DataFolder, car.SkinFile);
|
||||
}
|
||||
}
|
||||
|
||||
198
StarterNG/Domain/Vehicles/VehicleCatalog.cs
Normal file
198
StarterNG/Domain/Vehicles/VehicleCatalog.cs
Normal file
@@ -0,0 +1,198 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using StarterNG.Application.Abstractions;
|
||||
|
||||
namespace StarterNG.Domain.Vehicles;
|
||||
|
||||
/// <summary>
|
||||
/// Every livery the installation offers, indexed for the lookups the depot and
|
||||
/// the consist editor make: by uuid, by skin file, and by the set a vehicle
|
||||
/// belongs to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Was <c>VehicleCatalog</c>, which also owned file enumeration, textures.txt
|
||||
/// parsing, a static thumbnail dictionary and a missing-asset scan. Those moved
|
||||
/// to Infrastructure; what is left is the model and its resolution rules, filled
|
||||
/// by a repository through <see cref="Ingest"/>.
|
||||
/// </remarks>
|
||||
public sealed class VehicleCatalog
|
||||
{
|
||||
private readonly IMiniTextureIndex _minis;
|
||||
|
||||
public VehicleCatalog(IMiniTextureIndex minis)
|
||||
{
|
||||
_minis = minis;
|
||||
}
|
||||
|
||||
/// <summary>Every livery worth offering (wrecks excluded).</summary>
|
||||
public List<VehicleTexture> Textures { get; } = new();
|
||||
|
||||
public Dictionary<string, VehicleGroup> GroupsById { get; } = new();
|
||||
|
||||
public List<VehicleSet> Sets { get; } = new();
|
||||
|
||||
public Dictionary<string, VehicleSet> SetByTextureUuid { get; } = new();
|
||||
|
||||
public Dictionary<string, VehicleTexture> TextureByUuid { get; } = new();
|
||||
|
||||
/// <summary>Liveries by skin file name without extension, lower cased.</summary>
|
||||
public Dictionary<string, VehicleTexture> TextureBySkin { get; } = new();
|
||||
|
||||
public void BeginLoad()
|
||||
{
|
||||
Textures.Clear();
|
||||
GroupsById.Clear();
|
||||
Sets.Clear();
|
||||
SetByTextureUuid.Clear();
|
||||
TextureByUuid.Clear();
|
||||
TextureBySkin.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Adds one vehicle folder's liveries to the catalogue.</summary>
|
||||
public void Ingest(VehicleEntry entry)
|
||||
{
|
||||
foreach (var group in entry.Groups)
|
||||
if (!string.IsNullOrEmpty(group.Id))
|
||||
GroupsById.TryAdd(group.Id, group);
|
||||
|
||||
foreach (var texture in entry.Textures)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(texture.Uuid))
|
||||
TextureByUuid[texture.Uuid] = texture;
|
||||
|
||||
if (!string.IsNullOrEmpty(texture.Skinfile))
|
||||
TextureBySkin.TryAdd(Path.GetFileNameWithoutExtension(texture.Skinfile).ToLowerInvariant(), texture);
|
||||
|
||||
if (texture.Wreck)
|
||||
continue;
|
||||
|
||||
Textures.Add(texture);
|
||||
}
|
||||
|
||||
Sets.AddRange(entry.Sets);
|
||||
}
|
||||
|
||||
/// <summary>Builds the indexes that depend on the catalogue being complete.</summary>
|
||||
public void EndLoad()
|
||||
{
|
||||
BuildSetIndex();
|
||||
ResolveGroupInheritance();
|
||||
}
|
||||
|
||||
/// <summary>The vehicles of the fixed set this livery leads or belongs to.</summary>
|
||||
public List<VehicleTexture>? ResolveSet(VehicleTexture texture)
|
||||
{
|
||||
if (string.IsNullOrEmpty(texture.Uuid))
|
||||
return null;
|
||||
if (!SetByTextureUuid.TryGetValue(texture.Uuid, out var set) || set.TextureRefs is null)
|
||||
return null;
|
||||
|
||||
var cars = new List<VehicleTexture>();
|
||||
foreach (string uuid in set.TextureRefs)
|
||||
if (!string.IsNullOrEmpty(uuid) && TextureByUuid.TryGetValue(uuid, out var car))
|
||||
cars.Add(car);
|
||||
|
||||
return cars.Count > 0 ? cars : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the livery is a non-leading member of a set, and so is added
|
||||
/// along with its leader rather than on its own.
|
||||
/// </summary>
|
||||
public bool IsSetFollower(VehicleTexture texture)
|
||||
{
|
||||
if (string.IsNullOrEmpty(texture.Uuid))
|
||||
return false;
|
||||
if (!SetByTextureUuid.TryGetValue(texture.Uuid, out var set) ||
|
||||
set.TextureRefs is null || set.TextureRefs.Count < 2)
|
||||
return false;
|
||||
|
||||
string? lead = set.TextureRefs.FirstOrDefault(reference => !string.IsNullOrEmpty(reference));
|
||||
return lead is not null && !string.Equals(lead, texture.Uuid, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The thumbnail to draw for a livery: its own if we have that bitmap,
|
||||
/// otherwise the one its group declares.
|
||||
/// </summary>
|
||||
public string? ResolveMiniName(VehicleTexture texture)
|
||||
{
|
||||
if (_minis.Has(texture.TextureMini))
|
||||
return texture.TextureMini;
|
||||
|
||||
if (texture.Group is not null && GroupsById.TryGetValue(texture.Group, out var group) &&
|
||||
!string.IsNullOrEmpty(group.Mini))
|
||||
return group.Mini;
|
||||
|
||||
return texture.TextureMini;
|
||||
}
|
||||
|
||||
public string? MiniForSkin(string? skinFile) =>
|
||||
TextureForSkin(skinFile) is { } texture ? ResolveMiniName(texture) : null;
|
||||
|
||||
public VehicleTexture? TextureForSkin(string? skinFile)
|
||||
{
|
||||
if (string.IsNullOrEmpty(skinFile))
|
||||
return null;
|
||||
|
||||
string key = Path.GetFileNameWithoutExtension(skinFile).ToLowerInvariant();
|
||||
return TextureBySkin.TryGetValue(key, out var texture) ? texture : null;
|
||||
}
|
||||
|
||||
/// <summary>Heading shown above a group in the depot browser.</summary>
|
||||
public string GroupHeader(string? groupId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(groupId) || !GroupsById.TryGetValue(groupId, out var group))
|
||||
return groupId ?? "";
|
||||
|
||||
string mini = string.IsNullOrEmpty(group.Mini) ? group.Id : group.Mini;
|
||||
return string.IsNullOrEmpty(group.Category) ? mini : $"{mini} ({group.Category})";
|
||||
}
|
||||
|
||||
private void BuildSetIndex()
|
||||
{
|
||||
foreach (var set in Sets)
|
||||
{
|
||||
if (set.TextureRefs is null)
|
||||
continue;
|
||||
|
||||
foreach (string uuid in set.TextureRefs)
|
||||
if (!string.IsNullOrEmpty(uuid))
|
||||
SetByTextureUuid[uuid] = set;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies thumbnail, category and archived status down from each group onto
|
||||
/// its liveries, so the browser can sort and filter without walking back up.
|
||||
/// </summary>
|
||||
private void ResolveGroupInheritance()
|
||||
{
|
||||
foreach (var texture in Textures)
|
||||
{
|
||||
VehicleGroup? group = null;
|
||||
if (texture.Group is not null)
|
||||
GroupsById.TryGetValue(texture.Group, out group);
|
||||
|
||||
string mini = group is not null && !string.IsNullOrEmpty(group.Mini)
|
||||
? group.Mini
|
||||
: texture.MiniRef ?? "";
|
||||
|
||||
string? category = group?.Category;
|
||||
|
||||
// "*" means "categorise by the first letter of the thumbnail name".
|
||||
if (category == "*")
|
||||
{
|
||||
string source = !string.IsNullOrEmpty(mini) ? mini : texture.TextureMini ?? "";
|
||||
if (source.Length > 0)
|
||||
category = char.ToUpperInvariant(source[0]).ToString();
|
||||
}
|
||||
|
||||
texture.ResolvedCategory = category;
|
||||
texture.ResolvedClass = mini;
|
||||
texture.ResolvedArchived = group?.Archived ?? false;
|
||||
}
|
||||
}
|
||||
}
|
||||
54
StarterNG/Domain/Vehicles/VehiclePhysics.cs
Normal file
54
StarterNG/Domain/Vehicles/VehiclePhysics.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
|
||||
namespace StarterNG.Domain.Vehicles;
|
||||
|
||||
/// <summary>
|
||||
/// The physical properties of a vehicle type, as declared by its .fiz file:
|
||||
/// what it weighs, how fast it goes, what it can carry and what it couples to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Was <c>Classes.Physics</c>, which also owned the .fiz index, the parser and a
|
||||
/// static cache. Those are now <c>Infrastructure.Vehicles.FizPhysicsRepository</c>;
|
||||
/// this is the value the rest of the application reasons about.
|
||||
/// </remarks>
|
||||
public sealed class VehiclePhysics
|
||||
{
|
||||
/// <summary>Tare mass in kilograms.</summary>
|
||||
public double Mass { get; set; }
|
||||
|
||||
/// <summary>Design speed in km/h.</summary>
|
||||
public double VMax { get; set; }
|
||||
|
||||
/// <summary>Length over buffers in metres.</summary>
|
||||
public double Length { get; set; }
|
||||
|
||||
public string LoadAccepted { get; set; } = "";
|
||||
|
||||
public string LoadQ { get; set; } = "";
|
||||
|
||||
public int MaxLoad { get; set; }
|
||||
|
||||
/// <summary>Coupling capabilities of the front end.</summary>
|
||||
public int AllowedFlagA { get; set; } = 3;
|
||||
|
||||
/// <summary>Coupling capabilities of the rear end.</summary>
|
||||
public int AllowedFlagB { get; set; } = 3;
|
||||
|
||||
public string ControlTypeA { get; set; } = "";
|
||||
|
||||
public string ControlTypeB { get; set; } = "";
|
||||
|
||||
public string EngineType { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// True for the engine types the simulator actually runs its diesel heat model for.
|
||||
/// Mover.cpp calls dizel_Update - and through it dizel_Heat - only for DieselEngine
|
||||
/// and DieselElectric; LoadFIZ_EngineDecode maps the legacy "DumbDE" onto
|
||||
/// DieselElectric. On anything else the coolant temperature struct is written and
|
||||
/// never read.
|
||||
/// </summary>
|
||||
public bool IsDieselEngine =>
|
||||
EngineType.Equals("DieselEngine", StringComparison.OrdinalIgnoreCase)
|
||||
|| EngineType.Equals("DieselElectric", StringComparison.OrdinalIgnoreCase)
|
||||
|| EngineType.Equals("DumbDE", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
97
StarterNG/Domain/Vehicles/VehicleTexture.cs
Normal file
97
StarterNG/Domain/Vehicles/VehicleTexture.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StarterNG.Domain.Vehicles;
|
||||
|
||||
/// <summary>
|
||||
/// One vehicle folder's contribution to the catalogue: the liveries it defines,
|
||||
/// the groups they belong to and any fixed sets they form.
|
||||
/// </summary>
|
||||
public sealed class VehicleEntry
|
||||
{
|
||||
public string? Uuid { get; set; }
|
||||
public List<VehicleGroup> Groups { get; } = new();
|
||||
public List<VehicleTexture> Textures { get; } = new();
|
||||
public List<VehicleSet> Sets { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A family of liveries sharing a thumbnail and a category letter, as declared
|
||||
/// by one textures.txt block.
|
||||
/// </summary>
|
||||
public sealed class VehicleGroup
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string? Category { get; set; }
|
||||
public string? Mini { get; set; }
|
||||
|
||||
/// <summary>Withdrawn stock, hidden from the depot unless asked for.</summary>
|
||||
public bool Archived { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A single livery: one skin file on one model.</summary>
|
||||
public sealed class VehicleTexture
|
||||
{
|
||||
public string? Uuid { get; set; }
|
||||
public string Directory { get; set; } = "";
|
||||
public string Skinfile { get; set; } = "";
|
||||
public string? Model { get; set; }
|
||||
public string? Group { get; set; }
|
||||
public string? MiniRef { get; set; }
|
||||
public string? TextureMini { get; set; }
|
||||
|
||||
/// <summary>Wreck liveries exist in the files but are not offered in the depot.</summary>
|
||||
public bool Wreck { get; set; }
|
||||
|
||||
public List<VehicleAlias> Aliases { get; } = new();
|
||||
public VehicleMeta? Meta { get; set; }
|
||||
|
||||
public string FullPath => Directory + Skinfile;
|
||||
|
||||
/// <summary>Thumbnail name after group inheritance; filled by the catalogue.</summary>
|
||||
public string ResolvedClass { get; internal set; } = "";
|
||||
|
||||
/// <summary>Category letter after group inheritance; filled by the catalogue.</summary>
|
||||
public string? ResolvedCategory { get; internal set; }
|
||||
|
||||
/// <summary>Whether the owning group is archived; filled by the catalogue.</summary>
|
||||
public bool ResolvedArchived { get; internal set; }
|
||||
}
|
||||
|
||||
/// <summary>An alternative model this same skin can be worn by.</summary>
|
||||
public sealed class VehicleAlias
|
||||
{
|
||||
public string? Model { get; set; }
|
||||
public string? Group { get; set; }
|
||||
public string? MiniRef { get; set; }
|
||||
public string? TextureMini { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The comma separated description a livery carries after "//": who made it, for
|
||||
/// which operator and depot, and when it was last revised.
|
||||
/// </summary>
|
||||
public sealed class VehicleMeta
|
||||
{
|
||||
public string? Raw { get; set; }
|
||||
public string? Version { get; set; }
|
||||
public string? Vehicle { get; set; }
|
||||
public string? Operator { get; set; }
|
||||
public string? Depot { get; set; }
|
||||
public string? RevisionDate { get; set; }
|
||||
public string? RevisionPlace { get; set; }
|
||||
public string? TextureAuthor { get; set; }
|
||||
public string? PhotoAuthor { get; set; }
|
||||
public List<string> Extra { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vehicles that belong together and are added to a consist as a unit, such as a
|
||||
/// multiple unit or a fixed rake.
|
||||
/// </summary>
|
||||
public sealed class VehicleSet
|
||||
{
|
||||
public string? Uuid { get; set; }
|
||||
public string? Mode { get; set; }
|
||||
public int Count { get; set; }
|
||||
public List<string> TextureRefs { get; set; } = new();
|
||||
}
|
||||
@@ -43,6 +43,11 @@ public sealed class PhysicalFileSystem : IFileSystem
|
||||
public IReadOnlyList<string> GetFiles(string path, string searchPattern = "*") =>
|
||||
Directory.Exists(path) ? Directory.GetFiles(path, searchPattern) : Array.Empty<string>();
|
||||
|
||||
public IReadOnlyList<string> GetFilesRecursive(string path, string searchPattern) =>
|
||||
Directory.Exists(path)
|
||||
? Directory.GetFiles(path, searchPattern, SearchOption.AllDirectories)
|
||||
: Array.Empty<string>();
|
||||
|
||||
public IReadOnlyList<string> GetDirectories(string path) =>
|
||||
Directory.Exists(path) ? Directory.GetDirectories(path) : Array.Empty<string>();
|
||||
|
||||
|
||||
@@ -70,6 +70,8 @@ public static class Diagnostics
|
||||
new InstallationCheck(
|
||||
AppServices.Current.Paths,
|
||||
AppServices.Current.Files,
|
||||
AppServices.Current.Localization)
|
||||
AppServices.Current.Localization,
|
||||
AppServices.Current.Physics,
|
||||
AppServices.Current.Library)
|
||||
.Run();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Application;
|
||||
|
||||
namespace StarterNG.Infrastructure;
|
||||
|
||||
@@ -20,12 +21,17 @@ public sealed class InstallationCheck
|
||||
private readonly IGamePaths _paths;
|
||||
private readonly IFileSystem _files;
|
||||
private readonly ILocalizedStrings _strings;
|
||||
private readonly IPhysicsRepository _physics;
|
||||
private readonly GameLibrary _library;
|
||||
|
||||
public InstallationCheck(IGamePaths paths, IFileSystem files, ILocalizedStrings strings)
|
||||
public InstallationCheck(IGamePaths paths, IFileSystem files, ILocalizedStrings strings,
|
||||
IPhysicsRepository physics, GameLibrary library)
|
||||
{
|
||||
_paths = paths;
|
||||
_files = files;
|
||||
_strings = strings;
|
||||
_physics = physics;
|
||||
_library = library;
|
||||
}
|
||||
|
||||
public List<string> Run()
|
||||
@@ -39,13 +45,13 @@ public sealed class InstallationCheck
|
||||
if (!_files.FileExists(Path.Combine(_paths.Data, "load_weights.txt")))
|
||||
faults.Add(_strings["FaultNoWeights"]);
|
||||
|
||||
if (GameData.Instance.Sceneries.Count == 0)
|
||||
if (_library.Sceneries.Count == 0)
|
||||
faults.Add(_strings["FaultNoScenery"]);
|
||||
|
||||
if (GameData.Instance.Vehicles.Textures.Count == 0)
|
||||
if (_library.Vehicles.Textures.Count == 0)
|
||||
faults.Add(_strings["FaultNoVehicles"]);
|
||||
|
||||
if (Physics.IndexedCount == 0)
|
||||
if (_physics.IndexedCount == 0)
|
||||
faults.Add(_strings["FaultNoPhysics"]);
|
||||
|
||||
if (Settings.Instance.ResolveExecutable(out var problem) is var _ && problem == ExeProblem.NotFound)
|
||||
|
||||
284
StarterNG/Infrastructure/Vehicles/FizPhysicsRepository.cs
Normal file
284
StarterNG/Infrastructure/Vehicles/FizPhysicsRepository.cs
Normal file
@@ -0,0 +1,284 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
|
||||
namespace StarterNG.Infrastructure.Vehicles;
|
||||
|
||||
/// <summary>
|
||||
/// Reads vehicle physics from the .fiz files under dynamic/.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The format is a flat token stream of "section" headings and "key value"
|
||||
/// pairs, with an <c>include file p1 p2 end</c> directive whose parameters are
|
||||
/// substituted for <c>(1)</c>-style placeholders in the included file. Only the
|
||||
/// handful of keys the starter shows are decoded; everything else is skipped.
|
||||
///
|
||||
/// Both the name index and the parsed results are cached, because the depot asks
|
||||
/// for the same model repeatedly while the user scrolls.
|
||||
/// </remarks>
|
||||
public sealed class FizPhysicsRepository : IPhysicsRepository
|
||||
{
|
||||
/// <summary>Guard against an include cycle in hand written files.</summary>
|
||||
private const int MaxIncludeDepth = 6;
|
||||
|
||||
/// <summary>Sentinel meaning "the rear coupler was never declared".</summary>
|
||||
private const int UndeclaredCoupler = -1;
|
||||
|
||||
private static readonly HashSet<string> KnownKeys = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"M", "Vmax", "L", "LoadAccepted", "LoadQ", "MaxLoad", "AllowedFlag", "ControlType", "EngineType"
|
||||
};
|
||||
|
||||
private readonly IFileSystem _files;
|
||||
private readonly IGamePaths _paths;
|
||||
private readonly IDiagnosticsLog _log;
|
||||
private readonly Encoding _encoding;
|
||||
|
||||
private readonly ConcurrentDictionary<string, VehiclePhysics?> _cache =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly object _indexGate = new();
|
||||
private Dictionary<string, string>? _index;
|
||||
|
||||
public FizPhysicsRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log)
|
||||
{
|
||||
_files = files;
|
||||
_paths = paths;
|
||||
_log = log;
|
||||
_encoding = LegacyEncoding();
|
||||
}
|
||||
|
||||
public void Preload() => Index();
|
||||
|
||||
public int IndexedCount => _index?.Count ?? 0;
|
||||
|
||||
public VehiclePhysics? For(string? dataFolder, string? model)
|
||||
{
|
||||
if (string.IsNullOrEmpty(model))
|
||||
return null;
|
||||
|
||||
string name = Path.GetFileNameWithoutExtension(model);
|
||||
if (name.Length == 0)
|
||||
return null;
|
||||
|
||||
if (_cache.TryGetValue(name, out var cached))
|
||||
return cached;
|
||||
|
||||
var physics = Read(name);
|
||||
_cache[name] = physics;
|
||||
return physics;
|
||||
}
|
||||
|
||||
private VehiclePhysics? Read(string name)
|
||||
{
|
||||
var index = Index();
|
||||
|
||||
// Unpowered variants are shipped as "<model>dumb.fiz".
|
||||
if (!index.TryGetValue(name, out string? path) &&
|
||||
!index.TryGetValue(name + "dumb", out path))
|
||||
return null;
|
||||
|
||||
var physics = new VehiclePhysics { AllowedFlagB = UndeclaredCoupler };
|
||||
try
|
||||
{
|
||||
Parse(physics, path, Path.GetDirectoryName(path) ?? "", parameters: null, depth: 0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"physics/{Path.GetFileName(path)}", ex);
|
||||
}
|
||||
|
||||
// A vehicle that only declares one coupler has the same at both ends.
|
||||
if (physics.AllowedFlagB == UndeclaredCoupler)
|
||||
{
|
||||
physics.AllowedFlagB = physics.AllowedFlagA;
|
||||
if (string.IsNullOrEmpty(physics.ControlTypeB))
|
||||
physics.ControlTypeB = physics.ControlTypeA;
|
||||
}
|
||||
|
||||
return physics;
|
||||
}
|
||||
|
||||
private Dictionary<string, string> Index()
|
||||
{
|
||||
lock (_indexGate)
|
||||
{
|
||||
if (_index is not null)
|
||||
return _index;
|
||||
|
||||
var index = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
foreach (string file in _files.GetFilesRecursive(_paths.Dynamic, "*.fiz"))
|
||||
index[Path.GetFileNameWithoutExtension(file)] = file;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"indexing {_paths.Dynamic}", ex);
|
||||
}
|
||||
|
||||
_index = index;
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
private void Parse(VehiclePhysics physics, string path, string directory, string[]? parameters, int depth)
|
||||
{
|
||||
if (depth > MaxIncludeDepth || !_files.FileExists(path))
|
||||
return;
|
||||
|
||||
var tokens = Tokenize(_files.ReadAllText(path, _encoding));
|
||||
|
||||
string section = "";
|
||||
string? pendingKey = null;
|
||||
|
||||
for (int i = 0; i < tokens.Count; i++)
|
||||
{
|
||||
string token = tokens[i];
|
||||
if (token == "=")
|
||||
continue;
|
||||
|
||||
if (token.Equals("include", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (++i >= tokens.Count)
|
||||
break;
|
||||
|
||||
string includeFile = Resolve(tokens[i], parameters);
|
||||
var includeParameters = new List<string>();
|
||||
i++;
|
||||
while (i < tokens.Count && !tokens[i].Equals("end", StringComparison.OrdinalIgnoreCase))
|
||||
includeParameters.Add(Resolve(tokens[i++], parameters));
|
||||
|
||||
Parse(physics, Path.Combine(directory, includeFile), directory, includeParameters.ToArray(),
|
||||
depth + 1);
|
||||
pendingKey = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
int equals = token.IndexOf('=');
|
||||
if (equals >= 0)
|
||||
{
|
||||
string key = token[..equals].TrimStart('.').Trim();
|
||||
string value = token[(equals + 1)..].Trim();
|
||||
if (value.Length == 0)
|
||||
{
|
||||
pendingKey = key;
|
||||
continue;
|
||||
}
|
||||
Apply(physics, section, key, Resolve(value, parameters));
|
||||
pendingKey = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
string bare = token.TrimStart('.').Trim();
|
||||
if (pendingKey is not null)
|
||||
{
|
||||
Apply(physics, section, pendingKey, Resolve(bare, parameters));
|
||||
pendingKey = null;
|
||||
}
|
||||
else if (KnownKeys.Contains(bare))
|
||||
{
|
||||
pendingKey = bare;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Anything else at this level opens a section, e.g. "Param:" or "BuffCoupl1:".
|
||||
section = new string(bare.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void Apply(VehiclePhysics physics, string section, string key, string value)
|
||||
{
|
||||
switch (section)
|
||||
{
|
||||
case "param":
|
||||
if (Is(key, "M")) physics.Mass = ParseDouble(value, physics.Mass);
|
||||
else if (Is(key, "Vmax")) physics.VMax = ParseDouble(value, physics.VMax);
|
||||
break;
|
||||
case "load":
|
||||
if (Is(key, "LoadAccepted")) physics.LoadAccepted = value;
|
||||
else if (Is(key, "LoadQ")) physics.LoadQ = value;
|
||||
else if (Is(key, "MaxLoad")) physics.MaxLoad = (int)ParseDouble(value, physics.MaxLoad);
|
||||
break;
|
||||
case "engine":
|
||||
if (Is(key, "EngineType")) physics.EngineType = value;
|
||||
break;
|
||||
case "dimensions":
|
||||
if (Is(key, "L")) physics.Length = ParseDouble(value, physics.Length);
|
||||
break;
|
||||
case "buffcoupl":
|
||||
case "buffcoupl1":
|
||||
if (Is(key, "AllowedFlag")) physics.AllowedFlagA = ParseFlag(value, physics.AllowedFlagA);
|
||||
else if (Is(key, "ControlType")) physics.ControlTypeA = value;
|
||||
break;
|
||||
case "buffcoupl2":
|
||||
if (Is(key, "AllowedFlag")) physics.AllowedFlagB = ParseFlag(value, physics.AllowedFlagB);
|
||||
else if (Is(key, "ControlType")) physics.ControlTypeB = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Is(string key, string name) => key.Equals(name, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// A negative flag means the coupling is present but disabled; the simulator
|
||||
/// encodes that as its absolute value plus 128.
|
||||
/// </summary>
|
||||
private static int ParseFlag(string value, int fallback) =>
|
||||
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int flag)
|
||||
? flag < 0 ? -flag + 128 : flag
|
||||
: fallback;
|
||||
|
||||
private static double ParseDouble(string value, double fallback) =>
|
||||
double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed)
|
||||
? parsed
|
||||
: fallback;
|
||||
|
||||
/// <summary>Substitutes an include's "(n)" placeholder with its n-th parameter.</summary>
|
||||
private static string Resolve(string token, string[]? parameters)
|
||||
{
|
||||
if (parameters is null || token.Length < 3 || token[0] != '(' || token[^1] != ')')
|
||||
return token;
|
||||
|
||||
string digits = new(token.Where(char.IsDigit).ToArray());
|
||||
return int.TryParse(digits, out int index) && index >= 1 && index <= parameters.Length
|
||||
? parameters[index - 1]
|
||||
: token;
|
||||
}
|
||||
|
||||
private static List<string> Tokenize(string text)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (string line in text.Split('\n'))
|
||||
{
|
||||
string stripped = line;
|
||||
int comment = stripped.IndexOf('#');
|
||||
if (comment >= 0)
|
||||
stripped = stripped[..comment];
|
||||
sb.Append(stripped).Append(' ');
|
||||
}
|
||||
|
||||
return sb.ToString()
|
||||
.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static Encoding LegacyEncoding()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Encoding.GetEncoding(1250);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return Encoding.Latin1;
|
||||
}
|
||||
}
|
||||
}
|
||||
65
StarterNG/Infrastructure/Vehicles/MiniTextureIndex.cs
Normal file
65
StarterNG/Infrastructure/Vehicles/MiniTextureIndex.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using StarterNG.Application.Abstractions;
|
||||
|
||||
namespace StarterNG.Infrastructure.Vehicles;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IMiniTextureIndex"/> over the installation's textures/mini folder.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Replaces the static thumbnail dictionary that used to live on the vehicle
|
||||
/// database: an installation-scoped lookup with its own lifetime, which is what
|
||||
/// lets a test point the depot at thumbnails that only exist in memory.
|
||||
/// </remarks>
|
||||
public sealed class MiniTextureIndex : IMiniTextureIndex
|
||||
{
|
||||
/// <summary>Thumbnail shown when a vehicle names one we do not have.</summary>
|
||||
private const string FallbackName = "other";
|
||||
|
||||
private readonly IFileSystem _files;
|
||||
private readonly IGamePaths _paths;
|
||||
private readonly object _gate = new();
|
||||
|
||||
private Dictionary<string, string>? _index;
|
||||
|
||||
public MiniTextureIndex(IFileSystem files, IGamePaths paths)
|
||||
{
|
||||
_files = files;
|
||||
_paths = paths;
|
||||
}
|
||||
|
||||
public void Preload() => Index();
|
||||
|
||||
public bool Has(string? miniName) =>
|
||||
!string.IsNullOrEmpty(miniName) && Index().ContainsKey(miniName.ToLowerInvariant());
|
||||
|
||||
public string? PathFor(string? miniName)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(miniName) &&
|
||||
Index().TryGetValue(miniName.ToLowerInvariant(), out string? path))
|
||||
return path;
|
||||
|
||||
return FallbackPath;
|
||||
}
|
||||
|
||||
public string? FallbackPath =>
|
||||
Index().TryGetValue(FallbackName, out string? path) ? path : null;
|
||||
|
||||
private Dictionary<string, string> Index()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_index is not null)
|
||||
return _index;
|
||||
|
||||
var index = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (string file in _files.GetFiles(_paths.MiniTextures, "*.bmp"))
|
||||
index[Path.GetFileNameWithoutExtension(file).ToLowerInvariant()] = file;
|
||||
|
||||
_index = index;
|
||||
return index;
|
||||
}
|
||||
}
|
||||
}
|
||||
63
StarterNG/Infrastructure/Vehicles/MissingAssetScanner.cs
Normal file
63
StarterNG/Infrastructure/Vehicles/MissingAssetScanner.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
|
||||
namespace StarterNG.Infrastructure.Vehicles;
|
||||
|
||||
/// <summary>
|
||||
/// Reports liveries whose skin or model file is not actually present, for the
|
||||
/// optional "log missing vehicle files" diagnostic.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both assets are looked up under several extensions because the simulator
|
||||
/// accepts a texture as .mat or .bmp and a model as .t3d or .e3d.
|
||||
/// </remarks>
|
||||
public sealed class MissingAssetScanner
|
||||
{
|
||||
private static readonly string[] TextureExtensions = { ".mat", ".bmp" };
|
||||
private static readonly string[] ModelExtensions = { ".t3d", ".e3d" };
|
||||
|
||||
private readonly IFileSystem _files;
|
||||
private readonly IGamePaths _paths;
|
||||
|
||||
public MissingAssetScanner(IFileSystem files, IGamePaths paths)
|
||||
{
|
||||
_files = files;
|
||||
_paths = paths;
|
||||
}
|
||||
|
||||
public List<string> Scan(VehicleCatalog catalog)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
|
||||
foreach (var texture in catalog.Textures)
|
||||
{
|
||||
string directory = Path.Combine(
|
||||
_paths.Dynamic,
|
||||
texture.Directory.Replace('/', Path.DirectorySeparatorChar).TrimEnd('\\', '/'));
|
||||
|
||||
if (!string.IsNullOrEmpty(texture.Skinfile) &&
|
||||
!ExistsWithAnyExtension(Path.Combine(directory, texture.Skinfile), TextureExtensions))
|
||||
lines.Add($"# no file: {texture.Directory}{texture.Skinfile}");
|
||||
|
||||
if (!string.IsNullOrEmpty(texture.Model) &&
|
||||
!ExistsWithAnyExtension(Path.Combine(directory, texture.Model), ModelExtensions))
|
||||
lines.Add($"# no model: {texture.Directory}{texture.Model}");
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private bool ExistsWithAnyExtension(string path, string[] extensions)
|
||||
{
|
||||
if (_files.FileExists(path))
|
||||
return true;
|
||||
|
||||
foreach (string extension in extensions)
|
||||
if (_files.FileExists(Path.ChangeExtension(path, extension)))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
204
StarterNG/Infrastructure/Vehicles/TexturesTxtParser.cs
Normal file
204
StarterNG/Infrastructure/Vehicles/TexturesTxtParser.cs
Normal file
@@ -0,0 +1,204 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
|
||||
namespace StarterNG.Infrastructure.Vehicles;
|
||||
|
||||
/// <summary>
|
||||
/// Reads one vehicle folder's textures.txt into a <see cref="VehicleEntry"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The format is line oriented and stateful: '$a' marks the rest of the file as
|
||||
/// archived, '!=' sets the category letter, and '^n' opens a fixed set of n
|
||||
/// vehicles. Everything else with an '=' is a livery. Unknown lines are skipped
|
||||
/// rather than rejected, because the files are hand written.
|
||||
///
|
||||
/// Pure text in, model out: the repository does the reading, so the format can
|
||||
/// be tested from a string array.
|
||||
/// </remarks>
|
||||
public sealed class TexturesTxtParser
|
||||
{
|
||||
/// <summary>Category letter meaning "derive it from the thumbnail name".</summary>
|
||||
private const string DeriveCategory = "*";
|
||||
|
||||
/// <summary>
|
||||
/// Parses the file's lines. Returns null when the folder declares no liveries.
|
||||
/// </summary>
|
||||
/// <param name="directory">
|
||||
/// The folder path relative to dynamic/, with a trailing slash; it forms the
|
||||
/// livery ids and is what the scenery format writes as the data folder.
|
||||
/// </param>
|
||||
public VehicleEntry? Parse(string directory, IReadOnlyList<string> lines)
|
||||
{
|
||||
var entry = new VehicleEntry { Uuid = "legacy:" + directory };
|
||||
|
||||
bool archived = false;
|
||||
string categorySign = DeriveCategory;
|
||||
int setSize = 0;
|
||||
var pendingSet = new List<string>();
|
||||
|
||||
foreach (string raw in lines)
|
||||
{
|
||||
string line = raw.Trim();
|
||||
if (line.Length == 0)
|
||||
continue;
|
||||
|
||||
char first = line[0];
|
||||
if (first is '#' or '@' or '*')
|
||||
continue;
|
||||
|
||||
if (line.StartsWith("$a", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
archived = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (first == '^')
|
||||
{
|
||||
FlushSet(entry, pendingSet, setSize);
|
||||
setSize = line.Length > 1 && char.IsDigit(line[1]) ? line[1] - '0' : 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("!=", StringComparison.Ordinal))
|
||||
{
|
||||
categorySign = line.Length > 2 ? line[2].ToString() : DeriveCategory;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.IndexOf('=') < 0 || line.StartsWith("//", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
var texture = ParseLivery(line, directory, categorySign, archived, entry);
|
||||
if (texture is null)
|
||||
continue;
|
||||
|
||||
entry.Textures.Add(texture);
|
||||
|
||||
if (setSize > 1)
|
||||
{
|
||||
pendingSet.Add(texture.Uuid!);
|
||||
if (pendingSet.Count == setSize)
|
||||
FlushSet(entry, pendingSet, setSize);
|
||||
}
|
||||
}
|
||||
|
||||
FlushSet(entry, pendingSet, setSize);
|
||||
return entry.Textures.Count > 0 ? entry : null;
|
||||
}
|
||||
|
||||
private static void FlushSet(VehicleEntry entry, List<string> pending, int size)
|
||||
{
|
||||
if (pending.Count > 1)
|
||||
entry.Sets.Add(new VehicleSet
|
||||
{
|
||||
Uuid = "legacy-set:" + pending[0],
|
||||
Mode = "sequence",
|
||||
Count = size > 0 ? size : pending.Count,
|
||||
TextureRefs = new List<string>(pending)
|
||||
});
|
||||
|
||||
pending.Clear();
|
||||
}
|
||||
|
||||
private static VehicleTexture? ParseLivery(string line, string directory, string categorySign, bool archived,
|
||||
VehicleEntry entry)
|
||||
{
|
||||
string description = "";
|
||||
int comment = line.IndexOf("//", StringComparison.Ordinal);
|
||||
if (comment >= 0)
|
||||
{
|
||||
description = line[(comment + 2)..].Trim();
|
||||
line = line[..comment].Trim();
|
||||
}
|
||||
|
||||
string[] parts = line.Split('=');
|
||||
if (parts.Length < 2)
|
||||
return null;
|
||||
|
||||
string skin = StripExtension(parts[0].Trim());
|
||||
if (skin.Length == 0)
|
||||
return null;
|
||||
|
||||
// Every "=" segment is "model, thumbnail[, per-texture thumbnail]"; the
|
||||
// first is the livery proper and the rest are alternative models.
|
||||
var specs = new List<(string Model, string Mini, string TextureMini)>();
|
||||
for (int i = 1; i < parts.Length; i++)
|
||||
{
|
||||
string[] fields = parts[i].Split(',');
|
||||
if (fields.Length < 2)
|
||||
continue;
|
||||
specs.Add((fields[0].Trim(), fields[1].Trim(), fields.Length > 2 ? fields[2].Trim() : ""));
|
||||
}
|
||||
if (specs.Count == 0)
|
||||
return null;
|
||||
|
||||
var lead = specs[0];
|
||||
string groupId = $"legacy:{directory}:{lead.Mini}:{categorySign}:{(archived ? 1 : 0)}";
|
||||
|
||||
if (!entry.Groups.Exists(group => group.Id == groupId))
|
||||
entry.Groups.Add(new VehicleGroup
|
||||
{
|
||||
Id = groupId,
|
||||
Category = categorySign,
|
||||
Mini = lead.Mini,
|
||||
Archived = archived
|
||||
});
|
||||
|
||||
var texture = new VehicleTexture
|
||||
{
|
||||
Uuid = $"legacy:{directory}{skin}",
|
||||
Directory = directory,
|
||||
Skinfile = skin,
|
||||
Wreck = skin.Contains("wreck", StringComparison.OrdinalIgnoreCase) ||
|
||||
skin.Contains("wrak", StringComparison.OrdinalIgnoreCase),
|
||||
Model = lead.Model,
|
||||
Group = groupId,
|
||||
MiniRef = lead.Mini,
|
||||
TextureMini = string.IsNullOrEmpty(lead.TextureMini) ? null : lead.TextureMini,
|
||||
Meta = ParseDescription(description)
|
||||
};
|
||||
|
||||
for (int i = 1; i < specs.Count; i++)
|
||||
texture.Aliases.Add(new VehicleAlias
|
||||
{
|
||||
Model = specs[i].Model,
|
||||
MiniRef = specs[i].Mini,
|
||||
TextureMini = string.IsNullOrEmpty(specs[i].TextureMini) ? null : specs[i].TextureMini
|
||||
});
|
||||
|
||||
return texture;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The trailing comment is a fixed, comma separated credit line. Underscores
|
||||
/// stand in for spaces, a convention from the era the format comes from.
|
||||
/// </summary>
|
||||
private static VehicleMeta? ParseDescription(string description)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(description))
|
||||
return null;
|
||||
|
||||
string[] fields = description.Replace('_', ' ').Split(',');
|
||||
string At(int index) => index < fields.Length ? fields[index].Trim() : "";
|
||||
|
||||
return new VehicleMeta
|
||||
{
|
||||
Raw = description,
|
||||
Version = At(0),
|
||||
Vehicle = At(1),
|
||||
Operator = At(2),
|
||||
Depot = At(3),
|
||||
RevisionDate = At(4),
|
||||
RevisionPlace = At(5),
|
||||
TextureAuthor = At(6),
|
||||
PhotoAuthor = At(7)
|
||||
};
|
||||
}
|
||||
|
||||
private static string StripExtension(string name)
|
||||
{
|
||||
int dot = name.LastIndexOf('.');
|
||||
return dot > 0 ? name[..dot] : name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
|
||||
namespace StarterNG.Infrastructure.Vehicles;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the catalogue from the dynamic/<maker>/<vehicle>/textures.txt
|
||||
/// files an installation ships.
|
||||
/// </summary>
|
||||
public sealed class TexturesTxtVehicleRepository : IVehicleRepository
|
||||
{
|
||||
private const string TexturesFileName = "textures.txt";
|
||||
|
||||
private readonly IFileSystem _files;
|
||||
private readonly IGamePaths _paths;
|
||||
private readonly IDiagnosticsLog _log;
|
||||
private readonly TexturesTxtParser _parser;
|
||||
private readonly Encoding _encoding;
|
||||
|
||||
public TexturesTxtVehicleRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log,
|
||||
TexturesTxtParser parser)
|
||||
{
|
||||
_files = files;
|
||||
_paths = paths;
|
||||
_log = log;
|
||||
_parser = parser;
|
||||
_encoding = LegacyEncoding();
|
||||
}
|
||||
|
||||
public int Load(VehicleCatalog catalog)
|
||||
{
|
||||
catalog.BeginLoad();
|
||||
|
||||
int liveries = 0;
|
||||
foreach (string file in TexturesFiles())
|
||||
{
|
||||
var entry = ReadEntry(file);
|
||||
if (entry is null)
|
||||
continue;
|
||||
|
||||
liveries += entry.Textures.Count;
|
||||
catalog.Ingest(entry);
|
||||
}
|
||||
|
||||
catalog.EndLoad();
|
||||
return liveries;
|
||||
}
|
||||
|
||||
private VehicleEntry? ReadEntry(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Read as text so the code page survives: the credit lines carry
|
||||
// Polish characters that ReadAllLines' default encoding would mangle.
|
||||
string[] lines = _files.ReadAllText(path, _encoding).Split('\n');
|
||||
return _parser.Parse(RelativeDirectory(path), lines);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"textures.txt {path}", ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rolling stock sits exactly two levels below dynamic/: a maker folder and a
|
||||
/// vehicle folder.
|
||||
/// </summary>
|
||||
private IEnumerable<string> TexturesFiles()
|
||||
{
|
||||
foreach (string maker in _files.GetDirectories(_paths.Dynamic))
|
||||
foreach (string vehicle in _files.GetDirectories(maker))
|
||||
{
|
||||
string path = Path.Combine(vehicle, TexturesFileName);
|
||||
if (_files.FileExists(path))
|
||||
yield return path;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The folder as the scenery format spells it: relative to dynamic/, forward
|
||||
/// slashes, trailing slash.
|
||||
/// </summary>
|
||||
private string RelativeDirectory(string texturesPath)
|
||||
{
|
||||
string directory = Path.GetDirectoryName(texturesPath) ?? "";
|
||||
string full = Path.GetFullPath(directory);
|
||||
string root = Path.GetFullPath(_paths.Dynamic);
|
||||
|
||||
string relative = full.StartsWith(root, StringComparison.OrdinalIgnoreCase)
|
||||
? full[root.Length..].TrimStart(Path.DirectorySeparatorChar, '/')
|
||||
: directory;
|
||||
|
||||
return relative.Replace('\\', '/').TrimEnd('/') + "/";
|
||||
}
|
||||
|
||||
private static Encoding LegacyEncoding()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Encoding.GetEncoding(1250);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return Encoding.Latin1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Application;
|
||||
|
||||
namespace StarterNG;
|
||||
|
||||
|
||||
@@ -19,14 +19,16 @@ using Avalonia.VisualTree;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
using StarterNG.Application;
|
||||
|
||||
namespace StarterNG.Views;
|
||||
|
||||
public partial class Depot : UserControl
|
||||
{
|
||||
|
||||
private readonly VehicleDatabase _db = GameData.Instance.Vehicles;
|
||||
private readonly List<Scenery> _sceneries = GameData.Instance.Sceneries;
|
||||
private readonly VehicleCatalog _db = AppServices.Current.Library.Vehicles;
|
||||
private readonly List<Scenery> _sceneries = AppServices.Current.Library.Sceneries;
|
||||
|
||||
private readonly VehicleInfo _info;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ using Avalonia.Media.Imaging;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Infrastructure;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
|
||||
namespace StarterNG.Views;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Application;
|
||||
|
||||
namespace StarterNG.Views;
|
||||
|
||||
@@ -28,7 +29,7 @@ public sealed class MiniTextures
|
||||
{
|
||||
if (string.IsNullOrEmpty(name))
|
||||
return null;
|
||||
if (strict && !VehicleDatabase.HasMini(name))
|
||||
if (strict && !AppServices.Current.MiniTextures.Has(name))
|
||||
return null;
|
||||
|
||||
string key = $"{(strict ? "!" : "")}{name}@{height}";
|
||||
@@ -36,7 +37,7 @@ public sealed class MiniTextures
|
||||
return cached;
|
||||
|
||||
Bitmap? bmp = null;
|
||||
string? path = VehicleDatabase.MiniPath(name);
|
||||
string? path = AppServices.Current.MiniTextures.PathFor(name);
|
||||
if (path != null)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -15,6 +15,7 @@ using Avalonia.Threading;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
|
||||
namespace StarterNG.Views;
|
||||
|
||||
@@ -28,7 +29,7 @@ public sealed class VehicleBrowser
|
||||
private readonly Image miniPreview;
|
||||
private readonly Button addVehicleButton;
|
||||
|
||||
private readonly VehicleDatabase _db;
|
||||
private readonly VehicleCatalog _db;
|
||||
private readonly MiniTextures _minis;
|
||||
private readonly Action _openTextureBase;
|
||||
|
||||
@@ -57,7 +58,7 @@ public sealed class VehicleBrowser
|
||||
public VehicleBrowser(
|
||||
ComboBox categoryCombo, ComboBox classCombo, ListBox vehicleListBox, TextBox searchBox,
|
||||
CheckBox hideArchivalCheck, Image miniPreview, Button addVehicleButton,
|
||||
VehicleDatabase db, MiniTextures minis, Action openTextureBase)
|
||||
VehicleCatalog db, MiniTextures minis, Action openTextureBase)
|
||||
{
|
||||
this.categoryCombo = categoryCombo;
|
||||
this.classCombo = classCombo;
|
||||
|
||||
@@ -12,6 +12,7 @@ using Material.Icons.Avalonia;
|
||||
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
|
||||
namespace StarterNG.Views;
|
||||
|
||||
@@ -19,7 +20,7 @@ public sealed class VehicleCards
|
||||
{
|
||||
private readonly Panel consistStack;
|
||||
|
||||
private readonly VehicleDatabase _db;
|
||||
private readonly VehicleCatalog _db;
|
||||
private readonly Consist _consist;
|
||||
private readonly Cargo _cargo;
|
||||
private readonly MiniTextures _minis;
|
||||
@@ -36,7 +37,7 @@ public sealed class VehicleCards
|
||||
|
||||
public Dynamic? PressedCar { get; set; }
|
||||
|
||||
public VehicleCards(Panel consistStack, VehicleDatabase db, Consist consist,
|
||||
public VehicleCards(Panel consistStack, VehicleCatalog db, Consist consist,
|
||||
Cargo cargo, MiniTextures minis, Cursor hand,
|
||||
Action redraw, Action refreshDetails, Action<Dynamic> selectInBrowser,
|
||||
Action<int, PointerPressedEventArgs> armDrag)
|
||||
|
||||
@@ -12,6 +12,7 @@ using Avalonia.Media;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Controls;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
|
||||
namespace StarterNG.Views;
|
||||
|
||||
@@ -26,7 +27,7 @@ public sealed class VehicleDetails
|
||||
private readonly StackPanel damagePanel;
|
||||
private readonly Button removeVehicleButton;
|
||||
|
||||
private readonly VehicleDatabase _db;
|
||||
private readonly VehicleCatalog _db;
|
||||
private readonly VehicleInfo _info;
|
||||
private readonly Consist _consist;
|
||||
private readonly Cargo _cargo;
|
||||
@@ -41,7 +42,7 @@ public sealed class VehicleDetails
|
||||
Panel couplerActions,
|
||||
StackPanel brakesPanel, StackPanel loadsPanel, StackPanel damagePanel,
|
||||
Button removeVehicleButton,
|
||||
VehicleDatabase db, VehicleInfo info, Consist consist, Cargo cargo, Cursor hand,
|
||||
VehicleCatalog db, VehicleInfo info, Consist consist, Cargo cargo, Cursor hand,
|
||||
Action redraw, Action refreshChrome, Action<VehicleTexture, StackPanel> showTextureInfo)
|
||||
{
|
||||
this.generalPanel = generalPanel;
|
||||
|
||||
@@ -9,6 +9,8 @@ using Avalonia.Media;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Infrastructure;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
using StarterNG.Application;
|
||||
|
||||
namespace StarterNG.Views;
|
||||
|
||||
@@ -19,7 +21,7 @@ public sealed class RulesWindow : Window
|
||||
private readonly WrapPanel _members = new() { Orientation = Orientation.Horizontal };
|
||||
private readonly ComboBox _category = new();
|
||||
private readonly ComboBox _model = new();
|
||||
private readonly VehicleDatabase _db = GameData.Instance.Vehicles;
|
||||
private readonly VehicleCatalog _db = AppServices.Current.Library.Vehicles;
|
||||
|
||||
public RulesWindow()
|
||||
{
|
||||
|
||||
@@ -38,7 +38,7 @@ public partial class Scenarios : UserControl
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
Sceneries = GameData.Instance.Sceneries;
|
||||
Sceneries = AppServices.Current.Library.Sceneries;
|
||||
|
||||
_loadingFilters = true;
|
||||
archivalSwitch.IsChecked = !StarterNG.Classes.Settings.Instance.ShowArchivalSceneries;
|
||||
@@ -339,7 +339,7 @@ public partial class Scenarios : UserControl
|
||||
var lines = new List<string>();
|
||||
lines.AddRange(scenery.Faults);
|
||||
|
||||
var db = GameData.Instance.Vehicles;
|
||||
var db = AppServices.Current.Library.Vehicles;
|
||||
foreach (var ts in scenery.Trainsets)
|
||||
{
|
||||
if (!ts.Parsed)
|
||||
@@ -423,8 +423,8 @@ public partial class Scenarios : UserControl
|
||||
try
|
||||
{
|
||||
string? selected = AppState.Instance.CurrentScenery?.Path;
|
||||
await Task.Run(() => GameData.Instance.ReloadSceneries());
|
||||
Sceneries = GameData.Instance.Sceneries;
|
||||
await Task.Run(() => AppServices.Current.Library.ReloadSceneries());
|
||||
Sceneries = AppServices.Current.Library.Sceneries;
|
||||
BuildSceneryTree();
|
||||
if (!string.IsNullOrEmpty(selected))
|
||||
{
|
||||
@@ -486,7 +486,7 @@ public partial class Scenarios : UserControl
|
||||
return cached;
|
||||
|
||||
Bitmap? bmp = null;
|
||||
string? path = VehicleDatabase.MiniPath(name);
|
||||
string? path = AppServices.Current.MiniTextures.PathFor(name);
|
||||
if (path != null)
|
||||
{
|
||||
try
|
||||
@@ -888,12 +888,12 @@ public partial class Scenarios : UserControl
|
||||
UpdateTrainStats(trainset);
|
||||
});
|
||||
|
||||
var db = GameData.Instance.Vehicles;
|
||||
var db = AppServices.Current.Library.Vehicles;
|
||||
foreach (var train in trainset.Vehicles)
|
||||
{
|
||||
|
||||
string miniName = db.MiniForSkin(train.SkinFile) ?? train.SkinFile;
|
||||
if (!VehicleDatabase.HasMini(miniName) && VehicleDatabase.HasMini(train.SkinFile))
|
||||
if (!AppServices.Current.MiniTextures.Has(miniName) && AppServices.Current.MiniTextures.Has(train.SkinFile))
|
||||
miniName = train.SkinFile;
|
||||
int thumbH = StarterNG.Classes.Settings.Instance.LargeThumbnails ? 64 : 32;
|
||||
|
||||
@@ -1193,12 +1193,12 @@ public partial class Scenarios : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
var db = GameData.Instance.Vehicles;
|
||||
var db = AppServices.Current.Library.Vehicles;
|
||||
StarterNG.Infrastructure.StatsBar.Fill(trainStats, TrainsetDisplay.StatsFields(
|
||||
trainset,
|
||||
car => Physics.For(car.DataFolder, db.TextureForSkin(car.SkinFile)?.Model)
|
||||
?? Physics.For(car.DataFolder, car.MmdFile)
|
||||
?? Physics.For(car.DataFolder, car.SkinFile),
|
||||
car => AppServices.Current.Physics.For(car.DataFolder, db.TextureForSkin(car.SkinFile)?.Model)
|
||||
?? AppServices.Current.Physics.For(car.DataFolder, car.MmdFile)
|
||||
?? AppServices.Current.Physics.For(car.DataFolder, car.SkinFile),
|
||||
car => db.TextureForSkin(car.SkinFile)?.ResolvedCategory));
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,14 @@ using Avalonia.Input;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Interactivity;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
using StarterNG.Application;
|
||||
|
||||
namespace StarterNG.Views;
|
||||
|
||||
public partial class TextureBaseWindow : Window
|
||||
{
|
||||
private readonly VehicleDatabase _db = GameData.Instance.Vehicles;
|
||||
private readonly VehicleCatalog _db = AppServices.Current.Library.Vehicles;
|
||||
private readonly List<TextureRow> _all = new();
|
||||
|
||||
public VehicleTexture? Picked { get; private set; }
|
||||
@@ -193,7 +195,7 @@ internal sealed class TextureRow
|
||||
public string Directory { get; }
|
||||
public DateTime? RevisionDate { get; }
|
||||
|
||||
public TextureRow(VehicleTexture t, VehicleDatabase db)
|
||||
public TextureRow(VehicleTexture t, VehicleCatalog db)
|
||||
{
|
||||
Texture = t;
|
||||
Skin = Path.GetFileNameWithoutExtension(t.Skinfile);
|
||||
|
||||
Reference in New Issue
Block a user