From 9b60ecdd19e616205d76b00300d4e62207a755db Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=CE=B6eh=20Matt?= <5415177+ZehMatt@users.noreply.github.com>
Date: Tue, 28 Jul 2026 03:22:58 +0300
Subject: [PATCH 1/3] Separate logic into Core and add a basic cli
---
Cli/Cli.csproj | 27 +++
Cli/CommandContext.cs | 134 ++++++++++++
Cli/CommandLine.cs | 66 ++++++
Cli/Commands/CropCommand.cs | 50 +++++
Cli/Commands/ExportImagesCommand.cs | 76 +++++++
Cli/Commands/ImportImagesCommand.cs | 93 ++++++++
Cli/Commands/InfoCommand.cs | 99 +++++++++
Cli/Commands/OffsetsCommand.cs | 91 ++++++++
Cli/Commands/ReencodeCommand.cs | 53 +++++
Cli/Commands/StripImagesCommand.cs | 50 +++++
Cli/Commands/ValidateCommand.cs | 73 +++++++
Cli/ConsoleLogger.cs | 44 ++++
Cli/ExitCodes.cs | 9 +
Cli/ICommand.cs | 16 ++
Cli/Program.cs | 94 +++++++++
{Gui => Core}/Assets/palette.png | Bin
Core/Core.csproj | 40 ++++
.../Graphics/GraphicsElementJson.cs | 2 +-
Core/Graphics/GraphicsElementOperations.cs | 157 ++++++++++++++
Core/Graphics/ImageTableIo.cs | 197 +++++++++++++++++
Core/ImageTableGroupsConfig.cs | 77 +++++++
Core/Objects/LocoObjectFile.cs | 6 +
Core/Objects/ObjectFile.cs | 127 +++++++++++
Core/Operations/BatchProcessor.cs | 118 +++++++++++
Core/Operations/ObjectOperations.cs | 66 ++++++
Core/PaletteMapLoader.cs | 27 +++
Core/Validation/ObjectValidation.cs | 144 +++++++++++++
Gui/Gui.csproj | 7 +-
Gui/Models/ObjectEditorContext.cs | 62 +-----
.../Graphics/ImageTableViewModel.cs | 158 +-------------
Gui/ViewModels/Graphics/ImageViewModel.cs | 54 +----
Gui/ViewModels/Loco/ObjectEditorViewModel.cs | 199 +++---------------
Gui/ViewModels/MainWindowViewModel.cs | 9 +-
ObjectEditor.sln | 28 +++
Tests/IdempotenceTests.cs | 3 +-
Tests/Tests.csproj | 1 +
36 files changed, 2012 insertions(+), 445 deletions(-)
create mode 100644 Cli/Cli.csproj
create mode 100644 Cli/CommandContext.cs
create mode 100644 Cli/CommandLine.cs
create mode 100644 Cli/Commands/CropCommand.cs
create mode 100644 Cli/Commands/ExportImagesCommand.cs
create mode 100644 Cli/Commands/ImportImagesCommand.cs
create mode 100644 Cli/Commands/InfoCommand.cs
create mode 100644 Cli/Commands/OffsetsCommand.cs
create mode 100644 Cli/Commands/ReencodeCommand.cs
create mode 100644 Cli/Commands/StripImagesCommand.cs
create mode 100644 Cli/Commands/ValidateCommand.cs
create mode 100644 Cli/ConsoleLogger.cs
create mode 100644 Cli/ExitCodes.cs
create mode 100644 Cli/ICommand.cs
create mode 100644 Cli/Program.cs
rename {Gui => Core}/Assets/palette.png (100%)
create mode 100644 Core/Core.csproj
rename {Gui/ViewModels => Core}/Graphics/GraphicsElementJson.cs (96%)
create mode 100644 Core/Graphics/GraphicsElementOperations.cs
create mode 100644 Core/Graphics/ImageTableIo.cs
create mode 100644 Core/ImageTableGroupsConfig.cs
create mode 100644 Core/Objects/LocoObjectFile.cs
create mode 100644 Core/Objects/ObjectFile.cs
create mode 100644 Core/Operations/BatchProcessor.cs
create mode 100644 Core/Operations/ObjectOperations.cs
create mode 100644 Core/PaletteMapLoader.cs
create mode 100644 Core/Validation/ObjectValidation.cs
diff --git a/Cli/Cli.csproj b/Cli/Cli.csproj
new file mode 100644
index 00000000..1ef96698
--- /dev/null
+++ b/Cli/Cli.csproj
@@ -0,0 +1,27 @@
+
+
+
+ Exe
+ net10.0
+ preview
+ enable
+ enable
+ latest
+ Cli
+ locoobj
+ Major
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Cli/CommandContext.cs b/Cli/CommandContext.cs
new file mode 100644
index 00000000..a9e68389
--- /dev/null
+++ b/Cli/CommandContext.cs
@@ -0,0 +1,134 @@
+using Core;
+using Core.Objects;
+using Core.Operations;
+using Dat.Data;
+using Definitions.ObjectModels;
+using Microsoft.Extensions.Logging;
+
+namespace Cli;
+
+public sealed class CommandContext(CommandLine commandLine, ILogger logger)
+{
+ public CommandLine Args { get; } = commandLine;
+
+ public ILogger Logger { get; } = logger;
+
+ PaletteMap? paletteMap;
+
+ public PaletteMap PaletteMap
+ => paletteMap ??= PaletteMapLoader.Load(Args.GetString("palette"));
+
+ public static IReadOnlySet CommonFlags { get; } = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "dry-run", "no-recurse", "allow-vanilla", "verbose", "quiet", "help",
+ };
+
+ public static IReadOnlySet CommonOptions { get; } = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "dry-run", "no-recurse", "allow-vanilla", "verbose", "quiet", "help", "out", "encoding", "palette",
+ };
+
+ public bool DryRun
+ => Args.Has("dry-run");
+
+ public bool Recursive
+ => !Args.Has("no-recurse");
+
+ public bool AllowSavingAsVanillaObject
+ => Args.Has("allow-vanilla");
+
+ public string? OutputPath
+ => Args.GetString("out");
+
+ public bool TryGetEncoding(out SawyerEncoding? encoding)
+ {
+ encoding = null;
+ var raw = Args.GetString("encoding");
+
+ if (string.IsNullOrEmpty(raw))
+ {
+ return true;
+ }
+
+ if (!Enum.TryParse(raw, ignoreCase: true, out var parsed))
+ {
+ Logger.LogError("Unknown encoding \"{Encoding}\". Valid values: {Valid}", raw, string.Join(", ", Enum.GetNames()));
+ return false;
+ }
+
+ encoding = parsed;
+ return true;
+ }
+
+ public bool TryResolveInputs(out IReadOnlyList files, out string inputRoot)
+ {
+ files = [];
+ inputRoot = string.Empty;
+
+ var path = Args.Positionals.Count > 0 ? Args.Positionals[0] : null;
+
+ if (string.IsNullOrEmpty(path))
+ {
+ Logger.LogError("No input path was given");
+ return false;
+ }
+
+ files = ObjectFile.EnumerateDatFiles(path, Recursive);
+
+ if (files.Count == 0)
+ {
+ Logger.LogError("No .dat files found at \"{Path}\"", path);
+ return false;
+ }
+
+ inputRoot = Directory.Exists(path) ? path : Path.GetDirectoryName(path) ?? string.Empty;
+ return true;
+ }
+
+ public bool TryBuildBatchOptions(string inputRoot, out BatchOptions options, bool withPalette = false)
+ {
+ options = new BatchOptions();
+
+ if (!TryGetEncoding(out var encoding))
+ {
+ return false;
+ }
+
+ options = new BatchOptions
+ {
+ OutputDirectory = OutputPath,
+ InputRoot = inputRoot,
+ Encoding = encoding,
+ AllowSavingAsVanillaObject = AllowSavingAsVanillaObject,
+ DryRun = DryRun,
+ PaletteMap = withPalette ? PaletteMap : null,
+ };
+
+ return true;
+ }
+
+ public int Report(BatchResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+
+ foreach (var item in result.Items)
+ {
+ Console.WriteLine($"{(item.Succeeded ? "ok " : "FAIL")} {item.FileName}: {item.Message}");
+ }
+
+ Console.WriteLine($"{result.SucceededCount} succeeded, {result.FailedCount} failed");
+ return result.FailedCount == 0 ? ExitCodes.Success : ExitCodes.OperationFailed;
+ }
+
+ public bool ValidateOptions(IReadOnlySet known)
+ {
+ var unknown = Args.UnknownOptions(known).ToList();
+ if (unknown.Count == 0)
+ {
+ return true;
+ }
+
+ Logger.LogError("Unknown option(s): {Unknown}", string.Join(", ", unknown.Select(x => $"--{x}")));
+ return false;
+ }
+}
diff --git a/Cli/CommandLine.cs b/Cli/CommandLine.cs
new file mode 100644
index 00000000..961c7cd7
--- /dev/null
+++ b/Cli/CommandLine.cs
@@ -0,0 +1,66 @@
+namespace Cli;
+
+public sealed class CommandLine
+{
+ readonly List positionals = [];
+ readonly Dictionary options = new(StringComparer.OrdinalIgnoreCase);
+
+ public IReadOnlyList Positionals
+ => positionals;
+
+ public IReadOnlyCollection OptionNames
+ => options.Keys;
+
+ public static CommandLine Parse(IReadOnlyList args, IReadOnlySet flagNames)
+ {
+ ArgumentNullException.ThrowIfNull(args);
+ ArgumentNullException.ThrowIfNull(flagNames);
+
+ var result = new CommandLine();
+
+ for (var i = 0; i < args.Count; i++)
+ {
+ var arg = args[i];
+
+ if (!arg.StartsWith("--", StringComparison.Ordinal))
+ {
+ result.positionals.Add(arg);
+ continue;
+ }
+
+ var body = arg[2..];
+ var equals = body.IndexOf('=', StringComparison.Ordinal);
+
+ if (equals >= 0)
+ {
+ result.options[body[..equals]] = body[(equals + 1)..];
+ continue;
+ }
+
+ if (flagNames.Contains(body) || i + 1 >= args.Count || args[i + 1].StartsWith("--", StringComparison.Ordinal))
+ {
+ result.options[body] = null;
+ continue;
+ }
+
+ result.options[body] = args[++i];
+ }
+
+ return result;
+ }
+
+ public bool Has(string name)
+ => options.ContainsKey(name);
+
+ public string? GetString(string name, string? defaultValue = null)
+ => options.TryGetValue(name, out var value) && value != null ? value : defaultValue;
+
+ public bool TryGetInt(string name, out int value)
+ {
+ value = 0;
+ return options.TryGetValue(name, out var raw) && int.TryParse(raw, out value);
+ }
+
+ public IEnumerable UnknownOptions(IReadOnlySet known)
+ => options.Keys.Where(x => !known.Contains(x));
+}
diff --git a/Cli/Commands/CropCommand.cs b/Cli/Commands/CropCommand.cs
new file mode 100644
index 00000000..18133bf3
--- /dev/null
+++ b/Cli/Commands/CropCommand.cs
@@ -0,0 +1,50 @@
+using Core.Operations;
+
+namespace Cli.Commands;
+
+public sealed class CropCommand : ICommand
+{
+ public string Name
+ => "crop";
+
+ public string Summary
+ => "Crop transparent borders off every image, adjusting offsets to match";
+
+ public string Usage
+ => "locoobj crop [--out ] [--encoding ] [--palette ] [--dry-run] [--no-recurse] [--allow-vanilla]";
+
+ public IReadOnlySet Options
+ => CommandContext.CommonOptions;
+
+ public IReadOnlySet Flags
+ => CommandContext.CommonFlags;
+
+ public Task RunAsync(CommandContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ if (!context.TryResolveInputs(out var files, out var inputRoot))
+ {
+ return Task.FromResult(ExitCodes.UsageError);
+ }
+
+ if (!context.TryBuildBatchOptions(inputRoot, out var options, withPalette: true))
+ {
+ return Task.FromResult(ExitCodes.UsageError);
+ }
+
+ var result = BatchProcessor.Run(
+ files,
+ file =>
+ {
+ var cropped = ObjectOperations.CropAllImages(file.LocoObject, context.PaletteMap);
+ return cropped == 0
+ ? OperationOutcome.Unchanged("no images")
+ : OperationOutcome.Changed($"cropped {cropped} image(s)");
+ },
+ options,
+ context.Logger);
+
+ return Task.FromResult(context.Report(result));
+ }
+}
diff --git a/Cli/Commands/ExportImagesCommand.cs b/Cli/Commands/ExportImagesCommand.cs
new file mode 100644
index 00000000..9495ce3b
--- /dev/null
+++ b/Cli/Commands/ExportImagesCommand.cs
@@ -0,0 +1,76 @@
+using Core.Graphics;
+using Core.Objects;
+using Microsoft.Extensions.Logging;
+
+namespace Cli.Commands;
+
+public sealed class ExportImagesCommand : ICommand
+{
+ public string Name
+ => "export-images";
+
+ public string Summary
+ => "Export an object's images as PNGs plus a sprites.json offsets file";
+
+ public string Usage
+ => "locoobj export-images --out [--use-names] [--palette ] [--no-recurse]";
+
+ public IReadOnlySet Options { get; } = new HashSet(CommandContext.CommonOptions, StringComparer.OrdinalIgnoreCase)
+ {
+ "use-names",
+ };
+
+ public IReadOnlySet Flags { get; } = new HashSet(CommandContext.CommonFlags, StringComparer.OrdinalIgnoreCase)
+ {
+ "use-names",
+ };
+
+ public async Task RunAsync(CommandContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ var outputRoot = context.OutputPath;
+ if (string.IsNullOrEmpty(outputRoot))
+ {
+ context.Logger.LogError("--out is required");
+ return ExitCodes.UsageError;
+ }
+
+ if (!context.TryResolveInputs(out var files, out var inputRoot))
+ {
+ return ExitCodes.UsageError;
+ }
+
+ var useNames = context.Args.Has("use-names");
+ var perObjectFolder = files.Count > 1;
+ var failed = 0;
+
+ foreach (var fileName in files)
+ {
+ try
+ {
+ var file = ObjectFile.Load(fileName, context.Logger, context.PaletteMap);
+ if (file?.LocoObject.ImageTable == null)
+ {
+ context.Logger.LogWarning("\"{FileName}\" has no image table - skipping", fileName);
+ continue;
+ }
+
+ var targetDir = perObjectFolder
+ ? Path.Combine(outputRoot, Path.GetFileNameWithoutExtension(fileName))
+ : outputRoot;
+
+ var count = await ImageTableIo.ExportAsync(file.LocoObject.ImageTable, targetDir, useNames, context.Logger);
+ Console.WriteLine($"ok {fileName}: exported {count} image(s) to \"{targetDir}\"");
+ }
+ catch (Exception ex)
+ {
+ context.Logger.LogError(ex, "Failed to export images from \"{FileName}\"", fileName);
+ Console.WriteLine($"FAIL {fileName}: {ex.Message}");
+ failed++;
+ }
+ }
+
+ return failed == 0 ? ExitCodes.Success : ExitCodes.OperationFailed;
+ }
+}
diff --git a/Cli/Commands/ImportImagesCommand.cs b/Cli/Commands/ImportImagesCommand.cs
new file mode 100644
index 00000000..1f0b96cc
--- /dev/null
+++ b/Cli/Commands/ImportImagesCommand.cs
@@ -0,0 +1,93 @@
+using Core.Graphics;
+using Core.Objects;
+using Microsoft.Extensions.Logging;
+
+namespace Cli.Commands;
+
+public sealed class ImportImagesCommand : ICommand
+{
+ public string Name
+ => "import-images";
+
+ public string Summary
+ => "Replace an object's image table from a directory of PNGs and a sprites.json";
+
+ public string Usage
+ => "locoobj import-images --from [--out ] [--encoding ] [--palette ] [--offsets-only] [--dry-run] [--allow-vanilla]";
+
+ public IReadOnlySet Options { get; } = new HashSet(CommandContext.CommonOptions, StringComparer.OrdinalIgnoreCase)
+ {
+ "from", "offsets-only",
+ };
+
+ public IReadOnlySet Flags { get; } = new HashSet(CommandContext.CommonFlags, StringComparer.OrdinalIgnoreCase)
+ {
+ "offsets-only",
+ };
+
+ public async Task RunAsync(CommandContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ var source = context.Args.GetString("from");
+ if (string.IsNullOrEmpty(source))
+ {
+ context.Logger.LogError("--from is required");
+ return ExitCodes.UsageError;
+ }
+
+ var inputFile = context.Args.Positionals.Count > 0 ? context.Args.Positionals[0] : null;
+ if (string.IsNullOrEmpty(inputFile) || !File.Exists(inputFile))
+ {
+ context.Logger.LogError("A single existing .dat file must be given as the first argument");
+ return ExitCodes.UsageError;
+ }
+
+ if (!context.TryGetEncoding(out var encoding))
+ {
+ return ExitCodes.UsageError;
+ }
+
+ var file = ObjectFile.Load(inputFile, context.Logger, context.PaletteMap);
+ if (file?.LocoObject.ImageTable == null)
+ {
+ context.Logger.LogError("\"{FileName}\" has no image table", inputFile);
+ return ExitCodes.OperationFailed;
+ }
+
+ var imageTable = file.LocoObject.ImageTable;
+ int count;
+
+ if (context.Args.Has("offsets-only"))
+ {
+ var spritesFile = Directory.Exists(source) ? Path.Combine(source, ImageTableIo.SpritesFileName) : source;
+ count = await ImageTableIo.ApplyOffsetsAsync(imageTable, spritesFile, context.Logger);
+ }
+ else
+ {
+ count = await ImageTableIo.ImportAsync(imageTable, source, context.PaletteMap, context.Logger, file.LocoObject.Object, file.LocoObject.ObjectType);
+ }
+
+ if (count == 0)
+ {
+ context.Logger.LogError("Nothing was imported from \"{Source}\"", source);
+ return ExitCodes.OperationFailed;
+ }
+
+ var outputFile = context.OutputPath ?? inputFile;
+
+ if (context.DryRun)
+ {
+ Console.WriteLine($"ok {inputFile}: imported {count} image(s) (dry run, would write \"{outputFile}\")");
+ return ExitCodes.Success;
+ }
+
+ if (!ObjectFile.SaveDat(file, outputFile, context.Logger, encoding, allowSavingAsVanillaObject: context.AllowSavingAsVanillaObject))
+ {
+ return ExitCodes.OperationFailed;
+ }
+
+ Console.WriteLine($"ok {inputFile}: imported {count} image(s) into \"{outputFile}\"");
+ return ExitCodes.Success;
+ }
+}
diff --git a/Cli/Commands/InfoCommand.cs b/Cli/Commands/InfoCommand.cs
new file mode 100644
index 00000000..beb468a9
--- /dev/null
+++ b/Cli/Commands/InfoCommand.cs
@@ -0,0 +1,99 @@
+using Core.Objects;
+using System.Text.Json;
+
+namespace Cli.Commands;
+
+public sealed class InfoCommand : ICommand
+{
+ public string Name
+ => "info";
+
+ public string Summary
+ => "Print header, string table and image table details for objects";
+
+ public string Usage
+ => "locoobj info [--json] [--no-recurse]";
+
+ public IReadOnlySet Options { get; } = new HashSet(CommandContext.CommonOptions, StringComparer.OrdinalIgnoreCase)
+ {
+ "json",
+ };
+
+ public IReadOnlySet Flags { get; } = new HashSet(CommandContext.CommonFlags, StringComparer.OrdinalIgnoreCase)
+ {
+ "json",
+ };
+
+ sealed record ObjectInfo(
+ string FileName,
+ string Name,
+ string ObjectType,
+ string ObjectSource,
+ string Encoding,
+ uint32_t Checksum,
+ uint32_t DataLength,
+ int ImageCount,
+ int ImageGroupCount,
+ int StringCount);
+
+ public Task RunAsync(CommandContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ if (!context.TryResolveInputs(out var files, out _))
+ {
+ return Task.FromResult(ExitCodes.UsageError);
+ }
+
+ var asJson = context.Args.Has("json");
+ var infos = new List();
+ var failed = 0;
+
+ foreach (var fileName in files)
+ {
+ var file = ObjectFile.Load(fileName, context.Logger);
+ if (file == null)
+ {
+ failed++;
+ continue;
+ }
+
+ var header = file.DatInfo.S5Header;
+ var imageTable = file.LocoObject.ImageTable;
+
+ infos.Add(new ObjectInfo(
+ fileName,
+ header.Name,
+ header.ObjectType.ToString(),
+ header.ObjectSource.ToString(),
+ file.DatInfo.ObjectHeader.Encoding.ToString(),
+ header.Checksum,
+ file.DatInfo.ObjectHeader.DataLength,
+ imageTable?.Groups.Sum(x => x.GraphicsElements.Count) ?? 0,
+ imageTable?.Groups.Count ?? 0,
+ file.LocoObject.StringTable.Table.Count));
+ }
+
+ if (asJson)
+ {
+ Console.WriteLine(JsonSerializer.Serialize(infos, new JsonSerializerOptions { WriteIndented = true }));
+ }
+ else
+ {
+ foreach (var info in infos)
+ {
+ Console.WriteLine(info.FileName);
+ Console.WriteLine($" name {info.Name}");
+ Console.WriteLine($" type {info.ObjectType}");
+ Console.WriteLine($" source {info.ObjectSource}");
+ Console.WriteLine($" encoding {info.Encoding}");
+ Console.WriteLine($" checksum 0x{info.Checksum:X8}");
+ Console.WriteLine($" data length {info.DataLength}");
+ Console.WriteLine($" images {info.ImageCount} in {info.ImageGroupCount} group(s)");
+ Console.WriteLine($" strings {info.StringCount}");
+ }
+ }
+
+ return Task.FromResult(failed == 0 ? ExitCodes.Success : ExitCodes.OperationFailed);
+ }
+}
diff --git a/Cli/Commands/OffsetsCommand.cs b/Cli/Commands/OffsetsCommand.cs
new file mode 100644
index 00000000..ba32eb25
--- /dev/null
+++ b/Cli/Commands/OffsetsCommand.cs
@@ -0,0 +1,91 @@
+using Core.Operations;
+using Microsoft.Extensions.Logging;
+
+namespace Cli.Commands;
+
+public sealed class OffsetsCommand : ICommand
+{
+ public string Name
+ => "offsets";
+
+ public string Summary
+ => "Bulk-edit the x/y offsets of every image in an object";
+
+ public string Usage
+ => "locoobj offsets (--zero | --center | --translate ) [--out ] [--encoding ] [--dry-run] [--no-recurse] [--allow-vanilla]";
+
+ public IReadOnlySet Options { get; } = new HashSet(CommandContext.CommonOptions, StringComparer.OrdinalIgnoreCase)
+ {
+ "zero", "center", "translate",
+ };
+
+ public IReadOnlySet Flags { get; } = new HashSet(CommandContext.CommonFlags, StringComparer.OrdinalIgnoreCase)
+ {
+ "zero", "center",
+ };
+
+ public Task RunAsync(CommandContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ var zero = context.Args.Has("zero");
+ var center = context.Args.Has("center");
+ var translate = context.Args.GetString("translate");
+
+ var modeCount = (zero ? 1 : 0) + (center ? 1 : 0) + (translate != null ? 1 : 0);
+ if (modeCount != 1)
+ {
+ context.Logger.LogError("Exactly one of --zero, --center or --translate must be given");
+ return Task.FromResult(ExitCodes.UsageError);
+ }
+
+ short deltaX = 0;
+ short deltaY = 0;
+
+ if (translate != null && !TryParseDelta(translate, out deltaX, out deltaY))
+ {
+ context.Logger.LogError("--translate expects two comma-separated whole numbers, for example --translate 4,-2");
+ return Task.FromResult(ExitCodes.UsageError);
+ }
+
+ if (!context.TryResolveInputs(out var files, out var inputRoot))
+ {
+ return Task.FromResult(ExitCodes.UsageError);
+ }
+
+ if (!context.TryBuildBatchOptions(inputRoot, out var options))
+ {
+ return Task.FromResult(ExitCodes.UsageError);
+ }
+
+ var result = BatchProcessor.Run(
+ files,
+ file =>
+ {
+ var count = zero
+ ? ObjectOperations.ZeroAllOffsets(file.LocoObject)
+ : center
+ ? ObjectOperations.CenterAllOffsets(file.LocoObject)
+ : ObjectOperations.TranslateAllOffsets(file.LocoObject, deltaX, deltaY);
+
+ return count == 0
+ ? OperationOutcome.Unchanged("no images")
+ : OperationOutcome.Changed($"updated offsets on {count} image(s)");
+ },
+ options,
+ context.Logger);
+
+ return Task.FromResult(context.Report(result));
+ }
+
+ static bool TryParseDelta(string value, out short deltaX, out short deltaY)
+ {
+ deltaX = 0;
+ deltaY = 0;
+
+ var parts = value.Split(',', StringSplitOptions.TrimEntries);
+ return parts.Length == 2
+ && short.TryParse(parts[0], out deltaX)
+ && short.TryParse(parts[1], out deltaY);
+ }
+}
diff --git a/Cli/Commands/ReencodeCommand.cs b/Cli/Commands/ReencodeCommand.cs
new file mode 100644
index 00000000..ff73da00
--- /dev/null
+++ b/Cli/Commands/ReencodeCommand.cs
@@ -0,0 +1,53 @@
+using Core.Operations;
+using Microsoft.Extensions.Logging;
+
+namespace Cli.Commands;
+
+public sealed class ReencodeCommand : ICommand
+{
+ public string Name
+ => "reencode";
+
+ public string Summary
+ => "Rewrite objects using a different Sawyer encoding";
+
+ public string Usage
+ => "locoobj reencode --encoding [--out ] [--dry-run] [--no-recurse] [--allow-vanilla]";
+
+ public IReadOnlySet Options
+ => CommandContext.CommonOptions;
+
+ public IReadOnlySet Flags
+ => CommandContext.CommonFlags;
+
+ public Task RunAsync(CommandContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ if (context.Args.GetString("encoding") == null)
+ {
+ context.Logger.LogError("--encoding is required");
+ return Task.FromResult(ExitCodes.UsageError);
+ }
+
+ if (!context.TryResolveInputs(out var files, out var inputRoot))
+ {
+ return Task.FromResult(ExitCodes.UsageError);
+ }
+
+ if (!context.TryBuildBatchOptions(inputRoot, out var options))
+ {
+ return Task.FromResult(ExitCodes.UsageError);
+ }
+
+ var result = BatchProcessor.Run(
+ files,
+ file => file.DatInfo.ObjectHeader.Encoding == options.Encoding
+ ? OperationOutcome.Unchanged($"already {options.Encoding}")
+ : OperationOutcome.Changed($"{file.DatInfo.ObjectHeader.Encoding} -> {options.Encoding}"),
+ options,
+ context.Logger);
+
+ return Task.FromResult(context.Report(result));
+ }
+}
diff --git a/Cli/Commands/StripImagesCommand.cs b/Cli/Commands/StripImagesCommand.cs
new file mode 100644
index 00000000..53d620c4
--- /dev/null
+++ b/Cli/Commands/StripImagesCommand.cs
@@ -0,0 +1,50 @@
+using Core.Operations;
+
+namespace Cli.Commands;
+
+public sealed class StripImagesCommand : ICommand
+{
+ public string Name
+ => "strip-images";
+
+ public string Summary
+ => "Remove every image from an object's image table";
+
+ public string Usage
+ => "locoobj strip-images [--out ] [--encoding ] [--dry-run] [--no-recurse] [--allow-vanilla]";
+
+ public IReadOnlySet Options
+ => CommandContext.CommonOptions;
+
+ public IReadOnlySet Flags
+ => CommandContext.CommonFlags;
+
+ public Task RunAsync(CommandContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ if (!context.TryResolveInputs(out var files, out var inputRoot))
+ {
+ return Task.FromResult(ExitCodes.UsageError);
+ }
+
+ if (!context.TryBuildBatchOptions(inputRoot, out var options))
+ {
+ return Task.FromResult(ExitCodes.UsageError);
+ }
+
+ var result = BatchProcessor.Run(
+ files,
+ file =>
+ {
+ var removed = ObjectOperations.StripImages(file.LocoObject);
+ return removed == 0
+ ? OperationOutcome.Unchanged("no images to strip")
+ : OperationOutcome.Changed($"stripped {removed} image(s)");
+ },
+ options,
+ context.Logger);
+
+ return Task.FromResult(context.Report(result));
+ }
+}
diff --git a/Cli/Commands/ValidateCommand.cs b/Cli/Commands/ValidateCommand.cs
new file mode 100644
index 00000000..40b84caf
--- /dev/null
+++ b/Cli/Commands/ValidateCommand.cs
@@ -0,0 +1,73 @@
+using Core.Objects;
+using Core.Validation;
+
+namespace Cli.Commands;
+
+public sealed class ValidateCommand : ICommand
+{
+ public string Name
+ => "validate";
+
+ public string Summary
+ => "Validate objects, optionally against the OpenGraphics ruleset";
+
+ public string Usage
+ => "locoobj validate [--og] [--no-recurse]";
+
+ public IReadOnlySet Options { get; } = new HashSet(CommandContext.CommonOptions, StringComparer.OrdinalIgnoreCase)
+ {
+ "og",
+ };
+
+ public IReadOnlySet Flags { get; } = new HashSet(CommandContext.CommonFlags, StringComparer.OrdinalIgnoreCase)
+ {
+ "og",
+ };
+
+ public Task RunAsync(CommandContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ if (!context.TryResolveInputs(out var files, out _))
+ {
+ return Task.FromResult(ExitCodes.UsageError);
+ }
+
+ var includeOg = context.Args.Has("og");
+ var failed = 0;
+
+ foreach (var fileName in files)
+ {
+ var file = ObjectFile.Load(fileName, context.Logger);
+ if (file == null)
+ {
+ Console.WriteLine($"FAIL {fileName}: failed to load");
+ failed++;
+ continue;
+ }
+
+ var errors = ObjectValidation.Validate(file);
+
+ if (includeOg)
+ {
+ errors.AddRange(ObjectValidation.ValidateForOG(file, context.Logger));
+ }
+
+ if (errors.Count == 0)
+ {
+ Console.WriteLine($"ok {fileName}");
+ continue;
+ }
+
+ failed++;
+ Console.WriteLine($"FAIL {fileName}: {errors.Count} issue(s)");
+ foreach (var error in errors)
+ {
+ Console.WriteLine($" {error}");
+ }
+ }
+
+ Console.WriteLine($"{files.Count - failed} passed, {failed} failed");
+ return Task.FromResult(failed == 0 ? ExitCodes.Success : ExitCodes.ValidationFailed);
+ }
+}
diff --git a/Cli/ConsoleLogger.cs b/Cli/ConsoleLogger.cs
new file mode 100644
index 00000000..5cda6ec0
--- /dev/null
+++ b/Cli/ConsoleLogger.cs
@@ -0,0 +1,44 @@
+using Microsoft.Extensions.Logging;
+
+namespace Cli;
+
+public sealed class ConsoleLogger(LogLevel minLevel) : ILogger
+{
+ public LogLevel MinLevel { get; set; } = minLevel;
+
+ public IDisposable? BeginScope(TState state) where TState : notnull
+ => null;
+
+ public bool IsEnabled(LogLevel logLevel)
+ => logLevel != LogLevel.None && logLevel >= MinLevel;
+
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)
+ {
+ ArgumentNullException.ThrowIfNull(formatter);
+
+ if (!IsEnabled(logLevel))
+ {
+ return;
+ }
+
+ var message = formatter(state, exception);
+ if (exception != null)
+ {
+ message = $"{message} - {exception.Message}";
+ }
+
+ Console.Error.WriteLine($"{Prefix(logLevel)} {message}");
+ }
+
+ static string Prefix(LogLevel level)
+ => level switch
+ {
+ LogLevel.Trace => "trce:",
+ LogLevel.Debug => "dbug:",
+ LogLevel.Information => "info:",
+ LogLevel.Warning => "warn:",
+ LogLevel.Error => "fail:",
+ LogLevel.Critical => "crit:",
+ _ => " ",
+ };
+}
diff --git a/Cli/ExitCodes.cs b/Cli/ExitCodes.cs
new file mode 100644
index 00000000..ae06df85
--- /dev/null
+++ b/Cli/ExitCodes.cs
@@ -0,0 +1,9 @@
+namespace Cli;
+
+public static class ExitCodes
+{
+ public const int Success = 0;
+ public const int UsageError = 1;
+ public const int OperationFailed = 2;
+ public const int ValidationFailed = 3;
+}
diff --git a/Cli/ICommand.cs b/Cli/ICommand.cs
new file mode 100644
index 00000000..fb4dfa56
--- /dev/null
+++ b/Cli/ICommand.cs
@@ -0,0 +1,16 @@
+namespace Cli;
+
+public interface ICommand
+{
+ string Name { get; }
+
+ string Summary { get; }
+
+ string Usage { get; }
+
+ IReadOnlySet Options { get; }
+
+ IReadOnlySet Flags { get; }
+
+ Task RunAsync(CommandContext context);
+}
diff --git a/Cli/Program.cs b/Cli/Program.cs
new file mode 100644
index 00000000..2fc12b26
--- /dev/null
+++ b/Cli/Program.cs
@@ -0,0 +1,94 @@
+using Cli;
+using Cli.Commands;
+using Core;
+using Microsoft.Extensions.Logging;
+
+ICommand[] commands =
+[
+ new StripImagesCommand(),
+ new ExportImagesCommand(),
+ new ImportImagesCommand(),
+ new CropCommand(),
+ new OffsetsCommand(),
+ new ReencodeCommand(),
+ new ValidateCommand(),
+ new InfoCommand(),
+];
+
+if (args.Length == 0 || args[0] is "-h" or "--help" or "help")
+{
+ PrintHelp(commands);
+ return ExitCodes.Success;
+}
+
+var command = commands.FirstOrDefault(x => string.Equals(x.Name, args[0], StringComparison.OrdinalIgnoreCase));
+
+if (command == null)
+{
+ Console.Error.WriteLine($"Unknown command \"{args[0]}\"");
+ PrintHelp(commands);
+ return ExitCodes.UsageError;
+}
+
+var commandArgs = args[1..];
+var flags = new HashSet(command.Flags, StringComparer.OrdinalIgnoreCase);
+var commandLine = CommandLine.Parse(commandArgs, flags);
+
+if (commandLine.Has("help"))
+{
+ Console.WriteLine(command.Summary);
+ Console.WriteLine();
+ Console.WriteLine(command.Usage);
+ return ExitCodes.Success;
+}
+
+var minLevel = commandLine.Has("verbose")
+ ? LogLevel.Debug
+ : commandLine.Has("quiet") ? LogLevel.Error : LogLevel.Information;
+
+var logger = new ConsoleLogger(minLevel);
+var context = new CommandContext(commandLine, logger);
+
+if (!context.ValidateOptions(command.Options))
+{
+ Console.Error.WriteLine(command.Usage);
+ return ExitCodes.UsageError;
+}
+
+await ImageTableGroupsConfig.LoadDefaultAsync(logger);
+
+try
+{
+ return await command.RunAsync(context);
+}
+catch (Exception ex)
+{
+ logger.LogError(ex, "Unhandled error running \"{Command}\"", command.Name);
+ return ExitCodes.OperationFailed;
+}
+
+static void PrintHelp(IEnumerable commands)
+{
+ Console.WriteLine("locoobj - headless OpenLoco object tools");
+ Console.WriteLine();
+ Console.WriteLine("Usage: locoobj [arguments]");
+ Console.WriteLine();
+ Console.WriteLine("Commands:");
+
+ foreach (var command in commands)
+ {
+ Console.WriteLine($" {command.Name,-14} {command.Summary}");
+ }
+
+ Console.WriteLine();
+ Console.WriteLine("Common options:");
+ Console.WriteLine(" --out write results here instead of overwriting the input");
+ Console.WriteLine(" --encoding Uncompressed | RunLengthSingle | RunLengthMulti | Rotate");
+ Console.WriteLine(" --palette use a custom 16x16 palette instead of the built-in one");
+ Console.WriteLine(" --dry-run report what would change without writing anything");
+ Console.WriteLine(" --no-recurse do not descend into subdirectories");
+ Console.WriteLine(" --allow-vanilla permit writing objects with a vanilla object source");
+ Console.WriteLine(" --verbose/--quiet raise or lower log verbosity");
+ Console.WriteLine();
+ Console.WriteLine("Run 'locoobj --help' for command-specific usage.");
+}
diff --git a/Gui/Assets/palette.png b/Core/Assets/palette.png
similarity index 100%
rename from Gui/Assets/palette.png
rename to Core/Assets/palette.png
diff --git a/Core/Core.csproj b/Core/Core.csproj
new file mode 100644
index 00000000..b8ac95ea
--- /dev/null
+++ b/Core/Core.csproj
@@ -0,0 +1,40 @@
+
+
+
+ Library
+ net10.0
+ preview
+ enable
+ enable
+ latest
+ Core
+
+
+
+
+
+
+
+
+
+ Core.palette.png
+
+
+
+
+
+ Core.ImageTableGroups.json
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Gui/ViewModels/Graphics/GraphicsElementJson.cs b/Core/Graphics/GraphicsElementJson.cs
similarity index 96%
rename from Gui/ViewModels/Graphics/GraphicsElementJson.cs
rename to Core/Graphics/GraphicsElementJson.cs
index 67f33b45..645d59c4 100644
--- a/Gui/ViewModels/Graphics/GraphicsElementJson.cs
+++ b/Core/Graphics/GraphicsElementJson.cs
@@ -1,7 +1,7 @@
using Definitions.ObjectModels.Graphics;
using System.Text.Json.Serialization;
-namespace Gui.ViewModels.Graphics;
+namespace Core.Graphics;
public record GraphicsElementJson(
[property: JsonPropertyName("path")] string Path,
diff --git a/Core/Graphics/GraphicsElementOperations.cs b/Core/Graphics/GraphicsElementOperations.cs
new file mode 100644
index 00000000..bac0744c
--- /dev/null
+++ b/Core/Graphics/GraphicsElementOperations.cs
@@ -0,0 +1,157 @@
+using Definitions.ObjectModels;
+using Definitions.ObjectModels.Graphics;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Processing;
+
+namespace Core.Graphics;
+
+public static class GraphicsElementOperations
+{
+ public static void SetImage(this GraphicsElement element, Image image, PaletteMap paletteMap)
+ {
+ ArgumentNullException.ThrowIfNull(element);
+ ArgumentNullException.ThrowIfNull(image);
+ ArgumentNullException.ThrowIfNull(paletteMap);
+
+ if (!ReferenceEquals(element.Image, image))
+ {
+ element.Image?.Dispose();
+ element.Image = image;
+ }
+
+ element.Width = (short)image.Width;
+ element.Height = (short)image.Height;
+ element.ImageData = paletteMap.ConvertRgba32ImageToG1Data(image, element.Flags);
+ }
+
+ public static void ReplaceImage(this GraphicsElement element, string pngFileName, PaletteMap paletteMap)
+ => element.SetImage(Image.Load(pngFileName), paletteMap);
+
+ public static void SyncImageData(this GraphicsElement element, PaletteMap paletteMap)
+ {
+ ArgumentNullException.ThrowIfNull(element);
+
+ if (element.Image == null)
+ {
+ return;
+ }
+
+ element.SetImage(element.Image, paletteMap);
+ }
+
+ public static void Decode(this GraphicsElement element, PaletteMap paletteMap, ColourSwatch primary = ColourSwatch.PrimaryRemap, ColourSwatch secondary = ColourSwatch.SecondaryRemap)
+ {
+ ArgumentNullException.ThrowIfNull(element);
+ ArgumentNullException.ThrowIfNull(paletteMap);
+
+ element.Image = paletteMap.TryConvertG1ToRgba32Bitmap(element, primary, secondary, out var image)
+ ? image
+ : ImageTableHelpers.ErrorImage;
+ }
+
+ public static void Crop(this GraphicsElement element, PaletteMap paletteMap)
+ {
+ ArgumentNullException.ThrowIfNull(element);
+
+ var image = element.Image;
+ if (image == null)
+ {
+ return;
+ }
+
+ var cropRegion = FindCropRegion(image);
+
+ if (cropRegion.Width <= 0 || cropRegion.Height <= 0)
+ {
+ element.SetImage(image.Clone(i => i.Crop(new Rectangle(0, 0, 1, 1))), paletteMap);
+ element.XOffset = 0;
+ element.YOffset = 0;
+ }
+ else
+ {
+ element.SetImage(image.Clone(i => i.Crop(cropRegion)), paletteMap);
+ element.XOffset += (short)cropRegion.Left;
+ element.YOffset += (short)cropRegion.Top;
+ }
+ }
+
+ public static void ZeroOffsets(this GraphicsElement element)
+ {
+ ArgumentNullException.ThrowIfNull(element);
+
+ element.XOffset = 0;
+ element.YOffset = 0;
+ }
+
+ public static void CenterOffsets(this GraphicsElement element)
+ {
+ ArgumentNullException.ThrowIfNull(element);
+
+ element.XOffset = (short)(-element.Width / 2);
+ element.YOffset = (short)(-element.Height / 2);
+ }
+
+ public static void TranslateOffsets(this GraphicsElement element, short deltaX, short deltaY)
+ {
+ ArgumentNullException.ThrowIfNull(element);
+
+ element.XOffset += deltaX;
+ element.YOffset += deltaY;
+ }
+
+ public static Rectangle FindCropRegion(Image image)
+ {
+ ArgumentNullException.ThrowIfNull(image);
+
+ var minX = image.Width;
+ var maxX = 0;
+ var minY = image.Height;
+ var maxY = 0;
+
+ for (var y = 0; y < image.Height; y++)
+ {
+ for (var x = 0; x < image.Width; x++)
+ {
+ var pixel = image[x, y];
+
+ if (pixel.A > 0)
+ {
+ minX = Math.Min(minX, x);
+ maxX = Math.Max(maxX, x);
+ minY = Math.Min(minY, y);
+ maxY = Math.Max(maxY, y);
+ }
+ }
+ }
+
+ // Calculate the crop area. Ensure it is within image bounds.
+ var width = Math.Max(0, Math.Min(maxX - minX + 1, image.Width - minX));
+ var height = Math.Max(0, Math.Min(maxY - minY + 1, image.Height - minY));
+ return new Rectangle(minX, minY, width, height);
+ }
+
+ public static GraphicsElement FromImage(GraphicsElementJson json, Image image, PaletteMap paletteMap, int index)
+ {
+ ArgumentNullException.ThrowIfNull(json);
+
+ var flags = json.Flags ?? GraphicsElementFlags.None;
+ var element = new GraphicsElement()
+ {
+ Width = (int16_t)image.Width,
+ Height = (int16_t)image.Height,
+ XOffset = json.XOffset,
+ YOffset = json.YOffset,
+ Flags = flags,
+ ZoomOffset = json.ZoomOffset ?? 0,
+ ImageData = paletteMap.ConvertRgba32ImageToG1Data(image, flags),
+ Name = json.Name ?? string.Empty,
+ Image = image,
+ ImageTableIndex = index,
+ };
+
+ element.Decode(paletteMap);
+
+ return element;
+ }
+}
diff --git a/Core/Graphics/ImageTableIo.cs b/Core/Graphics/ImageTableIo.cs
new file mode 100644
index 00000000..46318a60
--- /dev/null
+++ b/Core/Graphics/ImageTableIo.cs
@@ -0,0 +1,197 @@
+using Common.Json;
+using Definitions.ObjectModels;
+using Definitions.ObjectModels.Graphics;
+using Definitions.ObjectModels.Types;
+using Microsoft.Extensions.Logging;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace Core.Graphics;
+
+public static class ImageTableIo
+{
+ public const string SpritesFileName = "sprites.json";
+
+ public static async Task ExportAsync(ImageTable imageTable, string directory, bool prependGroupAndImageNameInFilename, ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(imageTable);
+ ArgumentNullException.ThrowIfNull(logger);
+
+ if (string.IsNullOrEmpty(directory))
+ {
+ logger.LogError("Directory is invalid: \"{Directory}\"", directory);
+ return 0;
+ }
+
+ _ = Directory.CreateDirectory(directory);
+
+ logger.LogInformation("Exporting images to {Directory}", directory);
+
+ var offsets = new List();
+ var invalidChars = Path.GetInvalidFileNameChars();
+
+ foreach (var item in imageTable.Groups
+ .SelectMany(group => group.GraphicsElements, (group, element) => new { group.Name, Element = element })
+ .OrderBy(x => x.Element.ImageTableIndex))
+ {
+ var element = item.Element;
+
+ var fileName = $"{element.ImageTableIndex}.png";
+ if (prependGroupAndImageNameInFilename)
+ {
+ var imageName = Sanitize(element.Name, invalidChars);
+ var groupName = Sanitize(item.Name, invalidChars);
+
+ if (!string.IsNullOrEmpty(groupName) && !string.IsNullOrEmpty(imageName))
+ {
+ fileName = $"{groupName}_{imageName}.png";
+ }
+ }
+
+ if (element.Image == null)
+ {
+ logger.LogWarning("Image[{Index}] has no decoded image and will be skipped", element.ImageTableIndex);
+ continue;
+ }
+
+ await element.Image.SaveAsPngAsync(Path.Combine(directory, fileName));
+ offsets.Add(new GraphicsElementJson(fileName, element));
+ }
+
+ var offsetsFile = Path.Combine(directory, SpritesFileName);
+ logger.LogInformation("Saving sprite offsets to {OffsetsFile}", offsetsFile);
+ await JsonFile.SerializeToFileAsync(offsets, offsetsFile);
+
+ return offsets.Count;
+
+ static string Sanitize(string value, char[] invalidChars)
+ => new string([.. value.ToLower().Replace(' ', '-').Where(x => !invalidChars.Contains(x))]).Trim();
+ }
+
+ public static async Task?> LoadSpritesJsonAsync(string filename, ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(logger);
+
+ if (!File.Exists(filename))
+ {
+ return null;
+ }
+
+ var offsets = await JsonFile.DeserializeFromFileAsync>(filename);
+ logger.LogDebug("Found sprites.json file with {Count} images", offsets?.Count ?? 0);
+ return offsets;
+ }
+
+ public static async Task?> LoadImagesAsync(string directory, PaletteMap paletteMap, ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(logger);
+
+ if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory))
+ {
+ logger.LogError("Directory does not exist: \"{Directory}\"", directory);
+ return null;
+ }
+
+ var spritesFile = Path.Combine(directory, SpritesFileName);
+ var sprites = await LoadSpritesJsonAsync(spritesFile, logger);
+
+ if (sprites == null || sprites.Count == 0)
+ {
+ logger.LogError("No sprites.json found or file is empty in {Directory}. Import aborted.", directory);
+ return null;
+ }
+
+ var importedImages = new List();
+ foreach (var (sprite, i) in sprites.Select((x, i) => (x, i)))
+ {
+ var is1Pixel = string.IsNullOrEmpty(sprite.Path);
+ var img = is1Pixel
+ ? ImageTableHelpers.OnePixelTransparent
+ : Image.Load(Path.Combine(directory, sprite.Path));
+
+ var effectiveSprite = is1Pixel
+ ? sprite with { Flags = GraphicsElementFlags.HasTransparency }
+ : sprite;
+
+ var graphicsElement = GraphicsElementOperations.FromImage(effectiveSprite, img, paletteMap, i);
+ graphicsElement.Name = string.IsNullOrEmpty(graphicsElement.Name)
+ ? DefaultImageTableNameProvider.GetImageName(i)
+ : graphicsElement.Name;
+
+ importedImages.Add(graphicsElement);
+ }
+
+ return importedImages;
+ }
+
+ public static async Task ImportAsync(ImageTable imageTable, string directory, PaletteMap paletteMap, ILogger logger, ILocoStruct? objectModel = null, ObjectType? objectType = null)
+ {
+ ArgumentNullException.ThrowIfNull(imageTable);
+
+ logger.LogInformation("Importing images from {Directory}", directory);
+
+ var importedImages = await LoadImagesAsync(directory, paletteMap, logger);
+ if (importedImages == null)
+ {
+ return 0;
+ }
+
+ imageTable.Groups.Clear();
+ imageTable.Groups.Add(new ImageTableGroup("", importedImages));
+
+ Regroup(imageTable, logger, objectModel, objectType);
+
+ return importedImages.Count;
+ }
+
+ public static void Regroup(ImageTable imageTable, ILogger logger, ILocoStruct? objectModel, ObjectType? objectType)
+ {
+ ArgumentNullException.ThrowIfNull(imageTable);
+
+ if (objectModel == null || !objectType.HasValue)
+ {
+ return;
+ }
+
+ var imageList = imageTable.GraphicsElements;
+
+ try
+ {
+ imageTable.Groups = [.. ImageTableGrouper.CreateGroupsForExistingImages(objectModel, objectType.Value, imageList)];
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to regroup the image table - images will remain in a single flat group");
+ }
+ }
+
+ public static async Task ApplyOffsetsAsync(ImageTable imageTable, string spritesJsonFileName, ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(imageTable);
+
+ var offsets = await LoadSpritesJsonAsync(spritesJsonFileName, logger);
+ if (offsets == null)
+ {
+ logger.LogError("Failed to load offsets from {Filename}", spritesJsonFileName);
+ return 0;
+ }
+
+ var elements = imageTable.GraphicsElements;
+ var applied = 0;
+
+ foreach (var (offset, i) in offsets.Select((o, index) => (o, index)))
+ {
+ if (elements.Count <= i)
+ {
+ logger.LogError("Offset for Image[{Index}] is provided in the sprites.json file, but only {Count} images are available in the current image table. This offset will be skipped.", i, elements.Count);
+ continue;
+ }
+
+ elements[i].XOffset = offset.XOffset;
+ elements[i].YOffset = offset.YOffset;
+ applied++;
+ }
+
+ return applied;
+ }
+}
diff --git a/Core/ImageTableGroupsConfig.cs b/Core/ImageTableGroupsConfig.cs
new file mode 100644
index 00000000..f7bacc4f
--- /dev/null
+++ b/Core/ImageTableGroupsConfig.cs
@@ -0,0 +1,77 @@
+using Common;
+using Definitions.ObjectModels.Graphics;
+using Microsoft.Extensions.Logging;
+using System.Reflection;
+
+namespace Core;
+
+public static class ImageTableGroupsConfig
+{
+ public const string FileName = "imageTableGroups.json";
+ public const string EmbeddedResourceName = "Core.ImageTableGroups.json";
+
+ public static async Task ReadDefaultAsync(ILogger logger)
+ {
+ try
+ {
+ await using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(EmbeddedResourceName);
+ if (stream == null)
+ {
+ logger.LogError("Default image table group configuration resource not found");
+ return null;
+ }
+
+ using var reader = new StreamReader(stream, leaveOpen: true);
+ return await reader.ReadToEndAsync();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to read the default image table group config");
+ return null;
+ }
+ }
+
+ public static async Task LoadDefaultAsync(ILogger logger)
+ {
+ var json = await ReadDefaultAsync(logger);
+ if (json == null)
+ {
+ return;
+ }
+
+ ImageTableGrouper.LoadGroupConfigurationJson(logger, json);
+ }
+
+ public static async Task EnsureOnDiskAndLoadAsync(Common.Logging.Logger logger, string pathName)
+ {
+ logger.LogInformation("Attempting to load image table group config from '{ImageTableGroupsFileName}'", pathName);
+
+ var defaultImageTableGroups = await ReadDefaultAsync(logger);
+ if (defaultImageTableGroups == null)
+ {
+ logger.LogError("Failed to load default image table group configuration - groups will not be automatically created for existing images. Please ensure the default config file is present and valid at '{ImageTableGroupsFileName}'", pathName);
+ return;
+ }
+
+ var currentImageTableGroups = defaultImageTableGroups;
+
+ if (File.Exists(pathName))
+ {
+ var jsonVersion = ImageTableGrouper.ReadImageTableGroupVersion(logger, pathName);
+ if (jsonVersion == null || jsonVersion < VersionHelpers.GetCurrentAppVersion())
+ {
+ currentImageTableGroups = defaultImageTableGroups;
+ }
+ else
+ {
+ await File.WriteAllTextAsync(pathName, defaultImageTableGroups);
+ }
+ }
+ else
+ {
+ await File.WriteAllTextAsync(pathName, defaultImageTableGroups);
+ }
+
+ ImageTableGrouper.LoadGroupConfigurationJson(logger, currentImageTableGroups);
+ }
+}
diff --git a/Core/Objects/LocoObjectFile.cs b/Core/Objects/LocoObjectFile.cs
new file mode 100644
index 00000000..4243f86e
--- /dev/null
+++ b/Core/Objects/LocoObjectFile.cs
@@ -0,0 +1,6 @@
+using Dat.Types;
+using Definitions.ObjectModels;
+
+namespace Core.Objects;
+
+public sealed record LocoObjectFile(string FileName, DatHeaderInfo DatInfo, LocoObject LocoObject);
diff --git a/Core/Objects/ObjectFile.cs b/Core/Objects/ObjectFile.cs
new file mode 100644
index 00000000..742c65ca
--- /dev/null
+++ b/Core/Objects/ObjectFile.cs
@@ -0,0 +1,127 @@
+using Dat.Converters;
+using Dat.Data;
+using Dat.FileParsing;
+using Definitions.ObjectModels;
+using Definitions.ObjectModels.Types;
+using Microsoft.Extensions.Logging;
+using System.Text.Json;
+
+namespace Core.Objects;
+
+public static class ObjectFile
+{
+ static readonly JsonSerializerOptions jsonOptions = new()
+ {
+ WriteIndented = true,
+ };
+
+ public static LocoObjectFile? Load(string fileName, ILogger logger, PaletteMap? paletteMap = null, bool loadExtra = true)
+ {
+ ArgumentNullException.ThrowIfNull(logger);
+
+ if (string.IsNullOrEmpty(fileName) || !File.Exists(fileName))
+ {
+ logger.LogError("File does not exist: \"{FileName}\"", fileName);
+ return null;
+ }
+
+ var (datInfo, locoObject) = SawyerStreamReader.LoadFullObject(fileName, logger, loadExtra);
+
+ if (locoObject == null)
+ {
+ logger.LogError("Unable to load a LocoObject from \"{FileName}\"", fileName);
+ return null;
+ }
+
+ if (paletteMap != null && locoObject.ImageTable != null)
+ {
+ locoObject.ImageTable.PaletteMap = paletteMap;
+ }
+
+ return new LocoObjectFile(fileName, datInfo, locoObject);
+ }
+
+ public static bool SaveDat(LocoObjectFile file, string fileName, ILogger logger, SawyerEncoding? encoding = null, string? objectName = null, ObjectSource? objectSource = null, bool allowSavingAsVanillaObject = false)
+ {
+ ArgumentNullException.ThrowIfNull(file);
+ ArgumentNullException.ThrowIfNull(logger);
+
+ if (!TryPrepareDirectory(fileName, logger))
+ {
+ return false;
+ }
+
+ var header = file.DatInfo.S5Header;
+
+ SawyerStreamWriter.Save(
+ fileName,
+ objectName ?? header.Name,
+ objectSource ?? header.ObjectSource.Convert(header.Name, header.Checksum),
+ encoding ?? file.DatInfo.ObjectHeader.Encoding,
+ file.LocoObject,
+ logger,
+ allowSavingAsVanillaObject);
+
+ return true;
+ }
+
+ public static bool SaveJson(LocoObjectFile file, string fileName, ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(file);
+ ArgumentNullException.ThrowIfNull(logger);
+
+ if (!TryPrepareDirectory(fileName, logger))
+ {
+ return false;
+ }
+
+ using var stream = new FileStream(fileName, FileMode.Create, FileAccess.Write);
+ JsonSerializer.Serialize(stream, file.LocoObject, jsonOptions);
+
+ logger.LogInformation("{ObjName} successfully saved to {Filename}", file.DatInfo.S5Header.Name, fileName);
+ return true;
+ }
+
+ public static IReadOnlyList EnumerateDatFiles(string path, bool recursive = true)
+ {
+ if (File.Exists(path))
+ {
+ return [path];
+ }
+
+ if (!Directory.Exists(path))
+ {
+ return [];
+ }
+
+ return [.. Directory
+ .EnumerateFiles(path, "*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly)
+ .Where(x => Path.GetExtension(x).Equals(".dat", StringComparison.OrdinalIgnoreCase))
+ .Order()];
+ }
+
+ static bool TryPrepareDirectory(string fileName, ILogger logger)
+ {
+ if (string.IsNullOrEmpty(fileName))
+ {
+ logger.LogError("Cannot save - filename was empty");
+ return false;
+ }
+
+ var saveDir = Path.GetDirectoryName(fileName);
+
+ if (string.IsNullOrEmpty(saveDir))
+ {
+ logger.LogError("Cannot save - directory is null or empty");
+ return false;
+ }
+
+ if (!Directory.Exists(saveDir))
+ {
+ logger.LogError("Cannot save - directory does not exist: \"{SaveDir}\"", saveDir);
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/Core/Operations/BatchProcessor.cs b/Core/Operations/BatchProcessor.cs
new file mode 100644
index 00000000..d28506cb
--- /dev/null
+++ b/Core/Operations/BatchProcessor.cs
@@ -0,0 +1,118 @@
+using Core.Objects;
+using Dat.Data;
+using Definitions.ObjectModels;
+using Microsoft.Extensions.Logging;
+
+namespace Core.Operations;
+
+public sealed record BatchItemResult(string FileName, bool Succeeded, string Message);
+
+public sealed record BatchResult(IReadOnlyList Items)
+{
+ public int SucceededCount
+ => Items.Count(x => x.Succeeded);
+
+ public int FailedCount
+ => Items.Count(x => !x.Succeeded);
+}
+
+public sealed record OperationOutcome(bool Modified, string Message)
+{
+ public static OperationOutcome Unchanged(string message)
+ => new(false, message);
+
+ public static OperationOutcome Changed(string message)
+ => new(true, message);
+}
+
+public sealed record BatchOptions
+{
+ public string? OutputDirectory { get; init; }
+
+ public string? InputRoot { get; init; }
+
+ public SawyerEncoding? Encoding { get; init; }
+
+ public bool AllowSavingAsVanillaObject { get; init; }
+
+ public bool DryRun { get; init; }
+
+ public PaletteMap? PaletteMap { get; init; }
+}
+
+public static class BatchProcessor
+{
+ public static BatchResult Run(IEnumerable fileNames, Func operation, BatchOptions options, ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(fileNames);
+ ArgumentNullException.ThrowIfNull(operation);
+ ArgumentNullException.ThrowIfNull(options);
+ ArgumentNullException.ThrowIfNull(logger);
+
+ var results = new List();
+
+ foreach (var fileName in fileNames)
+ {
+ results.Add(RunOne(fileName, operation, options, logger));
+ }
+
+ return new BatchResult(results);
+ }
+
+ static BatchItemResult RunOne(string fileName, Func operation, BatchOptions options, ILogger logger)
+ {
+ try
+ {
+ var file = ObjectFile.Load(fileName, logger, options.PaletteMap);
+ if (file == null)
+ {
+ return new BatchItemResult(fileName, false, "failed to load");
+ }
+
+ var outcome = operation(file);
+
+ if (!outcome.Modified)
+ {
+ return new BatchItemResult(fileName, true, outcome.Message);
+ }
+
+ var outputFileName = ResolveOutputFileName(fileName, options);
+
+ if (options.DryRun)
+ {
+ return new BatchItemResult(fileName, true, $"{outcome.Message} (dry run, would write \"{outputFileName}\")");
+ }
+
+ var outputDir = Path.GetDirectoryName(outputFileName);
+ if (!string.IsNullOrEmpty(outputDir))
+ {
+ _ = Directory.CreateDirectory(outputDir);
+ }
+
+ return ObjectFile.SaveDat(file, outputFileName, logger, options.Encoding, allowSavingAsVanillaObject: options.AllowSavingAsVanillaObject)
+ ? new BatchItemResult(fileName, true, outcome.Message)
+ : new BatchItemResult(fileName, false, "failed to save");
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Unhandled error processing \"{FileName}\"", fileName);
+ return new BatchItemResult(fileName, false, ex.Message);
+ }
+ }
+
+ public static string ResolveOutputFileName(string inputFileName, BatchOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ if (string.IsNullOrEmpty(options.OutputDirectory))
+ {
+ return inputFileName;
+ }
+
+ var relative = string.IsNullOrEmpty(options.InputRoot)
+ ? Path.GetFileName(inputFileName)
+ : Path.GetRelativePath(options.InputRoot, inputFileName);
+
+ return Path.Combine(options.OutputDirectory, relative);
+ }
+}
diff --git a/Core/Operations/ObjectOperations.cs b/Core/Operations/ObjectOperations.cs
new file mode 100644
index 00000000..c15d5603
--- /dev/null
+++ b/Core/Operations/ObjectOperations.cs
@@ -0,0 +1,66 @@
+using Core.Graphics;
+using Core.Objects;
+using Definitions.ObjectModels;
+using Definitions.ObjectModels.Graphics;
+
+namespace Core.Operations;
+
+public static class ObjectOperations
+{
+ public static int StripImages(LocoObject locoObject)
+ {
+ ArgumentNullException.ThrowIfNull(locoObject);
+
+ var imageTable = locoObject.ImageTable;
+ if (imageTable == null)
+ {
+ return 0;
+ }
+
+ var removed = imageTable.Groups.Sum(x => x.GraphicsElements.Count);
+
+ foreach (var group in imageTable.Groups)
+ {
+ foreach (var element in group.GraphicsElements)
+ {
+ element.Image?.Dispose();
+ element.Image = null;
+ }
+ }
+
+ imageTable.Groups.Clear();
+
+ return removed;
+ }
+
+ public static int CropAllImages(LocoObject locoObject, PaletteMap paletteMap)
+ => ForEachImage(locoObject, x => x.Crop(paletteMap));
+
+ public static int ZeroAllOffsets(LocoObject locoObject)
+ => ForEachImage(locoObject, x => x.ZeroOffsets());
+
+ public static int CenterAllOffsets(LocoObject locoObject)
+ => ForEachImage(locoObject, x => x.CenterOffsets());
+
+ public static int TranslateAllOffsets(LocoObject locoObject, short deltaX, short deltaY)
+ => ForEachImage(locoObject, x => x.TranslateOffsets(deltaX, deltaY));
+
+ public static int ForEachImage(LocoObject locoObject, Action action)
+ {
+ ArgumentNullException.ThrowIfNull(locoObject);
+ ArgumentNullException.ThrowIfNull(action);
+
+ var elements = locoObject.ImageTable?.GraphicsElements;
+ if (elements == null)
+ {
+ return 0;
+ }
+
+ foreach (var element in elements)
+ {
+ action(element);
+ }
+
+ return elements.Count;
+ }
+}
diff --git a/Core/PaletteMapLoader.cs b/Core/PaletteMapLoader.cs
new file mode 100644
index 00000000..4a1b1416
--- /dev/null
+++ b/Core/PaletteMapLoader.cs
@@ -0,0 +1,27 @@
+using Definitions.ObjectModels;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+using System.Reflection;
+
+namespace Core;
+
+public static class PaletteMapLoader
+{
+ public const string EmbeddedPaletteResourceName = "Core.palette.png";
+
+ public static Image LoadDefaultImage()
+ {
+ using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(EmbeddedPaletteResourceName)
+ ?? throw new InvalidOperationException($"Embedded palette resource \"{EmbeddedPaletteResourceName}\" was not found");
+
+ return Image.Load(stream);
+ }
+
+ public static PaletteMap LoadDefault()
+ => new(LoadDefaultImage());
+
+ public static PaletteMap Load(string? filename)
+ => string.IsNullOrEmpty(filename)
+ ? LoadDefault()
+ : new PaletteMap(filename);
+}
diff --git a/Core/Validation/ObjectValidation.cs b/Core/Validation/ObjectValidation.cs
new file mode 100644
index 00000000..884c26e1
--- /dev/null
+++ b/Core/Validation/ObjectValidation.cs
@@ -0,0 +1,144 @@
+using Core.Objects;
+using Dat.Data;
+using Definitions.ObjectModels;
+using Microsoft.Extensions.Logging;
+using System.ComponentModel.DataAnnotations;
+
+namespace Core.Validation;
+
+public static class ObjectValidation
+{
+ public static List Validate(ILocoStruct? obj)
+ => [.. (obj?.Validate(new ValidationContext(obj)) ?? []).Select(x => x.ToString() ?? string.Empty)];
+
+ public static List Validate(LocoObjectFile file)
+ {
+ ArgumentNullException.ThrowIfNull(file);
+ return Validate(file.LocoObject.Object);
+ }
+
+ public static List ValidateForOG(LocoObjectFile file, ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(file);
+ ArgumentNullException.ThrowIfNull(logger);
+
+ var validationErrors = new List();
+
+ try
+ {
+ var fileName = file.FileName;
+ if (string.IsNullOrEmpty(fileName))
+ {
+ validationErrors.Add("Filename is null or empty");
+ return validationErrors;
+ }
+
+ var currentDir = Path.GetDirectoryName(fileName);
+ if (string.IsNullOrEmpty(currentDir))
+ {
+ validationErrors.Add("Current directory is null or empty");
+ return validationErrors;
+ }
+
+ // reject if .gitkeep file still exists
+ var directoryFiles = Directory.GetFiles(currentDir).Select(x => Path.GetFileName(x)).ToList();
+ if (directoryFiles.Contains(".gitkeep"))
+ {
+ validationErrors.Add("File \".gitkeep\" exists in the current directory");
+ }
+
+ // find common textures directory
+ var textureDirectory = FindDirectoryInParentDirectory(currentDir, "textures")?.FullName;
+ if (string.IsNullOrEmpty(textureDirectory))
+ {
+ validationErrors.Add("Texture directory name is null or empty");
+ }
+ else
+ {
+ // reject if any files are here that existing /textures folder
+ var textureFiles = Directory.GetFiles(textureDirectory).Select(x => Path.GetFileName(x));
+ foreach (var textureFile in textureFiles)
+ {
+ if (directoryFiles.Contains(textureFile))
+ {
+ validationErrors.Add($"File \"{Path.GetFileName(textureFile)}\" exists in both the current directory and the textures directory");
+ }
+ }
+ }
+
+ var header = file.DatInfo.S5Header;
+ var currentDirName = Path.GetFileName(currentDir);
+ if (OriginalObjectFiles.Names.TryGetValue(currentDirName, out var fileInfo))
+ {
+ // DAT name is the expected dat name
+ if (header.Name != fileInfo.OpenGraphicsName)
+ {
+ validationErrors.Add($"✖ Internal DAT header name is not correct. Actual=\"{header.Name}\" Expected=\"{fileInfo.OpenGraphicsName}\" ");
+ }
+ }
+ else
+ {
+ validationErrors.Add($"✖ Unable to find file info for the vanilla file. Name=\"{currentDirName}\".");
+ }
+
+ var expectedFilename = $"OG_{currentDirName}.dat";
+ var actualFilename = Path.GetFileName(fileName);
+ if (expectedFilename != actualFilename)
+ {
+ validationErrors.Add($"✖ Filename not correct. Actual=\"{actualFilename}\" Expected=\"{expectedFilename}\" ");
+ }
+
+ // DAT name is NOT prefixed by OG_
+ if (header.Name.Contains('_'))
+ {
+ validationErrors.Add("✖ Internal header name should not contain an underscore");
+ }
+
+ // DAT name is prefixed by OG
+ if (!header.Name.StartsWith("OG"))
+ {
+ validationErrors.Add("✖ Internal header name is not prefixed with OG");
+ }
+
+ // OpenGraphics object source set
+ if (header.ObjectSource != DatObjectSource.OpenLoco)
+ {
+ validationErrors.Add("✖ Object source is not set to OpenLoco");
+ }
+
+ // if Vehicle - use RunLengthSingle
+ if (header.ObjectType == DatObjectType.Vehicle && file.DatInfo.ObjectHeader.Encoding != SawyerEncoding.RunLengthSingle)
+ {
+ validationErrors.Add("✖ Object is a Vehicle but doesn't have encoding set to RunLengthSingle");
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error validating for OpenGraphics");
+ validationErrors.Add($"Error validating for OpenGraphics: {ex.Message}");
+ }
+
+ return validationErrors;
+ }
+
+ public static DirectoryInfo? FindDirectoryInParentDirectory(string startPath, string targetName)
+ {
+ var current = new DirectoryInfo(startPath);
+
+ while (current != null)
+ {
+ foreach (var dir in current.EnumerateDirectories(targetName, SearchOption.TopDirectoryOnly))
+ {
+ if (string.Equals(dir.Name, targetName, StringComparison.OrdinalIgnoreCase))
+ {
+ return dir;
+ }
+ }
+
+ // Move up to the parent directory
+ current = current.Parent;
+ }
+
+ return null; // Reached root without finding the target directory
+ }
+}
diff --git a/Gui/Gui.csproj b/Gui/Gui.csproj
index 478d9ab7..36985cc2 100644
--- a/Gui/Gui.csproj
+++ b/Gui/Gui.csproj
@@ -43,12 +43,6 @@
-
-
- Gui.ImageTableGroups.json
-
-
-
@@ -74,6 +68,7 @@
+
diff --git a/Gui/Models/ObjectEditorContext.cs b/Gui/Models/ObjectEditorContext.cs
index 3bddcef5..c045c55f 100644
--- a/Gui/Models/ObjectEditorContext.cs
+++ b/Gui/Models/ObjectEditorContext.cs
@@ -1,6 +1,7 @@
using Avalonia.Threading;
using Common;
using Common.Logging;
+using Core;
using Dat.Converters;
using Dat.FileParsing;
using Dat.Types;
@@ -54,7 +55,7 @@ public class ObjectEditorContext : IDisposable, IAsyncDisposable
public const string ApplicationName = "OpenLoco Object Editor";
public const string SettingsFileName = "settings.json"; // "settings-dev.json" for dev, "settings.json" for prod
public const string LoggingFileName = "objectEditor.log";
- public const string ImageTableGroupsFileName = "imageTableGroups.json";
+ public const string ImageTableGroupsFileName = ImageTableGroupsConfig.FileName;
public string DefaultConfigFolder { get; set; } = "config";
public string DefaultDownloadFolder { get; set; } = "downloads";
@@ -169,64 +170,7 @@ string InitialiseDirectory(string folder, string defaultName)
}
public async Task LoadAsync()
- => await EnsureDefaultImageTableGroupsConfigFileAsync(Logger, ImageTableGroupsPathName);
-
- static async Task EnsureDefaultImageTableGroupsConfigFileAsync(Logger logger, string imageTableGroupsPathName)
- {
- logger.LogInformation("Attempting to load image table group config from '{ImageTableGroupsFileName}'", imageTableGroupsPathName);
- var defaultImageTableGroups = await ReadDefaultImageTableGroupsConfigAsync(logger, imageTableGroupsPathName);
- if (defaultImageTableGroups == null)
- {
- logger.LogError("Failed to load default image table group configuration - groups will not be automatically created for existing images. Please ensure the default config file is present and valid at '{ImageTableGroupsFileName}'", imageTableGroupsPathName);
- return;
- }
-
- var currentImageTableGroups = defaultImageTableGroups;
-
- if (File.Exists(imageTableGroupsPathName))
- {
- var jsonVersion = ImageTableGrouper.ReadImageTableGroupVersion(logger, imageTableGroupsPathName);
- if (jsonVersion == null || jsonVersion < VersionHelpers.GetCurrentAppVersion())
- {
- currentImageTableGroups = defaultImageTableGroups;
- }
- else
- {
- await File.WriteAllTextAsync(imageTableGroupsPathName, defaultImageTableGroups);
- }
- }
- else
- {
- await File.WriteAllTextAsync(imageTableGroupsPathName, defaultImageTableGroups);
- }
-
- ImageTableGrouper.LoadGroupConfigurationJson(logger, currentImageTableGroups);
- }
-
- static async Task ReadDefaultImageTableGroupsConfigAsync(Logger logger, string imageTableGroupsFileName)
- {
- try
- {
- var assembly = Assembly.GetExecutingAssembly();
- var currentVersion = VersionHelpers.GetCurrentAppVersion();
- using var assemblyStream = assembly.GetManifestResourceStream("Gui.ImageTableGroups.json");
- if (assemblyStream == null)
- {
- logger.LogError("Default image table group configuration resource not found.");
- return null;
- }
-
- using (var reader = new StreamReader(assemblyStream, leaveOpen: true))
- {
- return await reader.ReadToEndAsync();
- }
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "Failed to create default image table group config file.");
- return null;
- }
- }
+ => await ImageTableGroupsConfig.EnsureOnDiskAndLoadAsync(Logger, ImageTableGroupsPathName);
public bool TryLoadObject(FileSystemItem filesystemItem, out LocoUIObjectModel? uiLocoFile)
{
diff --git a/Gui/ViewModels/Graphics/ImageTableViewModel.cs b/Gui/ViewModels/Graphics/ImageTableViewModel.cs
index 40a09a83..40042e61 100644
--- a/Gui/ViewModels/Graphics/ImageTableViewModel.cs
+++ b/Gui/ViewModels/Graphics/ImageTableViewModel.cs
@@ -1,6 +1,6 @@
using Avalonia.Controls.Selection;
using Avalonia.Threading;
-using Common.Json;
+using Core.Graphics;
using Definitions.ObjectModels;
using Definitions.ObjectModels.Graphics;
using Definitions.ObjectModels.Types;
@@ -458,98 +458,24 @@ public static string TrimZeroes(string str)
async Task ImportSpritesJsonAsync(string filename)
{
- var offsets = await LoadSpritesJsonFileAsync(filename);
- if (offsets == null)
- {
- Logger.LogError("Failed to load offsets from {Filename}", filename);
- return;
- }
-
- var itvms = GroupedImageViewModels.SelectMany(x => x.Images).ToList();
-
- foreach (var (offset, i) in offsets.Select((o, index) => (o, index)))
- {
- if (itvms.Count <= i)
- {
- Logger.LogError("Offset for Image[{Index}] is provided in the sprites.json file, but only {Count} images are available in the current image table. This offset will be skipped.", i, itvms.Count);
- continue;
- }
- var ivm = itvms[i];
- if (ivm == null)
- {
- Logger.LogError("Image[{Index}] is not found in the current image table.", i);
- continue;
- }
-
- ivm.XOffset = offset.XOffset;
- ivm.YOffset = offset.YOffset;
- }
- }
-
- async Task?> LoadSpritesJsonFileAsync(string filename)
- {
- if (!File.Exists(filename))
- {
- return null;
- }
-
- var offsets = await JsonFile.DeserializeFromFileAsync>(filename) ?? null;
- Logger.LogDebug("Found sprites.json file with {Count} images", offsets?.Count ?? 0);
- return offsets;
+ _ = await ImageTableIo.ApplyOffsetsAsync(Model, filename, Logger);
+ RecreateViewModelGroupsFromImageTable(Model);
}
async Task ImportImagesAsync(string directory)
{
- if (string.IsNullOrEmpty(directory))
- {
- Logger.LogError("Directory is invalid: \"{Directory}\"", directory);
- return;
- }
-
- if (!Directory.Exists(directory))
- {
- Logger.LogError("Directory does not exist: \"{Directory}\"", directory);
- return;
- }
-
- Logger.LogInformation("Importing images from {Directory}", directory);
-
// Step 1: Clear selection model
ClearSelectionModel();
try
{
- // Step 2: Load sprites.json file
- var spritesFile = Path.Combine(directory, "sprites.json");
- var sprites = await LoadSpritesJsonFileAsync(spritesFile);
-
- if (sprites == null || sprites.Count == 0)
+ // Step 2+3: Load sprites.json and all the PNG files it references
+ var importedImages = await ImageTableIo.LoadImagesAsync(directory, Model.PaletteMap, Logger);
+ if (importedImages == null)
{
- Logger.LogError("No sprites.json found or file is empty in {Directory}. Import aborted.", directory);
return;
}
- // Step 3: Load all PNG files referenced in sprites.json
- var importedImages = new List();
- foreach (var (sprite, i) in sprites.Select((x, i) => (x, i)))
- {
- var is1Pixel = string.IsNullOrEmpty(sprite.Path);
- var img = is1Pixel
- ? ImageTableHelpers.OnePixelTransparent
- : Image.Load(Path.Combine(directory, sprite.Path));
-
- var effectiveSprite = is1Pixel
- ? sprite with { Flags = GraphicsElementFlags.HasTransparency }
- : sprite;
-
- var graphicsElement = GraphicsElementFromImage(effectiveSprite, img, Model.PaletteMap, i);
- graphicsElement.Name = string.IsNullOrEmpty(graphicsElement.Name)
- ? DefaultImageTableNameProvider.GetImageName(i)
- : graphicsElement.Name;
-
- importedImages.Add(graphicsElement);
- }
-
// Step 4: Clear the existing model image table
Model.Groups.Clear();
@@ -565,78 +491,8 @@ async Task ImportImagesAsync(string directory)
}
}
- static GraphicsElement GraphicsElementFromImage(GraphicsElementJson ele, Image img, PaletteMap paletteMap, int index)
- {
- var flags = ele.Flags ?? GraphicsElementFlags.None;
- var ge = new GraphicsElement()
- {
- Width = (int16_t)img.Width,
- Height = (int16_t)img.Height,
- XOffset = ele.XOffset,
- YOffset = ele.YOffset,
- Flags = flags,
- ZoomOffset = ele.ZoomOffset ?? 0,
- ImageData = paletteMap.ConvertRgba32ImageToG1Data(img, flags),
- Name = ele.Name ?? string.Empty,
- Image = img,
- ImageTableIndex = index,
- };
-
- ge.Image = paletteMap.TryConvertG1ToRgba32Bitmap(ge, ColourSwatch.PrimaryRemap, ColourSwatch.SecondaryRemap, out var convertedImage)
- ? convertedImage
- : ImageTableHelpers.ErrorImage;
-
- return ge;
- }
-
async Task ExportImages(string directory, bool prependGroupAndImageNameInFilename)
- {
- if (string.IsNullOrEmpty(directory))
- {
- Logger.LogError("Directory is invalid: \"{Directory}\"", directory);
- return;
- }
-
- if (!Directory.Exists(directory))
- {
- Logger.LogError("Directory does not exist: \"{Directory}\"", directory);
- return;
- }
-
- Logger.LogInformation("Exporting images to {Directory}", directory);
-
- var offsets = new List();
-
- var invalidChars = Path.GetInvalidFileNameChars();
-
- foreach (var item in GroupedImageViewModels
- .SelectMany(group => group.Images, (group, image) => new { group.GroupName, Image = image })
- .OrderBy(x => x.Image.ImageTableIndex))
- {
- var image = item.Image;
-
- var fileName = $"{image.ImageTableIndex}.png";
- if (prependGroupAndImageNameInFilename)
- {
- var imageName = new string([.. item.Image.Name.ToLower().Replace(' ', '-').Where(x => !invalidChars.Contains(x))]).Trim();
- var groupName = new string([.. item.GroupName.ToLower().Replace(' ', '-').Where(x => !invalidChars.Contains(x))]).Trim();
-
- if (!string.IsNullOrEmpty(groupName) && !string.IsNullOrEmpty(imageName))
- {
- fileName = $"{groupName}_{imageName}.png";
- }
- }
-
- var path = Path.Combine(directory, fileName);
- await image.UnderlyingImage.SaveAsPngAsync(path);
-
- offsets.Add(new GraphicsElementJson(fileName, image.ToGraphicsElement(Model.PaletteMap)));
- }
-
- var offsetsFile = Path.Combine(directory, "sprites.json");
- Logger.LogInformation("Saving sprite offsets to {OffsetsFile}", offsetsFile);
- await JsonFile.SerializeToFileAsync(offsets, offsetsFile);
- }
+ => _ = await ImageTableIo.ExportAsync(Model, directory, prependGroupAndImageNameInFilename, Logger);
void DisposeGroupedViewModels()
{
diff --git a/Gui/ViewModels/Graphics/ImageViewModel.cs b/Gui/ViewModels/Graphics/ImageViewModel.cs
index 4b81b9b0..b8d8c7e5 100644
--- a/Gui/ViewModels/Graphics/ImageViewModel.cs
+++ b/Gui/ViewModels/Graphics/ImageViewModel.cs
@@ -1,4 +1,5 @@
using Avalonia.Media.Imaging;
+using Core.Graphics;
using Definitions.ObjectModels;
using Definitions.ObjectModels.Graphics;
using PropertyModels.ComponentModel;
@@ -196,7 +197,7 @@ void SetDisplayedImage(Bitmap? bitmap)
public void CropImage()
{
- var cropRegion = FindCropRegion(UnderlyingImage);
+ var cropRegion = GraphicsElementOperations.FindCropRegion(UnderlyingImage);
if (cropRegion.Width <= 0 || cropRegion.Height <= 0)
{
@@ -210,57 +211,6 @@ public void CropImage()
XOffset += (short)cropRegion.Left;
YOffset += (short)cropRegion.Top;
}
-
- static Rectangle FindCropRegion(Image image)
- {
- var minX = image.Width;
- var maxX = 0;
- var minY = image.Height;
- var maxY = 0;
-
- for (var y = 0; y < image.Height; y++)
- {
- for (var x = 0; x < image.Width; x++)
- {
- var pixel = image[x, y];
-
- if (pixel.A > 0)
- {
- minX = Math.Min(minX, x);
- maxX = Math.Max(maxX, x);
- minY = Math.Min(minY, y);
- maxY = Math.Max(maxY, y);
- }
- }
- }
-
- // Calculate the crop area. Ensure it is within image bounds.
- var width = Math.Max(0, Math.Min(maxX - minX + 1, image.Width - minX));
- var height = Math.Max(0, Math.Min(maxY - minY + 1, image.Height - minY));
- return new Rectangle(minX, minY, width, height);
- }
- }
-
- public GraphicsElement ToGraphicsElement(PaletteMap paletteMap)
- {
- if (UnderlyingImage == null)
- {
- throw new InvalidOperationException("Cannot convert to GraphicsElement when UnderlyingImage is null");
- }
-
- // turn rgba32 into raw palette image
- var rawData = paletteMap.ConvertRgba32ImageToG1Data(UnderlyingImage, Flags);
- return new GraphicsElement
- {
- Width = (short)UnderlyingImage.Width,
- Height = (short)UnderlyingImage.Height,
- XOffset = XOffset,
- YOffset = YOffset,
- Flags = Flags,
- ZoomOffset = ZoomOffset,
- ImageData = rawData,
- ImageTableIndex = ImageTableIndex,
- };
}
public void Dispose()
diff --git a/Gui/ViewModels/Loco/ObjectEditorViewModel.cs b/Gui/ViewModels/Loco/ObjectEditorViewModel.cs
index 2be4e258..208fd256 100644
--- a/Gui/ViewModels/Loco/ObjectEditorViewModel.cs
+++ b/Gui/ViewModels/Loco/ObjectEditorViewModel.cs
@@ -1,9 +1,10 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
+using Core.Objects;
+using Core.Validation;
using Dat.Converters;
using Dat.Data;
-using Dat.FileParsing;
using Definitions.DTO;
using Definitions.ObjectModels;
using Definitions.ObjectModels.Objects.Common;
@@ -23,13 +24,11 @@
using ReactiveUI.Fody.Helpers;
using System;
using System.Collections.Generic;
-using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using System.Reactive;
using System.Reactive.Linq;
using System.Reflection;
-using System.Text.Json;
using System.Threading.Tasks;
namespace Gui.ViewModels;
@@ -85,12 +84,16 @@ private void CopyToGameObjDataFolder(GameObjDataFolder targetFolder, FileSystemI
bool ValidateObject(bool showPopupOnSuccess)
{
- var obj = Model?.LocoObject?.Object;
- var validationErrors = obj?.Validate(new ValidationContext(obj)).ToList() ?? [];
+ var validationErrors = ObjectValidation.Validate(Model?.LocoObject?.Object);
ShowValidationMessageBox(validationErrors, showPopupOnSuccess);
- return validationErrors != null && validationErrors.Count == 0;
+ return validationErrors.Count == 0;
}
+ LocoObjectFile? AsObjectFile()
+ => Model?.DatInfo == null || Model.LocoObject == null
+ ? null
+ : new LocoObjectFile(CurrentFile.FileName ?? string.Empty, Model.DatInfo, Model.LocoObject);
+
static void ShowValidationMessageBox(IEnumerable validationErrors, bool showPopupOnSuccess)
{
// Show message box
@@ -123,132 +126,18 @@ static void ShowValidationMessageBox(IEnumerable validationErrors, bool sh
}
}
- static DirectoryInfo? FindDirectoryInParentDirectory(string startPath, string targetName)
- {
- var current = new DirectoryInfo(startPath);
-
- while (current != null)
- {
- foreach (var dir in current.EnumerateDirectories(targetName, SearchOption.TopDirectoryOnly))
- {
- if (string.Equals(dir.Name, targetName, StringComparison.OrdinalIgnoreCase))
- {
- return dir;
- }
- }
-
- // Move up to the parent directory
- current = current.Parent;
- }
-
- return null; // Reached root without finding the target directory
- }
-
bool ValidateForOG(bool showPopupOnSuccess)
{
- try
+ var objectFile = AsObjectFile();
+ if (objectFile == null)
{
- var validationErrors = new List();
-
- if (Model?.DatInfo is null)
- {
- validationErrors.Add("Object DAT info is null");
- return false;
- }
-
- var filename = CurrentFile.FileName;
- if (string.IsNullOrEmpty(filename))
- {
- validationErrors.Add("Filename is null or empty");
- return false;
- }
-
- var currentDir = Path.GetDirectoryName(CurrentFile.FileName);
- if (string.IsNullOrEmpty(currentDir))
- {
- validationErrors.Add("Current directory is null or empty");
- return false;
- }
-
- // reject if .gitkeep file still exists
- var directoryFiles = Directory.GetFiles(currentDir).Select(x => Path.GetFileName(x));
- if (directoryFiles.Contains(".gitkeep"))
- {
- validationErrors.Add("File \".gitkeep\" exists in the current directory");
- }
-
- // find common textures directory
- var textureDirectory = FindDirectoryInParentDirectory(currentDir, "textures")?.FullName;
- if (string.IsNullOrEmpty(textureDirectory))
- {
- validationErrors.Add("Texture directory name is null or empty");
- }
- else
- {
- // reject if any files are here that existing /textures folder
- var textureFiles = Directory.GetFiles(textureDirectory).Select(x => Path.GetFileName(x));
- foreach (var textureFile in textureFiles)
- {
- if (directoryFiles.Contains(textureFile))
- {
- validationErrors.Add($"File \"{Path.GetFileName(textureFile)}\" exists in both the current directory and the textures directory");
- }
- }
- }
-
- var currentDirName = Path.GetFileName(currentDir);
- if (OriginalObjectFiles.Names.TryGetValue(currentDirName, out var fileInfo))
- {
- // DAT name is the expected dat name
- if (Model.DatInfo.S5Header.Name != fileInfo.OpenGraphicsName)
- {
- validationErrors.Add($"✖ Internal DAT header name is not correct. Actual=\"{Model.DatInfo.S5Header.Name}\" Expected=\"{fileInfo.OpenGraphicsName}\" ");
- }
- }
- else
- {
- validationErrors.Add($"✖ Unable to find file info for the vanilla file. Name=\"{currentDirName}\".");
- }
-
- var expectedFilename = $"OG_{currentDirName}.dat";
- var actualFilename = Path.GetFileName(CurrentFile.FileName);
- if (expectedFilename != actualFilename)
- {
- validationErrors.Add($"✖ Filename not correct. Actual=\"{actualFilename}\" Expected=\"{expectedFilename}\" ");
- }
-
- // DAT name is NOT prefixed by OG_
- if (Model.DatInfo.S5Header.Name.Contains('_'))
- {
- validationErrors.Add("✖ Internal header name should not contain an underscore");
- }
-
- // DAT name is prefixed by OG
- if (!Model.DatInfo.S5Header.Name.StartsWith("OG"))
- {
- validationErrors.Add("✖ Internal header name is not prefixed with OG");
- }
-
- // OpenGraphics object source set
- if (Model.DatInfo.S5Header.ObjectSource != DatObjectSource.OpenLoco)
- {
- validationErrors.Add("✖ Object source is not set to OpenLoco");
- }
-
- // if Vehicle - use RunLengthSingle
- if (Model.DatInfo.S5Header.ObjectType == DatObjectType.Vehicle && Model.DatInfo.ObjectHeader.Encoding != SawyerEncoding.RunLengthSingle)
- {
- validationErrors.Add("✖ Object is a Vehicle but doesn't have encoding set to RunLengthSingle");
- }
-
- ShowValidationMessageBox(validationErrors, showPopupOnSuccess);
- return validationErrors != null && validationErrors.Count == 0;
- }
- catch (Exception ex)
- {
- Logger.LogError(ex, "Error validating for OpenGraphics");
+ ShowValidationMessageBox(["Object DAT info is null"], showPopupOnSuccess);
return false;
}
+
+ var validationErrors = ObjectValidation.ValidateForOG(objectFile, Logger);
+ ShowValidationMessageBox(validationErrors, showPopupOnSuccess);
+ return validationErrors.Count == 0;
}
static async Task DoShowDialogAsync(IInteractionContext interaction) where TWindow : Window, new()
@@ -551,25 +440,6 @@ void SaveCore(string filename, SaveParameters saveParameters)
return;
}
- if (string.IsNullOrEmpty(filename))
- {
- Logger.LogError("Cannot save - filename was empty");
- return;
- }
-
- var saveDir = Path.GetDirectoryName(filename);
-
- if (string.IsNullOrEmpty(saveDir))
- {
- Logger.LogError("Cannot save - directory is null or empty");
- return;
- }
- else if (!Directory.Exists(saveDir))
- {
- Logger.LogError("Cannot save - directory does not exist: \"{SaveDir}\"", saveDir);
- return;
- }
-
_ = ValidateObject(showPopupOnSuccess: false);
foreach (var viewModel in ViewModelGroups.SelectMany(x => x.ViewModels).OfType())
@@ -591,37 +461,30 @@ void SaveCore(string filename, SaveParameters saveParameters)
}
}
- var header = Model.DatInfo?.S5Header;
- if (saveParameters.SaveType == SaveType.DAT && header != null)
+ var objectFile = AsObjectFile();
+ if (objectFile == null)
+ {
+ Logger.LogError("Cannot save - DAT info was null");
+ return;
+ }
+
+ if (saveParameters.SaveType == SaveType.DAT)
{
var objectModelHeader = GetViewModel();
var objectModelDatHeader = GetViewModel();
- SawyerStreamWriter.Save(filename,
- objectModelHeader?.Name ?? header.Name,
- objectModelHeader?.ObjectSource ?? header.ObjectSource.Convert(header.Name, header.Checksum),
- saveParameters.SawyerEncoding ?? objectModelDatHeader?.Encoding ?? SawyerEncoding.Uncompressed,
- Model.LocoObject,
+ _ = ObjectFile.SaveDat(
+ objectFile,
+ filename,
Logger,
+ saveParameters.SawyerEncoding ?? objectModelDatHeader?.Encoding ?? SawyerEncoding.Uncompressed,
+ objectModelHeader?.Name,
+ objectModelHeader?.ObjectSource,
EditorContext.Settings.AllowSavingAsVanillaObject);
}
else
{
- JsonSerializer.Serialize(
- new FileStream(filename, FileMode.Create, FileAccess.Write),
- Model.LocoObject,
- options);
+ _ = ObjectFile.SaveJson(objectFile, filename, Logger);
}
}
-
- readonly JsonSerializerOptions options = new()
- {
- WriteIndented = true,
- //Converters =
- //{
- // new LocoStructJsonConverterFactory(),
- // new ObjectTypeJsonConverter(),
- // new ObjectSourceJsonConverter(),
- //}
- };
}
diff --git a/Gui/ViewModels/MainWindowViewModel.cs b/Gui/ViewModels/MainWindowViewModel.cs
index c4a12c3a..62781db0 100644
--- a/Gui/ViewModels/MainWindowViewModel.cs
+++ b/Gui/ViewModels/MainWindowViewModel.cs
@@ -1,7 +1,7 @@
using Avalonia;
-using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Common;
+using Core;
using Dat.Data;
using Definitions.ObjectModels;
using DynamicData;
@@ -68,17 +68,12 @@ public string WindowTitle
[Reactive]
public bool IsUpdateAvailable { get; set; }
- const string DefaultPaletteImageString = "avares://ObjectEditor/Assets/palette.png";
- Image DefaultPaletteImage { get; init; }
-
public Interaction OpenEditorSettingsWindow { get; }
public Interaction OpenLogWindow { get; }
public MainWindowViewModel()
{
- DefaultPaletteImage = Image.Load(AssetLoader.Open(new Uri(DefaultPaletteImageString)));
-
EditorContext = new();
Task.Run(EditorContext.LoadAsync);
Task.Run(LoadDefaultPalette);
@@ -268,7 +263,7 @@ void PopulateObjDataMenu()
async Task LoadDefaultPalette()
{
- EditorContext.PaletteMap = await Task.Run(() => new PaletteMap(DefaultPaletteImage));
+ EditorContext.PaletteMap = await Task.Run(PaletteMapLoader.LoadDefault);
await CurrentTabModel.ReloadAllAsync();
}
diff --git a/ObjectEditor.sln b/ObjectEditor.sln
index fafe05ec..dac761a7 100644
--- a/ObjectEditor.sln
+++ b/ObjectEditor.sln
@@ -42,6 +42,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GuiUpdater", "GuiUpdater\Gu
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DatabaseTools", "DatabaseTools\DatabaseTools.csproj", "{D8B4E93C-CDB5-4E27-902E-04DAE06075AE}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core", "Core\Core.csproj", "{A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cli", "Cli\Cli.csproj", "{8F8A85E3-5568-4B19-97A1-068E5C025CC0}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -172,6 +176,30 @@ Global
{D8B4E93C-CDB5-4E27-902E-04DAE06075AE}.Release|x64.Build.0 = Release|Any CPU
{D8B4E93C-CDB5-4E27-902E-04DAE06075AE}.Release|x86.ActiveCfg = Release|Any CPU
{D8B4E93C-CDB5-4E27-902E-04DAE06075AE}.Release|x86.Build.0 = Release|Any CPU
+ {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Debug|x64.Build.0 = Debug|Any CPU
+ {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Debug|x86.Build.0 = Debug|Any CPU
+ {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Release|x64.ActiveCfg = Release|Any CPU
+ {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Release|x64.Build.0 = Release|Any CPU
+ {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Release|x86.ActiveCfg = Release|Any CPU
+ {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Release|x86.Build.0 = Release|Any CPU
+ {8F8A85E3-5568-4B19-97A1-068E5C025CC0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8F8A85E3-5568-4B19-97A1-068E5C025CC0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8F8A85E3-5568-4B19-97A1-068E5C025CC0}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {8F8A85E3-5568-4B19-97A1-068E5C025CC0}.Debug|x64.Build.0 = Debug|Any CPU
+ {8F8A85E3-5568-4B19-97A1-068E5C025CC0}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {8F8A85E3-5568-4B19-97A1-068E5C025CC0}.Debug|x86.Build.0 = Debug|Any CPU
+ {8F8A85E3-5568-4B19-97A1-068E5C025CC0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8F8A85E3-5568-4B19-97A1-068E5C025CC0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8F8A85E3-5568-4B19-97A1-068E5C025CC0}.Release|x64.ActiveCfg = Release|Any CPU
+ {8F8A85E3-5568-4B19-97A1-068E5C025CC0}.Release|x64.Build.0 = Release|Any CPU
+ {8F8A85E3-5568-4B19-97A1-068E5C025CC0}.Release|x86.ActiveCfg = Release|Any CPU
+ {8F8A85E3-5568-4B19-97A1-068E5C025CC0}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Tests/IdempotenceTests.cs b/Tests/IdempotenceTests.cs
index d258ed89..15cfef39 100644
--- a/Tests/IdempotenceTests.cs
+++ b/Tests/IdempotenceTests.cs
@@ -1,3 +1,4 @@
+using Core;
using Dat.Converters;
using Dat.FileParsing;
using Definitions.ObjectModels;
@@ -13,7 +14,7 @@ namespace Dat.Tests;
[TestFixture]
public class IdempotenceTests
{
- static PaletteMap PaletteMap { get; } = new PaletteMap("C:\\Users\\bigba\\source\\repos\\OpenLoco\\ObjectEditor\\Gui\\Assets\\palette.png");
+ static PaletteMap PaletteMap { get; } = PaletteMapLoader.LoadDefault();
static string[] VanillaFiles =>
[
diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj
index 98e8b195..fb3cd9b4 100644
--- a/Tests/Tests.csproj
+++ b/Tests/Tests.csproj
@@ -27,6 +27,7 @@
+
From c346488072088f24be4c0026e01cbf39b0021da2 Mon Sep 17 00:00:00 2001
From: Benjamin Sutas
Date: Sun, 2 Aug 2026 13:14:10 +1000
Subject: [PATCH 2/3] restructure
---
Cli/Cli.csproj | 2 +-
Cli/CommandContext.cs | 13 ++++----
Cli/CommandLine.cs | 2 +-
Cli/Commands/CropCommand.cs | 4 +--
Cli/Commands/ExportImagesCommand.cs | 2 +-
Cli/Commands/ImportImagesCommand.cs | 2 +-
Cli/Commands/InfoCommand.cs | 2 +-
Cli/Commands/OffsetsCommand.cs | 4 +--
Cli/Commands/ReencodeCommand.cs | 4 +--
Cli/Commands/StripImagesCommand.cs | 4 +--
Cli/Commands/ValidateCommand.cs | 4 +--
Cli/ConsoleLogger.cs | 3 +-
Core/Core.csproj | 13 --------
Dat/Loaders/SoundObjectLoader.cs | 4 ---
{Core => Definitions}/Assets/palette.png | Bin
Definitions/Definitions.csproj | 6 ++++
.../Graphics/GraphicsElementJson.cs | 3 +-
.../Graphics/GraphicsElementOperations.cs | 3 +-
.../ObjectModels/Graphics/ImageTable.cs | 2 +-
.../ImageTableGroupsConfig-Zehmatt.cs | 0
.../ObjectModels}/Graphics/ImageTableIo.cs | 0
.../ObjectModels/{ => Graphics}/PaletteMap.cs | 3 +-
.../Graphics}/PaletteMapLoader.cs | 3 +-
Gui/Gui.csproj | 7 ++---
Gui/Models/Audio/AudioHelpers.cs | 1 -
Gui/Models/ObjectEditorContext.cs | 1 -
Gui/ViewModels/AudioViewModel.cs | 2 --
.../EditorSettingsWindowViewModel.cs | 1 -
Gui/ViewModels/Filters/FilterViewModel.cs | 2 --
Gui/ViewModels/FolderTreeViewModel.cs | 4 ---
.../Graphics/ColourRemapSwatchViewModel.cs | 1 -
.../Graphics/GroupedImageViewModel.cs | 1 -
.../Graphics/ImageTableViewModel.cs | 1 -
Gui/ViewModels/Graphics/ImageViewModel.cs | 5 ----
Gui/ViewModels/Loco/BaseFileViewModel.cs | 5 ----
Gui/ViewModels/Loco/BaseViewModel.cs | 1 -
Gui/ViewModels/Loco/G1ViewModel.cs | 1 -
Gui/ViewModels/Loco/IFileViewModel.cs | 1 -
Gui/ViewModels/Loco/MusicViewModel.cs | 1 -
.../Loco/ObjectDatHeaderViewModel.cs | 1 -
Gui/ViewModels/Loco/ObjectEditorViewModel.cs | 6 ----
.../Loco/Objects/AirportViewModel.cs | 2 --
.../Loco/Objects/BridgeViewModel.cs | 1 -
.../Building/BuildingComponentsViewModel.cs | 2 --
.../Building/BuildingLayerViewModel.cs | 1 -
.../Objects/Building/BuildingViewModel.cs | 2 --
Gui/ViewModels/Loco/Objects/CargoViewModel.cs | 1 -
Gui/ViewModels/Loco/Objects/DockViewModel.cs | 2 --
.../Loco/Objects/IndustryViewModel.cs | 2 --
Gui/ViewModels/Loco/Objects/LandViewModel.cs | 1 -
.../Loco/Objects/LevelCrossingViewModel.cs | 1 -
.../Loco/Objects/RoadExtraViewModel.cs | 1 -
.../Loco/Objects/RoadStationViewModel.cs | 1 -
Gui/ViewModels/Loco/Objects/RoadViewModel.cs | 1 -
Gui/ViewModels/Loco/Objects/SteamViewModel.cs | 1 -
.../TownNames/StringTableEntryViewModel.cs | 1 -
.../TownNames/TownNamesPreviewViewModel.cs | 1 -
.../Loco/Objects/TrackExtraViewModel.cs | 1 -
.../Loco/Objects/TrackSignalViewModel.cs | 1 -
.../Loco/Objects/TrackStationViewModel.cs | 1 -
Gui/ViewModels/Loco/Objects/TrackViewModel.cs | 1 -
Gui/ViewModels/Loco/Objects/TreeViewModel.cs | 1 -
.../Loco/Objects/Vehicle/VehicleViewModel.cs | 3 --
Gui/ViewModels/Loco/Objects/WallViewModel.cs | 1 -
Gui/ViewModels/Loco/SCV5ViewModel.cs | 2 --
Gui/ViewModels/Loco/SoundEffectsViewModel.cs | 2 --
.../Loco/Tutorial/TutorialViewModel.cs | 1 -
Gui/ViewModels/MainWindowViewModel.cs | 6 +---
Gui/ViewModels/MenuItemViewModel.cs | 1 -
.../ObjectSelectionWindowViewModel.cs | 1 -
Gui/ViewModels/Pos3ViewModel.cs | 1 -
.../RequiredObjectsListViewModel.cs | 1 -
Gui/ViewModels/StringTableViewModel.cs | 2 --
Gui/ViewModels/TabViewPageViewModel.cs | 1 -
Gui/ViewModels/ViewModelGroup.cs | 1 -
Gui/Views/ExtendedPropertyGrid.cs | 2 --
Gui/Views/FolderTreeView.axaml.cs | 1 -
Gui/Views/ImageTableView.axaml.cs | 1 -
ObjectEditor.sln | 28 +++++++++---------
ObjectService/Program.cs | 2 +-
.../TableHandlers/V1RouteHandler.cs | 1 -
.../Files}/LocoObjectFile.cs | 2 +-
{Core/Objects => Shared/Files}/ObjectFile.cs | 4 +--
{Core => Shared}/Operations/BatchProcessor.cs | 6 ++--
.../Operations/ObjectOperations.cs | 4 +--
Shared/Shared.csproj | 15 ++++++++++
.../Validation/ObjectValidation.cs | 4 +--
Tests/IdempotenceTests.cs | 2 --
Tests/ImagePaletteConversionTests.cs | 1 -
Tests/Tests.csproj | 1 -
90 files changed, 76 insertions(+), 172 deletions(-)
rename {Core => Definitions}/Assets/palette.png (100%)
rename {Core => Definitions/ObjectModels}/Graphics/GraphicsElementJson.cs (93%)
rename {Core => Definitions/ObjectModels}/Graphics/GraphicsElementOperations.cs (98%)
rename Core/ImageTableGroupsConfig.cs => Definitions/ObjectModels/Graphics/ImageTableGroupsConfig-Zehmatt.cs (100%)
rename {Core => Definitions/ObjectModels}/Graphics/ImageTableIo.cs (100%)
rename Definitions/ObjectModels/{ => Graphics}/PaletteMap.cs (98%)
rename {Core => Definitions/ObjectModels/Graphics}/PaletteMapLoader.cs (93%)
rename {Core/Objects => Shared/Files}/LocoObjectFile.cs (86%)
rename {Core/Objects => Shared/Files}/ObjectFile.cs (98%)
rename {Core => Shared}/Operations/BatchProcessor.cs (97%)
rename {Core => Shared}/Operations/ObjectOperations.cs (95%)
create mode 100644 Shared/Shared.csproj
rename {Core => Shared}/Validation/ObjectValidation.cs (98%)
diff --git a/Cli/Cli.csproj b/Cli/Cli.csproj
index 1ef96698..64b6bad3 100644
--- a/Cli/Cli.csproj
+++ b/Cli/Cli.csproj
@@ -19,9 +19,9 @@
-
+
diff --git a/Cli/CommandContext.cs b/Cli/CommandContext.cs
index a9e68389..b8b17642 100644
--- a/Cli/CommandContext.cs
+++ b/Cli/CommandContext.cs
@@ -1,9 +1,8 @@
-using Core;
-using Core.Objects;
-using Core.Operations;
using Dat.Data;
-using Definitions.ObjectModels;
+using Definitions.ObjectModels.Graphics;
using Microsoft.Extensions.Logging;
+using Shared.Files;
+using Shared.Operations;
namespace Cli;
@@ -13,10 +12,8 @@ public sealed class CommandContext(CommandLine commandLine, ILogger logger)
public ILogger Logger { get; } = logger;
- PaletteMap? paletteMap;
-
public PaletteMap PaletteMap
- => paletteMap ??= PaletteMapLoader.Load(Args.GetString("palette"));
+ => field ??= PaletteMapLoader.Load(Args.GetString("palette"));
public static IReadOnlySet CommonFlags { get; } = new HashSet(StringComparer.OrdinalIgnoreCase)
{
@@ -107,7 +104,7 @@ public bool TryBuildBatchOptions(string inputRoot, out BatchOptions options, boo
return true;
}
- public int Report(BatchResult result)
+ public static int Report(BatchResult result)
{
ArgumentNullException.ThrowIfNull(result);
diff --git a/Cli/CommandLine.cs b/Cli/CommandLine.cs
index 961c7cd7..a9e4170f 100644
--- a/Cli/CommandLine.cs
+++ b/Cli/CommandLine.cs
@@ -3,7 +3,7 @@ namespace Cli;
public sealed class CommandLine
{
readonly List positionals = [];
- readonly Dictionary options = new(StringComparer.OrdinalIgnoreCase);
+ readonly Dictionary options = [with(StringComparer.OrdinalIgnoreCase)];
public IReadOnlyList Positionals
=> positionals;
diff --git a/Cli/Commands/CropCommand.cs b/Cli/Commands/CropCommand.cs
index 18133bf3..67faa6c9 100644
--- a/Cli/Commands/CropCommand.cs
+++ b/Cli/Commands/CropCommand.cs
@@ -1,4 +1,4 @@
-using Core.Operations;
+using Shared.Operations;
namespace Cli.Commands;
@@ -45,6 +45,6 @@ public Task RunAsync(CommandContext context)
options,
context.Logger);
- return Task.FromResult(context.Report(result));
+ return Task.FromResult(CommandContext.Report(result));
}
}
diff --git a/Cli/Commands/ExportImagesCommand.cs b/Cli/Commands/ExportImagesCommand.cs
index 9495ce3b..892fbc02 100644
--- a/Cli/Commands/ExportImagesCommand.cs
+++ b/Cli/Commands/ExportImagesCommand.cs
@@ -1,6 +1,6 @@
using Core.Graphics;
-using Core.Objects;
using Microsoft.Extensions.Logging;
+using Shared.Files;
namespace Cli.Commands;
diff --git a/Cli/Commands/ImportImagesCommand.cs b/Cli/Commands/ImportImagesCommand.cs
index 1f0b96cc..1cc4b862 100644
--- a/Cli/Commands/ImportImagesCommand.cs
+++ b/Cli/Commands/ImportImagesCommand.cs
@@ -1,6 +1,6 @@
using Core.Graphics;
-using Core.Objects;
using Microsoft.Extensions.Logging;
+using Shared.Files;
namespace Cli.Commands;
diff --git a/Cli/Commands/InfoCommand.cs b/Cli/Commands/InfoCommand.cs
index beb468a9..f4a5a102 100644
--- a/Cli/Commands/InfoCommand.cs
+++ b/Cli/Commands/InfoCommand.cs
@@ -1,4 +1,4 @@
-using Core.Objects;
+using Shared.Files;
using System.Text.Json;
namespace Cli.Commands;
diff --git a/Cli/Commands/OffsetsCommand.cs b/Cli/Commands/OffsetsCommand.cs
index ba32eb25..9441a08a 100644
--- a/Cli/Commands/OffsetsCommand.cs
+++ b/Cli/Commands/OffsetsCommand.cs
@@ -1,5 +1,5 @@
-using Core.Operations;
using Microsoft.Extensions.Logging;
+using Shared.Operations;
namespace Cli.Commands;
@@ -75,7 +75,7 @@ public Task RunAsync(CommandContext context)
options,
context.Logger);
- return Task.FromResult(context.Report(result));
+ return Task.FromResult(CommandContext.Report(result));
}
static bool TryParseDelta(string value, out short deltaX, out short deltaY)
diff --git a/Cli/Commands/ReencodeCommand.cs b/Cli/Commands/ReencodeCommand.cs
index ff73da00..c7897fae 100644
--- a/Cli/Commands/ReencodeCommand.cs
+++ b/Cli/Commands/ReencodeCommand.cs
@@ -1,5 +1,5 @@
-using Core.Operations;
using Microsoft.Extensions.Logging;
+using Shared.Operations;
namespace Cli.Commands;
@@ -48,6 +48,6 @@ public Task RunAsync(CommandContext context)
options,
context.Logger);
- return Task.FromResult(context.Report(result));
+ return Task.FromResult(CommandContext.Report(result));
}
}
diff --git a/Cli/Commands/StripImagesCommand.cs b/Cli/Commands/StripImagesCommand.cs
index 53d620c4..96af720c 100644
--- a/Cli/Commands/StripImagesCommand.cs
+++ b/Cli/Commands/StripImagesCommand.cs
@@ -1,4 +1,4 @@
-using Core.Operations;
+using Shared.Operations;
namespace Cli.Commands;
@@ -45,6 +45,6 @@ public Task RunAsync(CommandContext context)
options,
context.Logger);
- return Task.FromResult(context.Report(result));
+ return Task.FromResult(CommandContext.Report(result));
}
}
diff --git a/Cli/Commands/ValidateCommand.cs b/Cli/Commands/ValidateCommand.cs
index 40b84caf..78ca2167 100644
--- a/Cli/Commands/ValidateCommand.cs
+++ b/Cli/Commands/ValidateCommand.cs
@@ -1,5 +1,5 @@
-using Core.Objects;
-using Core.Validation;
+using Shared.Files;
+using Shared.Validation;
namespace Cli.Commands;
diff --git a/Cli/ConsoleLogger.cs b/Cli/ConsoleLogger.cs
index 5cda6ec0..80c7ea2b 100644
--- a/Cli/ConsoleLogger.cs
+++ b/Cli/ConsoleLogger.cs
@@ -39,6 +39,7 @@ static string Prefix(LogLevel level)
LogLevel.Warning => "warn:",
LogLevel.Error => "fail:",
LogLevel.Critical => "crit:",
- _ => " ",
+ LogLevel.None => string.Empty,
+ _ => throw new NotImplementedException(),
};
}
diff --git a/Core/Core.csproj b/Core/Core.csproj
index b8ac95ea..d39f3059 100644
--- a/Core/Core.csproj
+++ b/Core/Core.csproj
@@ -14,19 +14,6 @@
-
-
-
- Core.palette.png
-
-
-
-
-
- Core.ImageTableGroups.json
-
-
-
diff --git a/Dat/Loaders/SoundObjectLoader.cs b/Dat/Loaders/SoundObjectLoader.cs
index fed07329..fb182630 100644
--- a/Dat/Loaders/SoundObjectLoader.cs
+++ b/Dat/Loaders/SoundObjectLoader.cs
@@ -3,13 +3,9 @@
using Dat.Data;
using Dat.FileParsing;
-using Dat.Types;
-using Dat.Types.Audio;
using Definitions.ObjectModels;
using Definitions.ObjectModels.Objects.Sound;
using Definitions.ObjectModels.Types;
-using System.ComponentModel;
-using System.ComponentModel.DataAnnotations;
namespace Dat.Loaders;
diff --git a/Core/Assets/palette.png b/Definitions/Assets/palette.png
similarity index 100%
rename from Core/Assets/palette.png
rename to Definitions/Assets/palette.png
diff --git a/Definitions/Definitions.csproj b/Definitions/Definitions.csproj
index fc7d55e6..5f023bff 100644
--- a/Definitions/Definitions.csproj
+++ b/Definitions/Definitions.csproj
@@ -43,6 +43,12 @@
+
+
+ Core.palette.png
+
+
+
diff --git a/Core/Graphics/GraphicsElementJson.cs b/Definitions/ObjectModels/Graphics/GraphicsElementJson.cs
similarity index 93%
rename from Core/Graphics/GraphicsElementJson.cs
rename to Definitions/ObjectModels/Graphics/GraphicsElementJson.cs
index 645d59c4..20e1984a 100644
--- a/Core/Graphics/GraphicsElementJson.cs
+++ b/Definitions/ObjectModels/Graphics/GraphicsElementJson.cs
@@ -1,7 +1,6 @@
-using Definitions.ObjectModels.Graphics;
using System.Text.Json.Serialization;
-namespace Core.Graphics;
+namespace Definitions.ObjectModels.Graphics;
public record GraphicsElementJson(
[property: JsonPropertyName("path")] string Path,
diff --git a/Core/Graphics/GraphicsElementOperations.cs b/Definitions/ObjectModels/Graphics/GraphicsElementOperations.cs
similarity index 98%
rename from Core/Graphics/GraphicsElementOperations.cs
rename to Definitions/ObjectModels/Graphics/GraphicsElementOperations.cs
index bac0744c..a56d5e88 100644
--- a/Core/Graphics/GraphicsElementOperations.cs
+++ b/Definitions/ObjectModels/Graphics/GraphicsElementOperations.cs
@@ -1,10 +1,9 @@
-using Definitions.ObjectModels;
using Definitions.ObjectModels.Graphics;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
-namespace Core.Graphics;
+namespace Definitions.ObjectModels.Graphics;
public static class GraphicsElementOperations
{
diff --git a/Definitions/ObjectModels/Graphics/ImageTable.cs b/Definitions/ObjectModels/Graphics/ImageTable.cs
index 3eae6597..db59c890 100644
--- a/Definitions/ObjectModels/Graphics/ImageTable.cs
+++ b/Definitions/ObjectModels/Graphics/ImageTable.cs
@@ -21,7 +21,7 @@ public PaletteMap PaletteMap
{
if (!field.TryConvertG1ToRgba32Bitmap(ge, ColourSwatch.PrimaryRemap, ColourSwatch.SecondaryRemap, out var image))
{
- throw new Exception("Failed to convert image");
+ throw new InvalidOperationException("Failed to convert image");
}
ge.Image = image;
diff --git a/Core/ImageTableGroupsConfig.cs b/Definitions/ObjectModels/Graphics/ImageTableGroupsConfig-Zehmatt.cs
similarity index 100%
rename from Core/ImageTableGroupsConfig.cs
rename to Definitions/ObjectModels/Graphics/ImageTableGroupsConfig-Zehmatt.cs
diff --git a/Core/Graphics/ImageTableIo.cs b/Definitions/ObjectModels/Graphics/ImageTableIo.cs
similarity index 100%
rename from Core/Graphics/ImageTableIo.cs
rename to Definitions/ObjectModels/Graphics/ImageTableIo.cs
diff --git a/Definitions/ObjectModels/PaletteMap.cs b/Definitions/ObjectModels/Graphics/PaletteMap.cs
similarity index 98%
rename from Definitions/ObjectModels/PaletteMap.cs
rename to Definitions/ObjectModels/Graphics/PaletteMap.cs
index 75a4c69b..770ea4af 100644
--- a/Definitions/ObjectModels/PaletteMap.cs
+++ b/Definitions/ObjectModels/Graphics/PaletteMap.cs
@@ -1,8 +1,7 @@
-using Definitions.ObjectModels.Graphics;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
-namespace Definitions.ObjectModels;
+namespace Definitions.ObjectModels.Graphics;
public class PaletteMap
{
diff --git a/Core/PaletteMapLoader.cs b/Definitions/ObjectModels/Graphics/PaletteMapLoader.cs
similarity index 93%
rename from Core/PaletteMapLoader.cs
rename to Definitions/ObjectModels/Graphics/PaletteMapLoader.cs
index 4a1b1416..679b2891 100644
--- a/Core/PaletteMapLoader.cs
+++ b/Definitions/ObjectModels/Graphics/PaletteMapLoader.cs
@@ -1,9 +1,8 @@
-using Definitions.ObjectModels;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using System.Reflection;
-namespace Core;
+namespace Definitions.ObjectModels.Graphics;
public static class PaletteMapLoader
{
diff --git a/Gui/Gui.csproj b/Gui/Gui.csproj
index 36985cc2..d8a59768 100644
--- a/Gui/Gui.csproj
+++ b/Gui/Gui.csproj
@@ -68,7 +68,6 @@
-
@@ -88,8 +87,8 @@
-
-
-
+
+ $(AvaloniaUILicenseKey)
+
diff --git a/Gui/Models/Audio/AudioHelpers.cs b/Gui/Models/Audio/AudioHelpers.cs
index 8e472ae9..b345d720 100644
--- a/Gui/Models/Audio/AudioHelpers.cs
+++ b/Gui/Models/Audio/AudioHelpers.cs
@@ -1,5 +1,4 @@
using Definitions.ObjectModels.Objects.Sound;
-using NAudio.Wave;
using System.Collections.Generic;
namespace Gui.Models.Audio;
diff --git a/Gui/Models/ObjectEditorContext.cs b/Gui/Models/ObjectEditorContext.cs
index c045c55f..220fbf6e 100644
--- a/Gui/Models/ObjectEditorContext.cs
+++ b/Gui/Models/ObjectEditorContext.cs
@@ -19,7 +19,6 @@
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
-using System.Reflection;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
diff --git a/Gui/ViewModels/AudioViewModel.cs b/Gui/ViewModels/AudioViewModel.cs
index ecbfa108..427b80f5 100644
--- a/Gui/ViewModels/AudioViewModel.cs
+++ b/Gui/ViewModels/AudioViewModel.cs
@@ -1,9 +1,7 @@
using Definitions.ObjectModels.Objects.Sound;
using Gui.Models.Audio;
using Microsoft.Extensions.Logging;
-using NAudio.Wave;
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using System;
using System.IO;
using System.Reactive.Linq;
diff --git a/Gui/ViewModels/EditorSettingsWindowViewModel.cs b/Gui/ViewModels/EditorSettingsWindowViewModel.cs
index 01039044..ac2701ab 100644
--- a/Gui/ViewModels/EditorSettingsWindowViewModel.cs
+++ b/Gui/ViewModels/EditorSettingsWindowViewModel.cs
@@ -1,4 +1,3 @@
-using PropertyModels.ComponentModel.DataAnnotations;
using System.Collections.ObjectModel;
using System.ComponentModel;
diff --git a/Gui/ViewModels/Filters/FilterViewModel.cs b/Gui/ViewModels/Filters/FilterViewModel.cs
index 5f5a4f23..ca13a90f 100644
--- a/Gui/ViewModels/Filters/FilterViewModel.cs
+++ b/Gui/ViewModels/Filters/FilterViewModel.cs
@@ -3,9 +3,7 @@
using DynamicData;
using Gui.Models;
using Microsoft.Extensions.Logging;
-using PropertyModels.Extensions;
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using System;
using System.Collections;
using System.Collections.Generic;
diff --git a/Gui/ViewModels/FolderTreeViewModel.cs b/Gui/ViewModels/FolderTreeViewModel.cs
index 06aece9c..7083ca11 100644
--- a/Gui/ViewModels/FolderTreeViewModel.cs
+++ b/Gui/ViewModels/FolderTreeViewModel.cs
@@ -14,11 +14,7 @@
using Gui.ViewModels.Filters;
using Index;
using Microsoft.Extensions.Logging;
-using MsBox.Avalonia;
-using MsBox.Avalonia.Dto;
-using MsBox.Avalonia.Enums;
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
diff --git a/Gui/ViewModels/Graphics/ColourRemapSwatchViewModel.cs b/Gui/ViewModels/Graphics/ColourRemapSwatchViewModel.cs
index b3cd9933..8b304973 100644
--- a/Gui/ViewModels/Graphics/ColourRemapSwatchViewModel.cs
+++ b/Gui/ViewModels/Graphics/ColourRemapSwatchViewModel.cs
@@ -1,5 +1,4 @@
using Definitions.ObjectModels.Graphics;
-using ReactiveUI.Fody.Helpers;
using AvaColour = Avalonia.Media.Color;
namespace Gui.ViewModels.Graphics;
diff --git a/Gui/ViewModels/Graphics/GroupedImageViewModel.cs b/Gui/ViewModels/Graphics/GroupedImageViewModel.cs
index 94792d68..4d984b5f 100644
--- a/Gui/ViewModels/Graphics/GroupedImageViewModel.cs
+++ b/Gui/ViewModels/Graphics/GroupedImageViewModel.cs
@@ -1,6 +1,5 @@
using Avalonia.Controls.Selection;
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using System.Collections.Generic;
using System.Collections.ObjectModel;
diff --git a/Gui/ViewModels/Graphics/ImageTableViewModel.cs b/Gui/ViewModels/Graphics/ImageTableViewModel.cs
index 40042e61..ddb1cdea 100644
--- a/Gui/ViewModels/Graphics/ImageTableViewModel.cs
+++ b/Gui/ViewModels/Graphics/ImageTableViewModel.cs
@@ -6,7 +6,6 @@
using Definitions.ObjectModels.Types;
using Microsoft.Extensions.Logging;
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using System;
diff --git a/Gui/ViewModels/Graphics/ImageViewModel.cs b/Gui/ViewModels/Graphics/ImageViewModel.cs
index b8d8c7e5..bdfd0093 100644
--- a/Gui/ViewModels/Graphics/ImageViewModel.cs
+++ b/Gui/ViewModels/Graphics/ImageViewModel.cs
@@ -1,11 +1,6 @@
using Avalonia.Media.Imaging;
-using Core.Graphics;
-using Definitions.ObjectModels;
using Definitions.ObjectModels.Graphics;
-using PropertyModels.ComponentModel;
-using PropertyModels.ComponentModel.DataAnnotations;
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
diff --git a/Gui/ViewModels/Loco/BaseFileViewModel.cs b/Gui/ViewModels/Loco/BaseFileViewModel.cs
index 00f6abd0..12354001 100644
--- a/Gui/ViewModels/Loco/BaseFileViewModel.cs
+++ b/Gui/ViewModels/Loco/BaseFileViewModel.cs
@@ -2,12 +2,7 @@
using Dat.Data;
using Definitions.ObjectModels.Types;
using Gui.Models;
-using MsBox.Avalonia;
-using MsBox.Avalonia.Dto;
-using MsBox.Avalonia.Enums;
-using MsBox.Avalonia.Models;
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using System.Collections.Generic;
using System.Linq;
using System.Reactive;
diff --git a/Gui/ViewModels/Loco/BaseViewModel.cs b/Gui/ViewModels/Loco/BaseViewModel.cs
index 12c9f220..69636496 100644
--- a/Gui/ViewModels/Loco/BaseViewModel.cs
+++ b/Gui/ViewModels/Loco/BaseViewModel.cs
@@ -1,6 +1,5 @@
using DynamicData;
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
diff --git a/Gui/ViewModels/Loco/G1ViewModel.cs b/Gui/ViewModels/Loco/G1ViewModel.cs
index f3eb9735..c93fbc34 100644
--- a/Gui/ViewModels/Loco/G1ViewModel.cs
+++ b/Gui/ViewModels/Loco/G1ViewModel.cs
@@ -3,7 +3,6 @@
using Gui.Models;
using Gui.ViewModels.Graphics;
using Microsoft.Extensions.Logging;
-using ReactiveUI.Fody.Helpers;
using System.IO;
using System.Threading.Tasks;
diff --git a/Gui/ViewModels/Loco/IFileViewModel.cs b/Gui/ViewModels/Loco/IFileViewModel.cs
index aa3730e1..86ce48f5 100644
--- a/Gui/ViewModels/Loco/IFileViewModel.cs
+++ b/Gui/ViewModels/Loco/IFileViewModel.cs
@@ -1,6 +1,5 @@
using Gui.Models;
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using System.Reactive;
namespace Gui.ViewModels;
diff --git a/Gui/ViewModels/Loco/MusicViewModel.cs b/Gui/ViewModels/Loco/MusicViewModel.cs
index 40233ade..83bb1077 100644
--- a/Gui/ViewModels/Loco/MusicViewModel.cs
+++ b/Gui/ViewModels/Loco/MusicViewModel.cs
@@ -3,7 +3,6 @@
using Gui.Models;
using Gui.Models.Audio;
using Microsoft.Extensions.Logging;
-using ReactiveUI.Fody.Helpers;
using System.IO;
using System.Threading.Tasks;
diff --git a/Gui/ViewModels/Loco/ObjectDatHeaderViewModel.cs b/Gui/ViewModels/Loco/ObjectDatHeaderViewModel.cs
index 8a0fefa4..8d753322 100644
--- a/Gui/ViewModels/Loco/ObjectDatHeaderViewModel.cs
+++ b/Gui/ViewModels/Loco/ObjectDatHeaderViewModel.cs
@@ -1,6 +1,5 @@
using Dat.Data;
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using System.ComponentModel;
namespace Gui.ViewModels;
diff --git a/Gui/ViewModels/Loco/ObjectEditorViewModel.cs b/Gui/ViewModels/Loco/ObjectEditorViewModel.cs
index 208fd256..ae084cb2 100644
--- a/Gui/ViewModels/Loco/ObjectEditorViewModel.cs
+++ b/Gui/ViewModels/Loco/ObjectEditorViewModel.cs
@@ -1,8 +1,6 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
-using Core.Objects;
-using Core.Validation;
using Dat.Converters;
using Dat.Data;
using Definitions.DTO;
@@ -17,11 +15,7 @@
using Gui.ViewModels.Loco.Objects.TownNames;
using Gui.Views;
using Microsoft.Extensions.Logging;
-using MsBox.Avalonia;
-using MsBox.Avalonia.Base;
-using MsBox.Avalonia.Enums;
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using System;
using System.Collections.Generic;
using System.IO;
diff --git a/Gui/ViewModels/Loco/Objects/AirportViewModel.cs b/Gui/ViewModels/Loco/Objects/AirportViewModel.cs
index c35cc9ca..34554c4d 100644
--- a/Gui/ViewModels/Loco/Objects/AirportViewModel.cs
+++ b/Gui/ViewModels/Loco/Objects/AirportViewModel.cs
@@ -2,8 +2,6 @@
using Definitions.ObjectModels.Objects.Airport;
using Definitions.ObjectModels.Objects.Common;
using Gui.Attributes;
-using PropertyModels.ComponentModel.DataAnnotations;
-using PropertyModels.Extensions;
using ReactiveUI;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
diff --git a/Gui/ViewModels/Loco/Objects/BridgeViewModel.cs b/Gui/ViewModels/Loco/Objects/BridgeViewModel.cs
index 2d02d8eb..731af3fb 100644
--- a/Gui/ViewModels/Loco/Objects/BridgeViewModel.cs
+++ b/Gui/ViewModels/Loco/Objects/BridgeViewModel.cs
@@ -1,7 +1,6 @@
using Definitions.ObjectModels.Objects.Bridge;
using Definitions.ObjectModels.Types;
using Gui.Attributes;
-using PropertyModels.ComponentModel.DataAnnotations;
using ReactiveUI;
using System.ComponentModel;
diff --git a/Gui/ViewModels/Loco/Objects/Building/BuildingComponentsViewModel.cs b/Gui/ViewModels/Loco/Objects/Building/BuildingComponentsViewModel.cs
index e3d8bc27..437d3e98 100644
--- a/Gui/ViewModels/Loco/Objects/Building/BuildingComponentsViewModel.cs
+++ b/Gui/ViewModels/Loco/Objects/Building/BuildingComponentsViewModel.cs
@@ -1,9 +1,7 @@
using Definitions.ObjectModels.Graphics;
using Definitions.ObjectModels.Objects.Building;
using Definitions.ObjectModels.Objects.Common;
-using PropertyModels.ComponentModel;
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
diff --git a/Gui/ViewModels/Loco/Objects/Building/BuildingLayerViewModel.cs b/Gui/ViewModels/Loco/Objects/Building/BuildingLayerViewModel.cs
index 47ba35c5..dc1c9486 100644
--- a/Gui/ViewModels/Loco/Objects/Building/BuildingLayerViewModel.cs
+++ b/Gui/ViewModels/Loco/Objects/Building/BuildingLayerViewModel.cs
@@ -1,6 +1,5 @@
using Avalonia.Media.Imaging;
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using System;
using System.ComponentModel;
using System.Reactive.Linq;
diff --git a/Gui/ViewModels/Loco/Objects/Building/BuildingViewModel.cs b/Gui/ViewModels/Loco/Objects/Building/BuildingViewModel.cs
index ebadea95..917a56ed 100644
--- a/Gui/ViewModels/Loco/Objects/Building/BuildingViewModel.cs
+++ b/Gui/ViewModels/Loco/Objects/Building/BuildingViewModel.cs
@@ -3,8 +3,6 @@
using Definitions.ObjectModels.Objects.Common;
using Definitions.ObjectModels.Types;
using Gui.Attributes;
-using PropertyModels.ComponentModel.DataAnnotations;
-using PropertyModels.Extensions;
using ReactiveUI;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
diff --git a/Gui/ViewModels/Loco/Objects/CargoViewModel.cs b/Gui/ViewModels/Loco/Objects/CargoViewModel.cs
index 0a919934..52406b8d 100644
--- a/Gui/ViewModels/Loco/Objects/CargoViewModel.cs
+++ b/Gui/ViewModels/Loco/Objects/CargoViewModel.cs
@@ -1,5 +1,4 @@
using Definitions.ObjectModels.Objects.Cargo;
-using PropertyModels.ComponentModel.DataAnnotations;
using ReactiveUI;
using System.ComponentModel;
diff --git a/Gui/ViewModels/Loco/Objects/DockViewModel.cs b/Gui/ViewModels/Loco/Objects/DockViewModel.cs
index 1927f207..a66170f5 100644
--- a/Gui/ViewModels/Loco/Objects/DockViewModel.cs
+++ b/Gui/ViewModels/Loco/Objects/DockViewModel.cs
@@ -3,8 +3,6 @@
using Definitions.ObjectModels.Objects.Dock;
using Definitions.ObjectModels.Types;
using Gui.Attributes;
-using PropertyModels.ComponentModel.DataAnnotations;
-using PropertyModels.Extensions;
using ReactiveUI;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
diff --git a/Gui/ViewModels/Loco/Objects/IndustryViewModel.cs b/Gui/ViewModels/Loco/Objects/IndustryViewModel.cs
index 68f147f6..95d7a33f 100644
--- a/Gui/ViewModels/Loco/Objects/IndustryViewModel.cs
+++ b/Gui/ViewModels/Loco/Objects/IndustryViewModel.cs
@@ -4,8 +4,6 @@
using Definitions.ObjectModels.Objects.Industry;
using Definitions.ObjectModels.Types;
using Gui.Attributes;
-using PropertyModels.ComponentModel.DataAnnotations;
-using PropertyModels.Extensions;
using ReactiveUI;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
diff --git a/Gui/ViewModels/Loco/Objects/LandViewModel.cs b/Gui/ViewModels/Loco/Objects/LandViewModel.cs
index 0d9b230d..51810818 100644
--- a/Gui/ViewModels/Loco/Objects/LandViewModel.cs
+++ b/Gui/ViewModels/Loco/Objects/LandViewModel.cs
@@ -1,6 +1,5 @@
using Definitions.ObjectModels.Objects.Land;
using Definitions.ObjectModels.Types;
-using PropertyModels.ComponentModel.DataAnnotations;
namespace Gui.ViewModels;
diff --git a/Gui/ViewModels/Loco/Objects/LevelCrossingViewModel.cs b/Gui/ViewModels/Loco/Objects/LevelCrossingViewModel.cs
index 39ac7071..8f5fd5be 100644
--- a/Gui/ViewModels/Loco/Objects/LevelCrossingViewModel.cs
+++ b/Gui/ViewModels/Loco/Objects/LevelCrossingViewModel.cs
@@ -1,5 +1,4 @@
using Definitions.ObjectModels.Objects.LevelCrossing;
-using PropertyModels.Collections;
using System.ComponentModel;
namespace Gui.ViewModels;
diff --git a/Gui/ViewModels/Loco/Objects/RoadExtraViewModel.cs b/Gui/ViewModels/Loco/Objects/RoadExtraViewModel.cs
index a82d313f..5eef5da0 100644
--- a/Gui/ViewModels/Loco/Objects/RoadExtraViewModel.cs
+++ b/Gui/ViewModels/Loco/Objects/RoadExtraViewModel.cs
@@ -1,7 +1,6 @@
using Definitions.ObjectModels.Objects.Road;
using Definitions.ObjectModels.Objects.RoadExtra;
using Gui.Attributes;
-using PropertyModels.ComponentModel.DataAnnotations;
using ReactiveUI;
using System.ComponentModel;
diff --git a/Gui/ViewModels/Loco/Objects/RoadStationViewModel.cs b/Gui/ViewModels/Loco/Objects/RoadStationViewModel.cs
index b13d0d2e..97d79510 100644
--- a/Gui/ViewModels/Loco/Objects/RoadStationViewModel.cs
+++ b/Gui/ViewModels/Loco/Objects/RoadStationViewModel.cs
@@ -3,7 +3,6 @@
using Definitions.ObjectModels.Objects.Shared;
using Definitions.ObjectModels.Types;
using Gui.Attributes;
-using PropertyModels.ComponentModel.DataAnnotations;
using ReactiveUI;
using System.ComponentModel;
diff --git a/Gui/ViewModels/Loco/Objects/RoadViewModel.cs b/Gui/ViewModels/Loco/Objects/RoadViewModel.cs
index 6b0bcc1f..f57594a6 100644
--- a/Gui/ViewModels/Loco/Objects/RoadViewModel.cs
+++ b/Gui/ViewModels/Loco/Objects/RoadViewModel.cs
@@ -1,7 +1,6 @@
using Definitions.ObjectModels.Objects.Road;
using Definitions.ObjectModels.Types;
using Gui.Attributes;
-using PropertyModels.ComponentModel.DataAnnotations;
using ReactiveUI;
using System.ComponentModel;
diff --git a/Gui/ViewModels/Loco/Objects/SteamViewModel.cs b/Gui/ViewModels/Loco/Objects/SteamViewModel.cs
index 8fd2daa7..8610e4df 100644
--- a/Gui/ViewModels/Loco/Objects/SteamViewModel.cs
+++ b/Gui/ViewModels/Loco/Objects/SteamViewModel.cs
@@ -1,6 +1,5 @@
using Definitions.ObjectModels.Objects.Steam;
using Definitions.ObjectModels.Types;
-using PropertyModels.ComponentModel.DataAnnotations;
using System.ComponentModel;
namespace Gui.ViewModels;
diff --git a/Gui/ViewModels/Loco/Objects/TownNames/StringTableEntryViewModel.cs b/Gui/ViewModels/Loco/Objects/TownNames/StringTableEntryViewModel.cs
index 4efa99ea..6cf304f8 100644
--- a/Gui/ViewModels/Loco/Objects/TownNames/StringTableEntryViewModel.cs
+++ b/Gui/ViewModels/Loco/Objects/TownNames/StringTableEntryViewModel.cs
@@ -1,5 +1,4 @@
using Definitions.ObjectModels.Objects.TownNames;
-using PropertyModels.ComponentModel.DataAnnotations;
using System.ComponentModel;
namespace Gui.ViewModels.Loco.Objects.TownNames;
diff --git a/Gui/ViewModels/Loco/Objects/TownNames/TownNamesPreviewViewModel.cs b/Gui/ViewModels/Loco/Objects/TownNames/TownNamesPreviewViewModel.cs
index 2ed39bf1..921c8411 100644
--- a/Gui/ViewModels/Loco/Objects/TownNames/TownNamesPreviewViewModel.cs
+++ b/Gui/ViewModels/Loco/Objects/TownNames/TownNamesPreviewViewModel.cs
@@ -1,6 +1,5 @@
using Definitions.ObjectModels.Objects.TownNames;
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
diff --git a/Gui/ViewModels/Loco/Objects/TrackExtraViewModel.cs b/Gui/ViewModels/Loco/Objects/TrackExtraViewModel.cs
index c5d4fa6c..fc8dcf41 100644
--- a/Gui/ViewModels/Loco/Objects/TrackExtraViewModel.cs
+++ b/Gui/ViewModels/Loco/Objects/TrackExtraViewModel.cs
@@ -1,7 +1,6 @@
using Definitions.ObjectModels.Objects.Track;
using Definitions.ObjectModels.Objects.TrackExtra;
using Gui.Attributes;
-using PropertyModels.ComponentModel.DataAnnotations;
using ReactiveUI;
using System.ComponentModel;
diff --git a/Gui/ViewModels/Loco/Objects/TrackSignalViewModel.cs b/Gui/ViewModels/Loco/Objects/TrackSignalViewModel.cs
index b41f4412..67367e01 100644
--- a/Gui/ViewModels/Loco/Objects/TrackSignalViewModel.cs
+++ b/Gui/ViewModels/Loco/Objects/TrackSignalViewModel.cs
@@ -2,7 +2,6 @@
using Definitions.ObjectModels.Objects.TrackSignal;
using Definitions.ObjectModels.Types;
using Gui.Attributes;
-using PropertyModels.ComponentModel.DataAnnotations;
using ReactiveUI;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
diff --git a/Gui/ViewModels/Loco/Objects/TrackStationViewModel.cs b/Gui/ViewModels/Loco/Objects/TrackStationViewModel.cs
index 9f99550c..0532ed24 100644
--- a/Gui/ViewModels/Loco/Objects/TrackStationViewModel.cs
+++ b/Gui/ViewModels/Loco/Objects/TrackStationViewModel.cs
@@ -3,7 +3,6 @@
using Definitions.ObjectModels.Objects.TrackStation;
using Definitions.ObjectModels.Types;
using Gui.Attributes;
-using PropertyModels.ComponentModel.DataAnnotations;
using ReactiveUI;
using System.ComponentModel;
diff --git a/Gui/ViewModels/Loco/Objects/TrackViewModel.cs b/Gui/ViewModels/Loco/Objects/TrackViewModel.cs
index b0456ecb..96db67a2 100644
--- a/Gui/ViewModels/Loco/Objects/TrackViewModel.cs
+++ b/Gui/ViewModels/Loco/Objects/TrackViewModel.cs
@@ -1,7 +1,6 @@
using Definitions.ObjectModels.Objects.Track;
using Definitions.ObjectModels.Types;
using Gui.Attributes;
-using PropertyModels.ComponentModel.DataAnnotations;
using ReactiveUI;
using System.ComponentModel;
using TrackObject = Definitions.ObjectModels.Objects.Track.TrackObject;
diff --git a/Gui/ViewModels/Loco/Objects/TreeViewModel.cs b/Gui/ViewModels/Loco/Objects/TreeViewModel.cs
index a794ed93..a2fbb288 100644
--- a/Gui/ViewModels/Loco/Objects/TreeViewModel.cs
+++ b/Gui/ViewModels/Loco/Objects/TreeViewModel.cs
@@ -1,6 +1,5 @@
using Definitions.ObjectModels.Objects.Tree;
using Gui.Attributes;
-using PropertyModels.ComponentModel.DataAnnotations;
using ReactiveUI;
using System.ComponentModel;
diff --git a/Gui/ViewModels/Loco/Objects/Vehicle/VehicleViewModel.cs b/Gui/ViewModels/Loco/Objects/Vehicle/VehicleViewModel.cs
index ab0b293a..68105a08 100644
--- a/Gui/ViewModels/Loco/Objects/Vehicle/VehicleViewModel.cs
+++ b/Gui/ViewModels/Loco/Objects/Vehicle/VehicleViewModel.cs
@@ -4,10 +4,7 @@
using Definitions.ObjectModels.Types;
using DynamicData.Binding;
using Gui.Attributes;
-using PropertyModels.ComponentModel.DataAnnotations;
-using PropertyModels.Extensions;
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
diff --git a/Gui/ViewModels/Loco/Objects/WallViewModel.cs b/Gui/ViewModels/Loco/Objects/WallViewModel.cs
index 2bc6d862..2bbc521e 100644
--- a/Gui/ViewModels/Loco/Objects/WallViewModel.cs
+++ b/Gui/ViewModels/Loco/Objects/WallViewModel.cs
@@ -1,5 +1,4 @@
using Definitions.ObjectModels.Objects.Wall;
-using PropertyModels.ComponentModel.DataAnnotations;
namespace Gui.ViewModels;
diff --git a/Gui/ViewModels/Loco/SCV5ViewModel.cs b/Gui/ViewModels/Loco/SCV5ViewModel.cs
index 309609f8..903fe585 100644
--- a/Gui/ViewModels/Loco/SCV5ViewModel.cs
+++ b/Gui/ViewModels/Loco/SCV5ViewModel.cs
@@ -8,9 +8,7 @@
using Gui.Models;
using Index;
using Microsoft.Extensions.Logging;
-using PropertyModels.Extensions;
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
diff --git a/Gui/ViewModels/Loco/SoundEffectsViewModel.cs b/Gui/ViewModels/Loco/SoundEffectsViewModel.cs
index 13c313fa..29563159 100644
--- a/Gui/ViewModels/Loco/SoundEffectsViewModel.cs
+++ b/Gui/ViewModels/Loco/SoundEffectsViewModel.cs
@@ -4,8 +4,6 @@
using Gui.Models;
using Gui.Models.Audio;
using Microsoft.Extensions.Logging;
-using PropertyModels.Extensions;
-using ReactiveUI.Fody.Helpers;
using System;
using System.ComponentModel;
using System.IO;
diff --git a/Gui/ViewModels/Loco/Tutorial/TutorialViewModel.cs b/Gui/ViewModels/Loco/Tutorial/TutorialViewModel.cs
index 10815b48..8427c8ee 100644
--- a/Gui/ViewModels/Loco/Tutorial/TutorialViewModel.cs
+++ b/Gui/ViewModels/Loco/Tutorial/TutorialViewModel.cs
@@ -1,7 +1,6 @@
using Gui.Models;
using Microsoft.Extensions.Logging;
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using System;
using System.Collections.ObjectModel;
using System.IO;
diff --git a/Gui/ViewModels/MainWindowViewModel.cs b/Gui/ViewModels/MainWindowViewModel.cs
index 62781db0..1f717bb6 100644
--- a/Gui/ViewModels/MainWindowViewModel.cs
+++ b/Gui/ViewModels/MainWindowViewModel.cs
@@ -1,19 +1,15 @@
using Avalonia;
using Avalonia.Platform.Storage;
using Common;
-using Core;
using Dat.Data;
-using Definitions.ObjectModels;
+using Definitions.ObjectModels.Graphics;
using DynamicData;
using Gui.Models;
using Gui.ViewModels.Loco.Tutorial;
using Microsoft.Extensions.Logging;
using NuGet.Versioning;
-using PropertyModels.Extensions;
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using SixLabors.ImageSharp;
-using SixLabors.ImageSharp.PixelFormats;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
diff --git a/Gui/ViewModels/MenuItemViewModel.cs b/Gui/ViewModels/MenuItemViewModel.cs
index a467081c..cc985834 100644
--- a/Gui/ViewModels/MenuItemViewModel.cs
+++ b/Gui/ViewModels/MenuItemViewModel.cs
@@ -1,5 +1,4 @@
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using System.Windows.Input;
namespace Gui.ViewModels;
diff --git a/Gui/ViewModels/ObjectSelectionWindowViewModel.cs b/Gui/ViewModels/ObjectSelectionWindowViewModel.cs
index d391c529..d6b12215 100644
--- a/Gui/ViewModels/ObjectSelectionWindowViewModel.cs
+++ b/Gui/ViewModels/ObjectSelectionWindowViewModel.cs
@@ -1,7 +1,6 @@
using DynamicData;
using Index;
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
diff --git a/Gui/ViewModels/Pos3ViewModel.cs b/Gui/ViewModels/Pos3ViewModel.cs
index 9235b6b0..419dba31 100644
--- a/Gui/ViewModels/Pos3ViewModel.cs
+++ b/Gui/ViewModels/Pos3ViewModel.cs
@@ -1,5 +1,4 @@
using Definitions.ObjectModels.Types;
-using PropertyModels.ComponentModel;
namespace Gui.ViewModels;
diff --git a/Gui/ViewModels/RequiredObjectsListViewModel.cs b/Gui/ViewModels/RequiredObjectsListViewModel.cs
index edecf9ad..667347cc 100644
--- a/Gui/ViewModels/RequiredObjectsListViewModel.cs
+++ b/Gui/ViewModels/RequiredObjectsListViewModel.cs
@@ -7,7 +7,6 @@
using Gui.Views;
using Index;
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
diff --git a/Gui/ViewModels/StringTableViewModel.cs b/Gui/ViewModels/StringTableViewModel.cs
index 8cdb8242..dd26cb0f 100644
--- a/Gui/ViewModels/StringTableViewModel.cs
+++ b/Gui/ViewModels/StringTableViewModel.cs
@@ -1,8 +1,6 @@
using Definitions.ObjectModels;
using Definitions.ObjectModels.Types;
-using PropertyModels.Extensions;
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using System;
using System.Collections.Generic;
using System.ComponentModel;
diff --git a/Gui/ViewModels/TabViewPageViewModel.cs b/Gui/ViewModels/TabViewPageViewModel.cs
index 7b9b32fa..a472350d 100644
--- a/Gui/ViewModels/TabViewPageViewModel.cs
+++ b/Gui/ViewModels/TabViewPageViewModel.cs
@@ -1,6 +1,5 @@
using Gui.Models;
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using System;
using System.Collections.ObjectModel;
using System.Linq;
diff --git a/Gui/ViewModels/ViewModelGroup.cs b/Gui/ViewModels/ViewModelGroup.cs
index 3e297263..d6907ff2 100644
--- a/Gui/ViewModels/ViewModelGroup.cs
+++ b/Gui/ViewModels/ViewModelGroup.cs
@@ -1,6 +1,5 @@
using DynamicData;
using ReactiveUI;
-using ReactiveUI.Fody.Helpers;
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
diff --git a/Gui/Views/ExtendedPropertyGrid.cs b/Gui/Views/ExtendedPropertyGrid.cs
index 6b930f63..09ac28e0 100644
--- a/Gui/Views/ExtendedPropertyGrid.cs
+++ b/Gui/Views/ExtendedPropertyGrid.cs
@@ -1,6 +1,4 @@
using Avalonia.Controls;
-using Avalonia.PropertyGrid.Controls;
-using Avalonia.PropertyGrid.Controls.Factories;
using Definitions.ObjectModels.Types;
using Gui.Attributes;
using Gui.ViewModels;
diff --git a/Gui/Views/FolderTreeView.axaml.cs b/Gui/Views/FolderTreeView.axaml.cs
index 35d3976d..78e1d06a 100644
--- a/Gui/Views/FolderTreeView.axaml.cs
+++ b/Gui/Views/FolderTreeView.axaml.cs
@@ -2,7 +2,6 @@
using System.Reactive.Disposables;
using System.Reactive.Disposables.Fluent;
using Avalonia.Controls;
-using Avalonia.Controls.Selection;
using Avalonia.Controls.Templates;
using Gui.Models;
using Gui.ViewModels;
diff --git a/Gui/Views/ImageTableView.axaml.cs b/Gui/Views/ImageTableView.axaml.cs
index b7e5f7e8..2515db8e 100644
--- a/Gui/Views/ImageTableView.axaml.cs
+++ b/Gui/Views/ImageTableView.axaml.cs
@@ -1,5 +1,4 @@
using Avalonia.Controls;
-using Avalonia.Controls.PanAndZoom;
using Avalonia.Input;
namespace Gui.Views;
diff --git a/ObjectEditor.sln b/ObjectEditor.sln
index dac761a7..edeb4a8b 100644
--- a/ObjectEditor.sln
+++ b/ObjectEditor.sln
@@ -42,10 +42,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GuiUpdater", "GuiUpdater\Gu
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DatabaseTools", "DatabaseTools\DatabaseTools.csproj", "{D8B4E93C-CDB5-4E27-902E-04DAE06075AE}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core", "Core\Core.csproj", "{A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}"
-EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cli", "Cli\Cli.csproj", "{8F8A85E3-5568-4B19-97A1-068E5C025CC0}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared", "Shared\Shared.csproj", "{DD2A6CB9-90C1-4F93-9AA6-CCD6F3CC425C}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -176,18 +176,6 @@ Global
{D8B4E93C-CDB5-4E27-902E-04DAE06075AE}.Release|x64.Build.0 = Release|Any CPU
{D8B4E93C-CDB5-4E27-902E-04DAE06075AE}.Release|x86.ActiveCfg = Release|Any CPU
{D8B4E93C-CDB5-4E27-902E-04DAE06075AE}.Release|x86.Build.0 = Release|Any CPU
- {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Debug|x64.ActiveCfg = Debug|Any CPU
- {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Debug|x64.Build.0 = Debug|Any CPU
- {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Debug|x86.ActiveCfg = Debug|Any CPU
- {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Debug|x86.Build.0 = Debug|Any CPU
- {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Release|Any CPU.Build.0 = Release|Any CPU
- {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Release|x64.ActiveCfg = Release|Any CPU
- {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Release|x64.Build.0 = Release|Any CPU
- {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Release|x86.ActiveCfg = Release|Any CPU
- {A2122F3C-A32E-4E4D-94C8-36E9B4C811EB}.Release|x86.Build.0 = Release|Any CPU
{8F8A85E3-5568-4B19-97A1-068E5C025CC0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8F8A85E3-5568-4B19-97A1-068E5C025CC0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8F8A85E3-5568-4B19-97A1-068E5C025CC0}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -200,6 +188,18 @@ Global
{8F8A85E3-5568-4B19-97A1-068E5C025CC0}.Release|x64.Build.0 = Release|Any CPU
{8F8A85E3-5568-4B19-97A1-068E5C025CC0}.Release|x86.ActiveCfg = Release|Any CPU
{8F8A85E3-5568-4B19-97A1-068E5C025CC0}.Release|x86.Build.0 = Release|Any CPU
+ {DD2A6CB9-90C1-4F93-9AA6-CCD6F3CC425C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {DD2A6CB9-90C1-4F93-9AA6-CCD6F3CC425C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {DD2A6CB9-90C1-4F93-9AA6-CCD6F3CC425C}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {DD2A6CB9-90C1-4F93-9AA6-CCD6F3CC425C}.Debug|x64.Build.0 = Debug|Any CPU
+ {DD2A6CB9-90C1-4F93-9AA6-CCD6F3CC425C}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {DD2A6CB9-90C1-4F93-9AA6-CCD6F3CC425C}.Debug|x86.Build.0 = Debug|Any CPU
+ {DD2A6CB9-90C1-4F93-9AA6-CCD6F3CC425C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {DD2A6CB9-90C1-4F93-9AA6-CCD6F3CC425C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {DD2A6CB9-90C1-4F93-9AA6-CCD6F3CC425C}.Release|x64.ActiveCfg = Release|Any CPU
+ {DD2A6CB9-90C1-4F93-9AA6-CCD6F3CC425C}.Release|x64.Build.0 = Release|Any CPU
+ {DD2A6CB9-90C1-4F93-9AA6-CCD6F3CC425C}.Release|x86.ActiveCfg = Release|Any CPU
+ {DD2A6CB9-90C1-4F93-9AA6-CCD6F3CC425C}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/ObjectService/Program.cs b/ObjectService/Program.cs
index 4c052566..07df6995 100644
--- a/ObjectService/Program.cs
+++ b/ObjectService/Program.cs
@@ -1,5 +1,5 @@
using Definitions.Database;
-using Definitions.ObjectModels;
+using Definitions.ObjectModels.Graphics;
using Microsoft.AspNetCore.Authentication.BearerToken;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.HttpLogging;
diff --git a/ObjectService/RouteHandlers/TableHandlers/V1RouteHandler.cs b/ObjectService/RouteHandlers/TableHandlers/V1RouteHandler.cs
index dafd4bca..a0e6f0e8 100644
--- a/ObjectService/RouteHandlers/TableHandlers/V1RouteHandler.cs
+++ b/ObjectService/RouteHandlers/TableHandlers/V1RouteHandler.cs
@@ -6,7 +6,6 @@
using Definitions.Database;
using Definitions.DTO;
using Definitions.DTO.Mappers;
-using Definitions.ObjectModels;
using Definitions.ObjectModels.Graphics;
using Definitions.ObjectModels.Objects.Vehicle;
using Definitions.ObjectModels.Types;
diff --git a/Core/Objects/LocoObjectFile.cs b/Shared/Files/LocoObjectFile.cs
similarity index 86%
rename from Core/Objects/LocoObjectFile.cs
rename to Shared/Files/LocoObjectFile.cs
index 4243f86e..711121cb 100644
--- a/Core/Objects/LocoObjectFile.cs
+++ b/Shared/Files/LocoObjectFile.cs
@@ -1,6 +1,6 @@
using Dat.Types;
using Definitions.ObjectModels;
-namespace Core.Objects;
+namespace Shared.Files;
public sealed record LocoObjectFile(string FileName, DatHeaderInfo DatInfo, LocoObject LocoObject);
diff --git a/Core/Objects/ObjectFile.cs b/Shared/Files/ObjectFile.cs
similarity index 98%
rename from Core/Objects/ObjectFile.cs
rename to Shared/Files/ObjectFile.cs
index 742c65ca..32581e40 100644
--- a/Core/Objects/ObjectFile.cs
+++ b/Shared/Files/ObjectFile.cs
@@ -1,12 +1,12 @@
using Dat.Converters;
using Dat.Data;
using Dat.FileParsing;
-using Definitions.ObjectModels;
+using Definitions.ObjectModels.Graphics;
using Definitions.ObjectModels.Types;
using Microsoft.Extensions.Logging;
using System.Text.Json;
-namespace Core.Objects;
+namespace Shared.Files;
public static class ObjectFile
{
diff --git a/Core/Operations/BatchProcessor.cs b/Shared/Operations/BatchProcessor.cs
similarity index 97%
rename from Core/Operations/BatchProcessor.cs
rename to Shared/Operations/BatchProcessor.cs
index d28506cb..804633d9 100644
--- a/Core/Operations/BatchProcessor.cs
+++ b/Shared/Operations/BatchProcessor.cs
@@ -1,9 +1,9 @@
-using Core.Objects;
using Dat.Data;
-using Definitions.ObjectModels;
+using Definitions.ObjectModels.Graphics;
using Microsoft.Extensions.Logging;
+using Shared.Files;
-namespace Core.Operations;
+namespace Shared.Operations;
public sealed record BatchItemResult(string FileName, bool Succeeded, string Message);
diff --git a/Core/Operations/ObjectOperations.cs b/Shared/Operations/ObjectOperations.cs
similarity index 95%
rename from Core/Operations/ObjectOperations.cs
rename to Shared/Operations/ObjectOperations.cs
index c15d5603..8c046201 100644
--- a/Core/Operations/ObjectOperations.cs
+++ b/Shared/Operations/ObjectOperations.cs
@@ -1,9 +1,7 @@
-using Core.Graphics;
-using Core.Objects;
using Definitions.ObjectModels;
using Definitions.ObjectModels.Graphics;
-namespace Core.Operations;
+namespace Shared.Operations;
public static class ObjectOperations
{
diff --git a/Shared/Shared.csproj b/Shared/Shared.csproj
new file mode 100644
index 00000000..418a447e
--- /dev/null
+++ b/Shared/Shared.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
diff --git a/Core/Validation/ObjectValidation.cs b/Shared/Validation/ObjectValidation.cs
similarity index 98%
rename from Core/Validation/ObjectValidation.cs
rename to Shared/Validation/ObjectValidation.cs
index 884c26e1..d04ec7de 100644
--- a/Core/Validation/ObjectValidation.cs
+++ b/Shared/Validation/ObjectValidation.cs
@@ -1,10 +1,10 @@
-using Core.Objects;
using Dat.Data;
using Definitions.ObjectModels;
using Microsoft.Extensions.Logging;
+using Shared.Files;
using System.ComponentModel.DataAnnotations;
-namespace Core.Validation;
+namespace Shared.Validation;
public static class ObjectValidation
{
diff --git a/Tests/IdempotenceTests.cs b/Tests/IdempotenceTests.cs
index 15cfef39..6e12f66f 100644
--- a/Tests/IdempotenceTests.cs
+++ b/Tests/IdempotenceTests.cs
@@ -1,7 +1,5 @@
-using Core;
using Dat.Converters;
using Dat.FileParsing;
-using Definitions.ObjectModels;
using Definitions.ObjectModels.Graphics;
using NUnit.Framework;
using NUnit.Framework.Internal;
diff --git a/Tests/ImagePaletteConversionTests.cs b/Tests/ImagePaletteConversionTests.cs
index d77ef14c..2b1572bc 100644
--- a/Tests/ImagePaletteConversionTests.cs
+++ b/Tests/ImagePaletteConversionTests.cs
@@ -1,5 +1,4 @@
using Dat.FileParsing;
-using Definitions.ObjectModels;
using Definitions.ObjectModels.Graphics;
using Microsoft.Extensions.Logging;
using NUnit.Framework;
diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj
index fb3cd9b4..98e8b195 100644
--- a/Tests/Tests.csproj
+++ b/Tests/Tests.csproj
@@ -27,7 +27,6 @@
-
From 2dce4b2a1e62cbb17d4bc9515769db81369edf2d Mon Sep 17 00:00:00 2001
From: Benjamin Sutas
Date: Sun, 2 Aug 2026 13:28:25 +1000
Subject: [PATCH 3/3] review fixes
---
Cli/Program.cs | 4 +-
.../Graphics/GraphicsElementOperations.cs | 8 +-
.../Graphics/ImageTableGroupConfiguration.cs | 6 +-
.../Graphics/ImageTableGroupLoader.cs | 135 ++++++++++++++++++
.../Graphics/ImageTableGrouper.cs | 61 +-------
.../ImageTableGroupsConfig-Zehmatt.cs | 77 ----------
Gui/Models/ObjectEditorContext.cs | 5 +-
Shared/Files/ObjectFile.cs | 33 +++--
Shared/Operations/ObjectOperations.cs | 7 +
9 files changed, 186 insertions(+), 150 deletions(-)
create mode 100644 Definitions/ObjectModels/Graphics/ImageTableGroupLoader.cs
delete mode 100644 Definitions/ObjectModels/Graphics/ImageTableGroupsConfig-Zehmatt.cs
diff --git a/Cli/Program.cs b/Cli/Program.cs
index 2fc12b26..c9acde1d 100644
--- a/Cli/Program.cs
+++ b/Cli/Program.cs
@@ -1,6 +1,6 @@
using Cli;
using Cli.Commands;
-using Core;
+using Definitions.ObjectModels.Graphics;
using Microsoft.Extensions.Logging;
ICommand[] commands =
@@ -55,7 +55,7 @@
return ExitCodes.UsageError;
}
-await ImageTableGroupsConfig.LoadDefaultAsync(logger);
+await ImageTableGroupLoader.LoadDefaultAsync(logger);
try
{
diff --git a/Definitions/ObjectModels/Graphics/GraphicsElementOperations.cs b/Definitions/ObjectModels/Graphics/GraphicsElementOperations.cs
index a56d5e88..ba89b5c5 100644
--- a/Definitions/ObjectModels/Graphics/GraphicsElementOperations.cs
+++ b/Definitions/ObjectModels/Graphics/GraphicsElementOperations.cs
@@ -15,7 +15,13 @@ public static void SetImage(this GraphicsElement element, Image image, P
if (!ReferenceEquals(element.Image, image))
{
- element.Image?.Dispose();
+ if (element.Image != null
+ && !ReferenceEquals(element.Image, ImageTableHelpers.ErrorImage)
+ && !ReferenceEquals(element.Image, ImageTableHelpers.OnePixelTransparent))
+ {
+ element.Image.Dispose();
+ }
+
element.Image = image;
}
diff --git a/Definitions/ObjectModels/Graphics/ImageTableGroupConfiguration.cs b/Definitions/ObjectModels/Graphics/ImageTableGroupConfiguration.cs
index 3d537981..1c2041dc 100644
--- a/Definitions/ObjectModels/Graphics/ImageTableGroupConfiguration.cs
+++ b/Definitions/ObjectModels/Graphics/ImageTableGroupConfiguration.cs
@@ -2,18 +2,18 @@
namespace Definitions.ObjectModels.Graphics;
-internal sealed record ImageTableGroupDefinition(
+public sealed record ImageTableGroupDefinition(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("start")] int Start,
[property: JsonPropertyName("chunkSize")] int? ChunkSize = null
);
-internal sealed record ImageTableGroupConfigurationType(
+public sealed record ImageTableGroupConfigurationType(
[property: JsonPropertyName("objectType")] string ObjectType,
[property: JsonPropertyName("groups")] List Groups
);
-internal sealed record ImageTableGroupConfiguration(
+public sealed record ImageTableGroupConfiguration(
[property: JsonPropertyName("version"),] string Version,
[property: JsonPropertyName("definitions")] List Definitions
);
diff --git a/Definitions/ObjectModels/Graphics/ImageTableGroupLoader.cs b/Definitions/ObjectModels/Graphics/ImageTableGroupLoader.cs
new file mode 100644
index 00000000..b49b35e4
--- /dev/null
+++ b/Definitions/ObjectModels/Graphics/ImageTableGroupLoader.cs
@@ -0,0 +1,135 @@
+using Common;
+using Common.Json;
+using Common.Logging;
+using Definitions.ObjectModels.Types;
+using Microsoft.Extensions.Logging;
+using NuGet.Versioning;
+using System.Reflection;
+using System.Text.Json;
+
+using GroupConfigDict = System.Collections.Generic.IReadOnlyDictionary<
+ Definitions.ObjectModels.Types.ObjectType,
+ Definitions.ObjectModels.Graphics.ImageTableGroupConfigurationType>;
+
+namespace Definitions.ObjectModels.Graphics;
+
+public static class ImageTableGroupLoader
+{
+ public const string FileName = "imageTableGroups.json";
+ public const string EmbeddedResourceName = "Core.ImageTableGroups.json";
+
+ public static SemanticVersion? ReadImageTableGroupVersion(Logger logger, string imageTableGroupsFileName)
+ {
+ var existingText = File.ReadAllText(imageTableGroupsFileName);
+ if (string.IsNullOrWhiteSpace(existingText))
+ {
+ logger.LogError("Existing image table group configuration file is empty");
+ return null;
+ }
+
+ try
+ {
+ using var doc = JsonDocument.Parse(existingText);
+ if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("version", out var verProp) && verProp.ValueKind == JsonValueKind.String)
+ {
+ var existingVersionText = verProp.GetString();
+ if (!string.IsNullOrEmpty(existingVersionText) && SemanticVersion.TryParse(existingVersionText, out var existingVersion))
+ {
+ logger.LogDebug("Existing image table group configuration version: {version}", existingVersion);
+ return existingVersion;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error occurred while reading image table group version");
+ }
+
+ return null;
+ }
+
+ public static GroupConfigDict? LoadGroupConfigurationJson(ILogger logger, string json)
+ {
+ try
+ {
+ var itgc = JsonSerializer.Deserialize(json, JsonFile.DefaultSerializerOptions);
+ return itgc?.Definitions
+ .Select(configuration => (configuration, success: Enum.TryParse(configuration.ObjectType, ignoreCase: true, out var objectType), objectType))
+ .Where(pair => pair.success)
+ .ToDictionary(pair => pair.objectType, pair => pair.configuration) ?? [];
+ }
+ catch (JsonException ex)
+ {
+ logger.LogError(ex, "Image table group config is not valid JSON or version could not be read");
+ }
+
+ return null;
+ }
+
+ public static async Task ReadDefaultAsync(ILogger logger)
+ {
+ try
+ {
+ await using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(EmbeddedResourceName);
+ if (stream == null)
+ {
+ logger.LogError("Default image table group configuration resource not found");
+ return null;
+ }
+
+ using var reader = new StreamReader(stream, leaveOpen: true);
+ return await reader.ReadToEndAsync();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to read the default image table group config");
+ return null;
+ }
+ }
+
+ public static async Task LoadDefaultAsync(ILogger logger)
+ {
+ var json = await ReadDefaultAsync(logger);
+ if (json == null)
+ {
+ return null;
+ }
+
+ return LoadGroupConfigurationJson(logger, json);
+ }
+
+ public static async Task EnsureOnDiskAndLoadAsync(Logger logger, string pathName)
+ {
+ logger.LogInformation("Attempting to load image table group config from '{ImageTableGroupsFileName}'", pathName);
+
+ var defaultImageTableGroups = await ReadDefaultAsync(logger);
+ if (defaultImageTableGroups == null)
+ {
+ logger.LogError("Failed to load default image table group configuration - groups will not be automatically created for existing images. Please ensure the default config file is present and valid at '{ImageTableGroupsFileName}'", pathName);
+ return null;
+ }
+
+ var currentImageTableGroups = defaultImageTableGroups;
+
+ if (File.Exists(pathName))
+ {
+ var jsonVersion = ReadImageTableGroupVersion(logger, pathName);
+ if (jsonVersion == null || jsonVersion < VersionHelpers.GetCurrentAppVersion())
+ {
+ await File.WriteAllTextAsync(pathName, defaultImageTableGroups);
+ currentImageTableGroups = defaultImageTableGroups;
+ }
+ else
+ {
+ currentImageTableGroups = await File.ReadAllTextAsync(pathName);
+ }
+ }
+ else
+ {
+ await File.WriteAllTextAsync(pathName, defaultImageTableGroups);
+ currentImageTableGroups = defaultImageTableGroups;
+ }
+
+ return LoadGroupConfigurationJson(logger, currentImageTableGroups);
+ }
+}
diff --git a/Definitions/ObjectModels/Graphics/ImageTableGrouper.cs b/Definitions/ObjectModels/Graphics/ImageTableGrouper.cs
index 0413a9e4..f22e1133 100644
--- a/Definitions/ObjectModels/Graphics/ImageTableGrouper.cs
+++ b/Definitions/ObjectModels/Graphics/ImageTableGrouper.cs
@@ -1,20 +1,22 @@
using Common;
-using Common.Json;
-using Common.Logging;
using Definitions.ObjectModels.Objects.Competitor;
using Definitions.ObjectModels.Objects.LevelCrossing;
using Definitions.ObjectModels.Objects.Vehicle;
using Definitions.ObjectModels.Types;
-using Microsoft.Extensions.Logging;
-using NuGet.Versioning;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
-using System.Text.Json;
+
+using GroupConfigDict = System.Collections.Generic.IReadOnlyDictionary<
+ Definitions.ObjectModels.Types.ObjectType,
+ Definitions.ObjectModels.Graphics.ImageTableGroupConfigurationType>;
namespace Definitions.ObjectModels.Graphics;
public static class ImageTableGrouper
{
+
+ private static GroupConfigDict GroupConfigurations = new Dictionary();
+
public static ImageTable CreateImageTable(ILocoStruct obj, ObjectType objectType, List imageList)
{
var originalCount = imageList.Count;
@@ -202,54 +204,6 @@ private static IEnumerable CreateGroupsFromConfig(ImageTableGro
}
}
- public static SemanticVersion? ReadImageTableGroupVersion(Logger logger, string imageTableGroupsFileName)
- {
- var existingText = File.ReadAllText(imageTableGroupsFileName);
- if (string.IsNullOrWhiteSpace(existingText))
- {
- logger.LogError("Existing image table group configuration file is empty");
- return null;
- }
-
- try
- {
- using var doc = JsonDocument.Parse(existingText);
- if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("version", out var verProp) && verProp.ValueKind == JsonValueKind.String)
- {
- var existingVersionText = verProp.GetString();
- if (!string.IsNullOrEmpty(existingVersionText) && SemanticVersion.TryParse(existingVersionText, out var existingVersion))
- {
- logger.LogDebug("Existing image table group configuration version: {version}", existingVersion);
- return existingVersion;
- }
- }
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "Error occurred while reading image table group version");
- }
-
- return null;
- }
-
- public static void LoadGroupConfigurationJson(ILogger logger, string json)
- {
- try
- {
- var itgc = JsonSerializer.Deserialize(json, JsonFile.DefaultSerializerOptions);
- GroupConfigurations = itgc?.Definitions
- .Select(configuration => (configuration, success: Enum.TryParse(configuration.ObjectType, ignoreCase: true, out var objectType), objectType))
- .Where(pair => pair.success)
- .ToDictionary(pair => pair.objectType, pair => pair.configuration) ?? [];
- }
- catch (JsonException ex)
- {
- logger.LogError(ex, "Image table group config is not valid JSON or version could not be read");
- }
- }
-
- private static IReadOnlyDictionary GroupConfigurations = new Dictionary();
-
private static IEnumerable CreateLevelCrossingGroups2(LevelCrossingObject model, List imageList)
{
for (var i = 0; i < 8; ++i)
@@ -477,5 +431,4 @@ private static IEnumerable CreateVehicleGroups(VehicleObject mo
yield return new("", remainder);
}
}
-
}
diff --git a/Definitions/ObjectModels/Graphics/ImageTableGroupsConfig-Zehmatt.cs b/Definitions/ObjectModels/Graphics/ImageTableGroupsConfig-Zehmatt.cs
deleted file mode 100644
index f7bacc4f..00000000
--- a/Definitions/ObjectModels/Graphics/ImageTableGroupsConfig-Zehmatt.cs
+++ /dev/null
@@ -1,77 +0,0 @@
-using Common;
-using Definitions.ObjectModels.Graphics;
-using Microsoft.Extensions.Logging;
-using System.Reflection;
-
-namespace Core;
-
-public static class ImageTableGroupsConfig
-{
- public const string FileName = "imageTableGroups.json";
- public const string EmbeddedResourceName = "Core.ImageTableGroups.json";
-
- public static async Task ReadDefaultAsync(ILogger logger)
- {
- try
- {
- await using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(EmbeddedResourceName);
- if (stream == null)
- {
- logger.LogError("Default image table group configuration resource not found");
- return null;
- }
-
- using var reader = new StreamReader(stream, leaveOpen: true);
- return await reader.ReadToEndAsync();
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "Failed to read the default image table group config");
- return null;
- }
- }
-
- public static async Task LoadDefaultAsync(ILogger logger)
- {
- var json = await ReadDefaultAsync(logger);
- if (json == null)
- {
- return;
- }
-
- ImageTableGrouper.LoadGroupConfigurationJson(logger, json);
- }
-
- public static async Task EnsureOnDiskAndLoadAsync(Common.Logging.Logger logger, string pathName)
- {
- logger.LogInformation("Attempting to load image table group config from '{ImageTableGroupsFileName}'", pathName);
-
- var defaultImageTableGroups = await ReadDefaultAsync(logger);
- if (defaultImageTableGroups == null)
- {
- logger.LogError("Failed to load default image table group configuration - groups will not be automatically created for existing images. Please ensure the default config file is present and valid at '{ImageTableGroupsFileName}'", pathName);
- return;
- }
-
- var currentImageTableGroups = defaultImageTableGroups;
-
- if (File.Exists(pathName))
- {
- var jsonVersion = ImageTableGrouper.ReadImageTableGroupVersion(logger, pathName);
- if (jsonVersion == null || jsonVersion < VersionHelpers.GetCurrentAppVersion())
- {
- currentImageTableGroups = defaultImageTableGroups;
- }
- else
- {
- await File.WriteAllTextAsync(pathName, defaultImageTableGroups);
- }
- }
- else
- {
- await File.WriteAllTextAsync(pathName, defaultImageTableGroups);
- }
-
- ImageTableGrouper.LoadGroupConfigurationJson(logger, currentImageTableGroups);
- }
-}
diff --git a/Gui/Models/ObjectEditorContext.cs b/Gui/Models/ObjectEditorContext.cs
index 220fbf6e..2f6fb7e6 100644
--- a/Gui/Models/ObjectEditorContext.cs
+++ b/Gui/Models/ObjectEditorContext.cs
@@ -1,7 +1,6 @@
using Avalonia.Threading;
using Common;
using Common.Logging;
-using Core;
using Dat.Converters;
using Dat.FileParsing;
using Dat.Types;
@@ -54,7 +53,7 @@ public class ObjectEditorContext : IDisposable, IAsyncDisposable
public const string ApplicationName = "OpenLoco Object Editor";
public const string SettingsFileName = "settings.json"; // "settings-dev.json" for dev, "settings.json" for prod
public const string LoggingFileName = "objectEditor.log";
- public const string ImageTableGroupsFileName = ImageTableGroupsConfig.FileName;
+ public const string ImageTableGroupsFileName = ImageTableGroupLoader.FileName;
public string DefaultConfigFolder { get; set; } = "config";
public string DefaultDownloadFolder { get; set; } = "downloads";
@@ -169,7 +168,7 @@ string InitialiseDirectory(string folder, string defaultName)
}
public async Task LoadAsync()
- => await ImageTableGroupsConfig.EnsureOnDiskAndLoadAsync(Logger, ImageTableGroupsPathName);
+ => await ImageTableGroupLoader.EnsureOnDiskAndLoadAsync(Logger, ImageTableGroupsPathName);
public bool TryLoadObject(FileSystemItem filesystemItem, out LocoUIObjectModel? uiLocoFile)
{
diff --git a/Shared/Files/ObjectFile.cs b/Shared/Files/ObjectFile.cs
index 32581e40..918fcf6c 100644
--- a/Shared/Files/ObjectFile.cs
+++ b/Shared/Files/ObjectFile.cs
@@ -52,17 +52,30 @@ public static bool SaveDat(LocoObjectFile file, string fileName, ILogger logger,
}
var header = file.DatInfo.S5Header;
+ var effectiveName = objectName ?? header.Name;
+ var effectiveSource = objectSource ?? header.ObjectSource.Convert(header.Name, header.Checksum);
+ var effectiveEncoding = encoding ?? file.DatInfo.ObjectHeader.Encoding;
- SawyerStreamWriter.Save(
- fileName,
- objectName ?? header.Name,
- objectSource ?? header.ObjectSource.Convert(header.Name, header.Checksum),
- encoding ?? file.DatInfo.ObjectHeader.Encoding,
- file.LocoObject,
- logger,
- allowSavingAsVanillaObject);
-
- return true;
+ try
+ {
+ logger.LogInformation("Writing \"{ObjName}\" to {Filename}", effectiveName, fileName);
+ var bytes = SawyerStreamWriter.WriteLocoObject(
+ effectiveName,
+ file.LocoObject.ObjectType,
+ effectiveSource,
+ effectiveEncoding,
+ logger,
+ file.LocoObject,
+ allowSavingAsVanillaObject).ToArray();
+ File.WriteAllBytes(fileName, bytes);
+ logger.LogInformation("{ObjName} successfully saved to {Filename}", effectiveName, fileName);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "An error occurred while saving {ObjName}", effectiveName);
+ return false;
+ }
}
public static bool SaveJson(LocoObjectFile file, string fileName, ILogger logger)
diff --git a/Shared/Operations/ObjectOperations.cs b/Shared/Operations/ObjectOperations.cs
index 8c046201..370c13f2 100644
--- a/Shared/Operations/ObjectOperations.cs
+++ b/Shared/Operations/ObjectOperations.cs
@@ -22,6 +22,13 @@ public static int StripImages(LocoObject locoObject)
foreach (var element in group.GraphicsElements)
{
element.Image?.Dispose();
+ if (element.Image != null
+ && !ReferenceEquals(element.Image, ImageTableHelpers.ErrorImage)
+ && !ReferenceEquals(element.Image, ImageTableHelpers.OnePixelTransparent))
+ {
+ element.Image.Dispose();
+ }
+
element.Image = null;
}
}