diff --git a/Cli/Cli.csproj b/Cli/Cli.csproj new file mode 100644 index 00000000..64b6bad3 --- /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..b8b17642 --- /dev/null +++ b/Cli/CommandContext.cs @@ -0,0 +1,131 @@ +using Dat.Data; +using Definitions.ObjectModels.Graphics; +using Microsoft.Extensions.Logging; +using Shared.Files; +using Shared.Operations; + +namespace Cli; + +public sealed class CommandContext(CommandLine commandLine, ILogger logger) +{ + public CommandLine Args { get; } = commandLine; + + public ILogger Logger { get; } = logger; + + public PaletteMap PaletteMap + => field ??= 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 static 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..a9e4170f --- /dev/null +++ b/Cli/CommandLine.cs @@ -0,0 +1,66 @@ +namespace Cli; + +public sealed class CommandLine +{ + readonly List positionals = []; + readonly Dictionary options = [with(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..67faa6c9 --- /dev/null +++ b/Cli/Commands/CropCommand.cs @@ -0,0 +1,50 @@ +using Shared.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(CommandContext.Report(result)); + } +} diff --git a/Cli/Commands/ExportImagesCommand.cs b/Cli/Commands/ExportImagesCommand.cs new file mode 100644 index 00000000..892fbc02 --- /dev/null +++ b/Cli/Commands/ExportImagesCommand.cs @@ -0,0 +1,76 @@ +using Core.Graphics; +using Microsoft.Extensions.Logging; +using Shared.Files; + +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..1cc4b862 --- /dev/null +++ b/Cli/Commands/ImportImagesCommand.cs @@ -0,0 +1,93 @@ +using Core.Graphics; +using Microsoft.Extensions.Logging; +using Shared.Files; + +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..f4a5a102 --- /dev/null +++ b/Cli/Commands/InfoCommand.cs @@ -0,0 +1,99 @@ +using Shared.Files; +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..9441a08a --- /dev/null +++ b/Cli/Commands/OffsetsCommand.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.Logging; +using Shared.Operations; + +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(CommandContext.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..c7897fae --- /dev/null +++ b/Cli/Commands/ReencodeCommand.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.Logging; +using Shared.Operations; + +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(CommandContext.Report(result)); + } +} diff --git a/Cli/Commands/StripImagesCommand.cs b/Cli/Commands/StripImagesCommand.cs new file mode 100644 index 00000000..96af720c --- /dev/null +++ b/Cli/Commands/StripImagesCommand.cs @@ -0,0 +1,50 @@ +using Shared.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(CommandContext.Report(result)); + } +} diff --git a/Cli/Commands/ValidateCommand.cs b/Cli/Commands/ValidateCommand.cs new file mode 100644 index 00000000..78ca2167 --- /dev/null +++ b/Cli/Commands/ValidateCommand.cs @@ -0,0 +1,73 @@ +using Shared.Files; +using Shared.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..80c7ea2b --- /dev/null +++ b/Cli/ConsoleLogger.cs @@ -0,0 +1,45 @@ +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:", + LogLevel.None => string.Empty, + _ => throw new NotImplementedException(), + }; +} 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..c9acde1d --- /dev/null +++ b/Cli/Program.cs @@ -0,0 +1,94 @@ +using Cli; +using Cli.Commands; +using Definitions.ObjectModels.Graphics; +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 ImageTableGroupLoader.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/Core/Core.csproj b/Core/Core.csproj new file mode 100644 index 00000000..d39f3059 --- /dev/null +++ b/Core/Core.csproj @@ -0,0 +1,27 @@ + + + + Library + net10.0 + preview + enable + enable + latest + Core + + + + + + + + + + + + + + + + + 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/Gui/Assets/palette.png b/Definitions/Assets/palette.png similarity index 100% rename from Gui/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/Gui/ViewModels/Graphics/GraphicsElementJson.cs b/Definitions/ObjectModels/Graphics/GraphicsElementJson.cs similarity index 92% rename from Gui/ViewModels/Graphics/GraphicsElementJson.cs rename to Definitions/ObjectModels/Graphics/GraphicsElementJson.cs index 67f33b45..20e1984a 100644 --- a/Gui/ViewModels/Graphics/GraphicsElementJson.cs +++ b/Definitions/ObjectModels/Graphics/GraphicsElementJson.cs @@ -1,7 +1,6 @@ -using Definitions.ObjectModels.Graphics; using System.Text.Json.Serialization; -namespace Gui.ViewModels.Graphics; +namespace Definitions.ObjectModels.Graphics; public record GraphicsElementJson( [property: JsonPropertyName("path")] string Path, diff --git a/Definitions/ObjectModels/Graphics/GraphicsElementOperations.cs b/Definitions/ObjectModels/Graphics/GraphicsElementOperations.cs new file mode 100644 index 00000000..ba89b5c5 --- /dev/null +++ b/Definitions/ObjectModels/Graphics/GraphicsElementOperations.cs @@ -0,0 +1,162 @@ +using Definitions.ObjectModels.Graphics; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace Definitions.ObjectModels.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)) + { + if (element.Image != null + && !ReferenceEquals(element.Image, ImageTableHelpers.ErrorImage) + && !ReferenceEquals(element.Image, ImageTableHelpers.OnePixelTransparent)) + { + 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/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/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..93c76141 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 { + + public 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/ImageTableIo.cs b/Definitions/ObjectModels/Graphics/ImageTableIo.cs new file mode 100644 index 00000000..46318a60 --- /dev/null +++ b/Definitions/ObjectModels/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/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/Definitions/ObjectModels/Graphics/PaletteMapLoader.cs b/Definitions/ObjectModels/Graphics/PaletteMapLoader.cs new file mode 100644 index 00000000..679b2891 --- /dev/null +++ b/Definitions/ObjectModels/Graphics/PaletteMapLoader.cs @@ -0,0 +1,26 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using System.Reflection; + +namespace Definitions.ObjectModels.Graphics; + +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/Gui/Gui.csproj b/Gui/Gui.csproj index c9abd54a..19f92a02 100644 --- a/Gui/Gui.csproj +++ b/Gui/Gui.csproj @@ -43,11 +43,6 @@ - - - Gui.ImageTableGroups.json - - @@ -76,6 +71,7 @@ + diff --git a/Gui/Models/ObjectEditorContext.cs b/Gui/Models/ObjectEditorContext.cs index 3bddcef5..2f6fb7e6 100644 --- a/Gui/Models/ObjectEditorContext.cs +++ b/Gui/Models/ObjectEditorContext.cs @@ -18,7 +18,6 @@ using System.Collections.ObjectModel; using System.IO; using System.Linq; -using System.Reflection; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -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 = "imageTableGroups.json"; + public const string ImageTableGroupsFileName = ImageTableGroupLoader.FileName; public string DefaultConfigFolder { get; set; } = "config"; public string DefaultDownloadFolder { get; set; } = "downloads"; @@ -169,64 +168,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 ImageTableGroupLoader.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..d6b6c9dd 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; @@ -406,7 +406,7 @@ Task ReloadImageTableGroupingAsync() { Logger.LogInformation("Reloading image table grouping from {ConfigFilePath}", groupingConfigFilePath); var json = File.ReadAllText(groupingConfigFilePath); - ImageTableGrouper.LoadGroupConfigurationJson(Logger, json); + ImageTableGrouper.GroupConfigurations = ImageTableGroupLoader.LoadGroupConfigurationJson(Logger, json); } else { @@ -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..a7009795 100644 --- a/Gui/ViewModels/Graphics/ImageViewModel.cs +++ b/Gui/ViewModels/Graphics/ImageViewModel.cs @@ -1,5 +1,4 @@ using Avalonia.Media.Imaging; -using Definitions.ObjectModels; using Definitions.ObjectModels.Graphics; using PropertyModels.ComponentModel; using PropertyModels.ComponentModel.DataAnnotations; @@ -196,7 +195,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 +209,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..e2025d90 100644 --- a/Gui/ViewModels/Loco/ObjectEditorViewModel.cs +++ b/Gui/ViewModels/Loco/ObjectEditorViewModel.cs @@ -3,7 +3,6 @@ using Avalonia.Controls.ApplicationLifetimes; using Dat.Converters; using Dat.Data; -using Dat.FileParsing; using Definitions.DTO; using Definitions.ObjectModels; using Definitions.ObjectModels.Objects.Common; @@ -21,15 +20,15 @@ using MsBox.Avalonia.Enums; using ReactiveUI; using ReactiveUI.Fody.Helpers; +using Shared.Files; +using Shared.Validation; 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/Loco/Objects/Vehicle/VehicleViewModel.cs b/Gui/ViewModels/Loco/Objects/Vehicle/VehicleViewModel.cs index ab0b293a..ca206533 100644 --- a/Gui/ViewModels/Loco/Objects/Vehicle/VehicleViewModel.cs +++ b/Gui/ViewModels/Loco/Objects/Vehicle/VehicleViewModel.cs @@ -5,7 +5,6 @@ using DynamicData.Binding; using Gui.Attributes; using PropertyModels.ComponentModel.DataAnnotations; -using PropertyModels.Extensions; using ReactiveUI; using ReactiveUI.Fody.Helpers; using System; diff --git a/Gui/ViewModels/MainWindowViewModel.cs b/Gui/ViewModels/MainWindowViewModel.cs index c4a12c3a..ef40bb47 100644 --- a/Gui/ViewModels/MainWindowViewModel.cs +++ b/Gui/ViewModels/MainWindowViewModel.cs @@ -1,19 +1,16 @@ using Avalonia; -using Avalonia.Platform; using Avalonia.Platform.Storage; using Common; 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; @@ -68,17 +65,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 +260,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..edeb4a8b 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}") = "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 @@ -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 + {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 + {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/Shared/Files/LocoObjectFile.cs b/Shared/Files/LocoObjectFile.cs new file mode 100644 index 00000000..711121cb --- /dev/null +++ b/Shared/Files/LocoObjectFile.cs @@ -0,0 +1,6 @@ +using Dat.Types; +using Definitions.ObjectModels; + +namespace Shared.Files; + +public sealed record LocoObjectFile(string FileName, DatHeaderInfo DatInfo, LocoObject LocoObject); diff --git a/Shared/Files/ObjectFile.cs b/Shared/Files/ObjectFile.cs new file mode 100644 index 00000000..918fcf6c --- /dev/null +++ b/Shared/Files/ObjectFile.cs @@ -0,0 +1,140 @@ +using Dat.Converters; +using Dat.Data; +using Dat.FileParsing; +using Definitions.ObjectModels.Graphics; +using Definitions.ObjectModels.Types; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace Shared.Files; + +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; + var effectiveName = objectName ?? header.Name; + var effectiveSource = objectSource ?? header.ObjectSource.Convert(header.Name, header.Checksum); + var effectiveEncoding = encoding ?? file.DatInfo.ObjectHeader.Encoding; + + 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) + { + 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/Shared/Operations/BatchProcessor.cs b/Shared/Operations/BatchProcessor.cs new file mode 100644 index 00000000..804633d9 --- /dev/null +++ b/Shared/Operations/BatchProcessor.cs @@ -0,0 +1,118 @@ +using Dat.Data; +using Definitions.ObjectModels.Graphics; +using Microsoft.Extensions.Logging; +using Shared.Files; + +namespace Shared.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/Shared/Operations/ObjectOperations.cs b/Shared/Operations/ObjectOperations.cs new file mode 100644 index 00000000..370c13f2 --- /dev/null +++ b/Shared/Operations/ObjectOperations.cs @@ -0,0 +1,71 @@ +using Definitions.ObjectModels; +using Definitions.ObjectModels.Graphics; + +namespace Shared.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(); + if (element.Image != null + && !ReferenceEquals(element.Image, ImageTableHelpers.ErrorImage) + && !ReferenceEquals(element.Image, ImageTableHelpers.OnePixelTransparent)) + { + 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/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/Shared/Validation/ObjectValidation.cs b/Shared/Validation/ObjectValidation.cs new file mode 100644 index 00000000..d04ec7de --- /dev/null +++ b/Shared/Validation/ObjectValidation.cs @@ -0,0 +1,144 @@ +using Dat.Data; +using Definitions.ObjectModels; +using Microsoft.Extensions.Logging; +using Shared.Files; +using System.ComponentModel.DataAnnotations; + +namespace Shared.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/Tests/IdempotenceTests.cs b/Tests/IdempotenceTests.cs index d258ed89..6e12f66f 100644 --- a/Tests/IdempotenceTests.cs +++ b/Tests/IdempotenceTests.cs @@ -1,6 +1,5 @@ using Dat.Converters; using Dat.FileParsing; -using Definitions.ObjectModels; using Definitions.ObjectModels.Graphics; using NUnit.Framework; using NUnit.Framework.Internal; @@ -13,7 +12,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/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;