Skip to content
Merged
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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.

- Added a plugin system. The SDK now defines `IPlugin`, `IPluginContext`, `PluginManifest`, and a `[Plugin(typeof(MyPlugin))]` assembly attribute under `GMConverter.SDK.Plugins`. The core library hosts a `PluginLoader` that discovers `plugin.json` manifests under a `plugins/` directory next to the executable, loads each into its own collectible `AssemblyLoadContext` with SDK type unification (so `IImporter`/`IExporter`/`IExplorer` references match across the plugin boundary), and instantiates the plugin's entry type. Plugin-contributed importers and explorers are merged into the host's resolution path; CLI and UI both bootstrap the loader at startup.
- Extracted Unreal Engine 4/5 support into a standalone `GMConverter.UnrealEngine` plugin assembly that references only `GMConverter.SDK` plus CUE4Parse and MessagePack. The plugin contributes the PSK importer (with optional scene-manifest support) and the UE4 and UE2 archive explorers via `IPluginContext`. Host bundling is opt-in: `GMConverter.CLI` and `GMConverter.UI` list it via `<BundledPlugin>` items in their csprojs, which trigger a loose `ProjectReference` (for build order) and a `CopyBundledPlugins` target that stages the plugin's `bin/` into the host's `bin/plugins/unrealengine/`. The host's CUE4Parse and MessagePack package references are gone — those move with the plugin.
- Extracted Source Engine support into a standalone `GMConverter.SourceEngine` plugin assembly that references only `GMConverter.SDK` plus MdlCrowbar. The plugin contributes the MDL importer (reads Garry's Mod / Source MDL via MdlCrowbar) and the MDL exporter (writes the full SMD / QC / VTF / VMT compile workspace via studiomdl + VTFCmd) via `IPluginContext` using DI activation. `MDLExporter` declares the full ~12-option schema (Tools / Materials / Physics groups) via the new `ExporterOptionSchema` so future generic UI rendering can drive the panel. Host's `MdlCrowbar` package ref and the entire `Source/` directory are gone from Core — they live in the plugin. The CoACD native and its MSBuild download target also moved with the plugin since CoACD is Source-physics-specific. Source compile binaries (`studiomdl.exe`, `VTFCmd.exe`) still auto-download into `./tools/` on first run.
- Added `ITextureFactory` to the SDK. Plugins construct `Texture` instances by name/stream/raw RGBA bytes without taking a dependency on the host's image library. The default implementation (`DefaultTextureFactory` in Core) wraps SixLabors.ImageSharp and is registered in the host's service provider.
- Wired Microsoft.Extensions.DependencyInjection into the plugin system. `IPluginContext` now exposes an `IServiceProvider Services` populated by the host with `ITextureFactory`, `ILoggerFactory`, and any future host-side services. Plugins consume these via constructor injection — `context.RegisterImporter<PSKImporter>()` activates `PSKImporter` against the provider, resolving its ctor params automatically. Named context properties for individual services (e.g. `TextureFactory`, `LoggerFactory`) were dropped in favor of a single `Services` source of truth; adding a new host capability is now a one-line registration in `PluginLoader` with zero impact on existing plugins.
- Moved the format-agnostic helpers `NameHelpers`, `PathHelpers`, `PerfTimer`, and `ExplorerFileSystem` from `GMConverter.Common`/`GMConverter.Explorer` (Core) to `GMConverter.SDK.Common`/`GMConverter.SDK.Explorer` so plugins (and any future engine plugin) can share them without taking a Core reference.
Expand All @@ -16,7 +17,9 @@ All notable changes to this project will be documented in this file.

- Extracted the plugin-facing contract into a new `GMConverter.SDK` project under the `GMConverter.SDK.*` namespace, split by domain: `GMConverter.SDK.Geometry` (mesh/skeleton bits), `GMConverter.SDK.Animation` (clips and tracks), `GMConverter.SDK.Materials`, `GMConverter.SDK.Textures`, plus `GMConverter.SDK.Importers` / `Exporters` / `Explorer` / `Common`. The core `GMConverter` library now references the SDK and all moved types are `public` so engine plugins can compile against the contract without a dependency on the core library. CLI and UI behavior is unchanged.
- Kept the SDK free of implementation dependencies. `Texture` is now an abstract base class with no image-library dependency; the concrete `ImageSharpTexture` implementation lives in the core library, and pipeline-specific transforms (`ToGltfMetallicRoughness`, `ToSourcePhongExponent`, `WithOpenGlNormalMap`, etc.) are exposed as extension methods on the SDK `Texture` that delegate to the concrete type. The MessagePack attribute on `ExplorerFileEntry` was removed; the UE4 scan cache continues to serialize it via the contractless resolver.
- Split `IExporter<TOptions>` to inherit from a new non-generic `IExporterDescriptor` so the plugin registry can hold exporters in a homogeneous collection and look them up by `OutputFormat` without committing to a specific options type at the call site.
- Split `IExporter<TOptions>` to inherit from a new non-generic `IExporterDescriptor` so the plugin registry can hold exporters in a homogeneous collection and look them up by `OutputFormat` without committing to a specific options type at the call site. *(Superseded later in the same `[Unreleased]` window — see the schema-driven `IExporter` entry below.)*
- Replaced `IExporter<TOptions>` (and the transitional `IExporterDescriptor`) with a single non-generic `IExporter` that exposes an `ExporterOptionSchema` and accepts an `ExportOptions` bag at invocation time. The SDK adds the schema types (`OptionType`, `OptionDescriptor`, `OptionGroup`, `ExporterOptionSchema`) and the bag value type (`ExportOptions`). Each exporter declares the options it accepts as data; the host builds an `ExportOptions` from UI/CLI inputs keyed by `OptionDescriptor.Key` and hands it to the plugin, which reads what it needs via typed accessors and constructs its own internal options record. Source Engine declares the full schema; GLTF and OBJ declare minimal schemas (or empty for OBJ). The generic schema-driven UI panel that consumes this is a follow-up — the existing `SourceEngineSettings.axaml` panel keeps rendering Source's options with its bespoke layout for now, just building the `ExportOptions` bag from its bindings.
- Removed UI-side CoACD physics preview generation. The Source plugin owns CoACD now (`CoacdNative` moved with it), and the UI no longer has direct access to the native; physics preview falls back to a bounds visualisation for both `bounds` and `coacd` modes. The actual export still produces CoACD physics when the user selects that mode. A follow-up could surface a plugin-contributed "preview physics" hook so the UI can render the real shape pre-export.
- Hardened the plugin native-library loader. `PluginLoadContext.LoadUnmanagedDll` now falls back to a sibling-folder probe (with platform-conventional shared-library names) when `AssemblyDependencyResolver` returns no path. CUE4Parse-Natives ships as a `Content`/`CopyToOutputDirectory` item rather than a `runtimes/<rid>/native/` layout and is not listed in deps.json as a native asset, so the resolver alone would not find it; the fallback closes that gap.

## [1.7.0] - 2026-05-21
Expand Down
3 changes: 3 additions & 0 deletions GMConverter.CLI/GMConverter.CLI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
plugin moves to its own repo, drop the entry here and ship the plugin through a release
artifact instead. -->
<ItemGroup>
<BundledPlugin Include="..\GMConverter.SourceEngine\GMConverter.SourceEngine.csproj">
<PluginId>sourceengine</PluginId>
</BundledPlugin>
<BundledPlugin Include="..\GMConverter.UnrealEngine\GMConverter.UnrealEngine.csproj">
<PluginId>unrealengine</PluginId>
</BundledPlugin>
Expand Down
105 changes: 47 additions & 58 deletions GMConverter.CLI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
using GMConverter.Importers;
using GMConverter.Plugins;
using GMConverter.SDK.Common;
using GMConverter.SDK.Exporters;
using GMConverter.SDK.Geometry;
using GMConverter.SDK.Importers;
using GMConverter.Source;
using Microsoft.Extensions.Logging;

namespace GMConverter.CLI;
Expand Down Expand Up @@ -242,7 +242,12 @@ private static int Run(
studioMdlPath,
vtfCmdPath,
buildMaterials,
CreatePhysicsOptions(generatePhysics, physicsModeText, physicsMass, coacdThreshold, maxConvexPieces, maxHullVertices));
generatePhysics,
physicsModeText,
physicsMass,
coacdThreshold,
maxConvexPieces,
maxHullVertices);
return 0;

default:
Expand All @@ -255,15 +260,19 @@ private static void RunObj(Model model, string outputDirectory, string baseName)
Directory.CreateDirectory(outputDirectory);

Console.WriteLine($"Writing OBJ output to {outputDirectory}");
new OBJExporter().Export(model, outputDirectory, baseName, new OBJExportOptions());
new OBJExporter().Export(model, outputDirectory, baseName, ExportOptions.Empty);
}

private static void RunGltf(Model model, string outputDirectory, string baseName, bool binary)
{
Directory.CreateDirectory(outputDirectory);

Console.WriteLine($"Writing {(binary ? "GLB" : "glTF")} output to {outputDirectory}");
new GLTFExporter().Export(model, outputDirectory, baseName, new GLTFExportOptions(binary));
var options = new ExportOptions(new Dictionary<string, object?>
{
["binary"] = binary,
});
new GLTFExporter().Export(model, outputDirectory, baseName, options);
}

private static void RunMdl(
Expand All @@ -274,27 +283,50 @@ private static void RunMdl(
string? studioMdlPath,
string? vtfCmdPath,
bool buildMaterials,
PhysicsOptions? physicsOptions)
bool generatePhysics,
string? physicsModeText,
float physicsMass,
float coacdThreshold,
int maxConvexPieces,
int maxHullVertices)
{
Directory.CreateDirectory(outputDirectory);

var exporter = PluginHost.Registry.GetExporter("mdl")
?? throw new GMConverterException("Source plugin not loaded: cannot produce MDL output. Install GMConverter.SourceEngine.");

Console.WriteLine($"Writing Source compile workspace to {outputDirectory}");
new MDLExporter().Export(
model,
outputDirectory,
baseName,
new MDLExportOptions(modelPath, studioMdlPath, vtfCmdPath, buildMaterials, physicsOptions));
var bag = new Dictionary<string, object?>
{
["modelPath"] = modelPath,
["studioMdlPath"] = studioMdlPath,
["vtfCmdPath"] = vtfCmdPath,
["buildMaterials"] = buildMaterials,
};
if (generatePhysics || !string.IsNullOrWhiteSpace(physicsModeText))
{
var mode = NormalizePhysicsMode(physicsModeText);
bag["physics:enabled"] = true;
bag["physics:mode"] = mode;
bag["physics:mass"] = physicsMass;
if (mode == "coacd")
{
bag["physics:coacdThreshold"] = coacdThreshold;
bag["physics:maxConvexPieces"] = maxConvexPieces;
bag["physics:maxHullVertices"] = maxHullVertices;
}
}
exporter.Export(model, outputDirectory, baseName, new ExportOptions(bag));
}

private static IImporter GetImporter(string inputFormat, ILoggerFactory? loggerFactory = null)
{
return inputFormat switch
{
"opt" => new OPTImporter(),
"mdl" => new MDLImporter(),
"mow" => new MOWImporter(loggerFactory),
_ => PluginHost.Registry.GetImporter(inputFormat)
?? throw new ArgumentException($"Option --input-format '{inputFormat}' is not recognized. Built-ins: opt, mdl, mow. Plugins (e.g. GMConverter.UnrealEngine for psk) may contribute additional formats.")
?? throw new ArgumentException($"Option --input-format '{inputFormat}' is not recognized. Built-ins: opt, mow. Plugins (GMConverter.UnrealEngine for psk; GMConverter.SourceEngine for mdl) may contribute additional formats.")
};
}

Expand Down Expand Up @@ -362,49 +394,6 @@ private static string SanitizePathToken(string value)
return string.Concat(value.Select(c => char.IsLetterOrDigit(c) || c is '_' or '-' ? char.ToLowerInvariant(c) : '_')).Trim('_');
}

private static PhysicsOptions? CreatePhysicsOptions(
bool generatePhysics,
string? physicsModeText,
float mass,
float threshold,
int maxConvexPieces,
int maxHullVertices)
{
if (!generatePhysics && string.IsNullOrWhiteSpace(physicsModeText))
{
return null;
}

var mode = NormalizePhysicsMode(physicsModeText);

if (mass <= 0)
{
throw new ArgumentException("Option --physics-mass must be greater than zero.");
}

if (mode is PhysicsMode.Bounds)
{
return new PhysicsOptions(mode, mass, null);
}

if (threshold is < 0.01f or > 1.0f)
{
throw new ArgumentException("Option --coacd-threshold must be between 0.01 and 1.");
}

if (maxConvexPieces is 0 or < -1)
{
throw new ArgumentException("Option --max-convex-pieces must be -1 or greater than zero.");
}

if (maxHullVertices < 4)
{
throw new ArgumentException("Option --coacd-max-hull-vertices must be at least 4.");
}

return new PhysicsOptions(mode, mass, new CoacdOptions(threshold, maxConvexPieces, maxHullVertices));
}

private static MaterialResolveOptions? CreateMaterialResolveOptions(string? materialDirectory)
{
if (string.IsNullOrWhiteSpace(materialDirectory))
Expand Down Expand Up @@ -442,12 +431,12 @@ private static string SanitizePathToken(string value)
return fullPath;
}

private static PhysicsMode NormalizePhysicsMode(string? physicsModeText)
private static string NormalizePhysicsMode(string? physicsModeText)
{
return physicsModeText?.Trim().ToLowerInvariant() switch
{
null or "" or "bounds" => PhysicsMode.Bounds,
"coacd" => PhysicsMode.Coacd,
null or "" or "bounds" => "bounds",
"coacd" => "coacd",
_ => throw new ArgumentException("Option --physics-mode must be 'bounds' or 'coacd'.")
};
}
Expand Down
107 changes: 107 additions & 0 deletions GMConverter.SDK/Exporters/ExportOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using System.Globalization;

namespace GMConverter.SDK.Exporters;

/// <summary>
/// Opaque-but-typed-accessor bag of option values handed to an exporter at invocation time.
/// The host builds it from UI / CLI inputs (parsed values keyed by <see cref="OptionDescriptor.Key"/>);
/// the exporter reads what it needs via the typed Get helpers and constructs its own internal
/// strongly-typed options record from the result. This keeps the SDK contract opaque while
/// letting plugin code stay type-safe internally.
/// </summary>
public sealed class ExportOptions
{
/// <summary>An empty bag — no keys present. Useful for default invocations.</summary>
public static ExportOptions Empty { get; } = new(new Dictionary<string, object?>());

private readonly IReadOnlyDictionary<string, object?> _values;

public ExportOptions(IReadOnlyDictionary<string, object?> values)
{
ArgumentNullException.ThrowIfNull(values);
_values = values;
}

/// <summary>True if the bag contains a value (possibly <c>null</c>) for the given key.</summary>
public bool Contains(string key)
{
return _values.ContainsKey(key);
}

/// <summary>
/// Returns the value as a string, or <c>null</c> if not present or set to <c>null</c>.
/// Non-string values are converted via <c>ToString()</c> so callers can read primitive values
/// the host stored as their native CLR type (e.g. an <c>int</c> arrived from System.CommandLine).
/// </summary>
public string? GetString(string key)
{
if (!_values.TryGetValue(key, out var value) || value is null)
{
return null;
}
return value as string ?? value.ToString();
}

public bool GetBool(string key, bool defaultValue = false)
{
if (!_values.TryGetValue(key, out var value))
{
return defaultValue;
}
return value switch
{
bool b => b,
string s when bool.TryParse(s, out var parsed) => parsed,
_ => defaultValue,
};
}

public int GetInt(string key, int defaultValue = 0)
{
if (!_values.TryGetValue(key, out var value))
{
return defaultValue;
}
return value switch
{
int i => i,
long l => checked((int)l),
short s => s,
string s when int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) => parsed,
_ => defaultValue,
};
}

public float GetFloat(string key, float defaultValue = 0f)
{
if (!_values.TryGetValue(key, out var value))
{
return defaultValue;
}
return value switch
{
float f => f,
double d => (float)d,
int i => i,
long l => l,
string s when float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) => parsed,
_ => defaultValue,
};
}

/// <summary>
/// Reference-type accessor for non-primitive values the host may have stored directly (rare —
/// the schema is designed around primitives, but this escape hatch exists for plugins that
/// want to round-trip complex objects across an invocation).
/// </summary>
public T? Get<T>(string key) where T : class
{
return _values.TryGetValue(key, out var value) ? value as T : null;
}

/// <summary>The raw underlying dictionary. Useful for diagnostics and host-side iteration.</summary>
public IReadOnlyDictionary<string, object?> AsDictionary()
{
return _values;
}
}
22 changes: 22 additions & 0 deletions GMConverter.SDK/Exporters/ExporterOptionSchema.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace GMConverter.SDK.Exporters;

/// <summary>
/// The full set of options an exporter accepts, grouped for UI presentation. The host renders
/// one tab/section per group and one control per <see cref="OptionDescriptor"/>; the CLI
/// registers one argument per descriptor. The schema is the single source of truth for option
/// keys, labels, types, defaults, and (for <see cref="OptionType.Enum"/>) choices.
/// </summary>
public sealed record ExporterOptionSchema(IReadOnlyList<OptionGroup> Groups)
{
/// <summary>An empty schema (no groups, no options). Returned by exporters with no settings.</summary>
public static ExporterOptionSchema Empty { get; } = new([]);

/// <summary>Flattened view of every option in the schema, regardless of group.</summary>
public IEnumerable<OptionDescriptor> AllOptions => Groups.SelectMany(g => g.Options);

/// <summary>Looks up an option by key, returning <c>null</c> if the key is not in the schema.</summary>
public OptionDescriptor? Find(string key)
{
return AllOptions.FirstOrDefault(o => string.Equals(o.Key, key, StringComparison.Ordinal));
}
}
Loading
Loading