Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions Cli/Cli.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AnalysisLevel>latest</AnalysisLevel>
<RootNamespace>Cli</RootNamespace>
<AssemblyName>locoobj</AssemblyName>
<RollForward>Major</RollForward>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>

<ItemGroup>
<Compile Include="..\Dat\Types\GlobalUsings.cs" Link="GlobalUsings.cs" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Common\Common.csproj" />
<ProjectReference Include="..\Dat\Dat.csproj" />
<ProjectReference Include="..\Definitions\Definitions.csproj" />
<ProjectReference Include="..\Shared\Shared.csproj" />
</ItemGroup>

</Project>
131 changes: 131 additions & 0 deletions Cli/CommandContext.cs
Original file line number Diff line number Diff line change
@@ -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<string> CommonFlags { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"dry-run", "no-recurse", "allow-vanilla", "verbose", "quiet", "help",
};

public static IReadOnlySet<string> CommonOptions { get; } = new HashSet<string>(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<SawyerEncoding>(raw, ignoreCase: true, out var parsed))
{
Logger.LogError("Unknown encoding \"{Encoding}\". Valid values: {Valid}", raw, string.Join(", ", Enum.GetNames<SawyerEncoding>()));
return false;
}

encoding = parsed;
return true;
}

public bool TryResolveInputs(out IReadOnlyList<string> 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<string> 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;
}
}
66 changes: 66 additions & 0 deletions Cli/CommandLine.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
namespace Cli;

public sealed class CommandLine
{
readonly List<string> positionals = [];
readonly Dictionary<string, string?> options = [with(StringComparer.OrdinalIgnoreCase)];

public IReadOnlyList<string> Positionals
=> positionals;

public IReadOnlyCollection<string> OptionNames
=> options.Keys;

public static CommandLine Parse(IReadOnlyList<string> args, IReadOnlySet<string> 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<string> UnknownOptions(IReadOnlySet<string> known)
=> options.Keys.Where(x => !known.Contains(x));
}
50 changes: 50 additions & 0 deletions Cli/Commands/CropCommand.cs
Original file line number Diff line number Diff line change
@@ -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 <file-or-directory> [--out <dir>] [--encoding <enc>] [--palette <png>] [--dry-run] [--no-recurse] [--allow-vanilla]";

public IReadOnlySet<string> Options
=> CommandContext.CommonOptions;

public IReadOnlySet<string> Flags
=> CommandContext.CommonFlags;

public Task<int> 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));
}
}
76 changes: 76 additions & 0 deletions Cli/Commands/ExportImagesCommand.cs
Original file line number Diff line number Diff line change
@@ -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 <file-or-directory> --out <dir> [--use-names] [--palette <png>] [--no-recurse]";

public IReadOnlySet<string> Options { get; } = new HashSet<string>(CommandContext.CommonOptions, StringComparer.OrdinalIgnoreCase)
{
"use-names",
};

public IReadOnlySet<string> Flags { get; } = new HashSet<string>(CommandContext.CommonFlags, StringComparer.OrdinalIgnoreCase)
{
"use-names",
};

public async Task<int> RunAsync(CommandContext context)
{
ArgumentNullException.ThrowIfNull(context);

var outputRoot = context.OutputPath;
if (string.IsNullOrEmpty(outputRoot))
{
context.Logger.LogError("--out <dir> 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;
}
}
Loading