Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 

README.md

C# bindings — Whiteout

Pure-managed P/Invoke bindings to WhiteoutLib, the C++ library for reading and writing the model, texture, and storage formats used by Blizzard Entertainment games. Targets .NET 8 baseline, AOT-compatible, with a single shared native library (whiteout_native.dll / libwhiteout_native.so / libwhiteout_native.dylib) the codegen produces from the same @bind-annotated C++ headers that drive the Java, Python, and WASM bindings.

Package Surface
Whiteout.Common WhiteoutHandle base, NativeListView<T>, hand-written interop helpers
Math type aliases: Vector2f/Vector3f/Vector4f/Quaternion → System.Numerics
Whiteout.Textures Texture, parsers + writers for BLP / DDS / PNG / JPEG / BMP / TGA, GifWriter, APNG support
Whiteout.Mdx Warcraft III model + MDL writer (engine-faithful and HiveWorkshop dialects)
Whiteout.M2 World of Warcraft M2 (parser takes VirtualPathFileSystem for sibling .skin / .skel / .anim)
Whiteout.M3 StarCraft II / HotS M3
Whiteout.Mpq MPQ archive read + write
Whiteout.Casc CASC storage read (local; online support via the HttpHandler trampoline)
Whiteout.Host OsFileSystem, SimpleThreadPool, SimpleHttpHandler, BlizzardGameFinder, plus the abstract trampoline bases (VirtualPathFileSystem, WorkerPool, HttpHandler)

Everything except the trampoline bases under Whiteout.Host is autogenerated by tools/codegen/emit_csharp.py. The trampoline bases are hand-written because they wire [UnmanagedCallersOnly] static methods + GCHandle between managed subclasses and the C++ side.


Install

dotnet add package Whiteout

The NuGet package carries the managed assembly + the native library staged under runtimes/<rid>/native/. .NET's native loader picks the right binary for the current platform automatically (win-x64, linux-x64, osx-arm64, …).

For repo-local development without a NuGet feed, see Building from source below. You can also point at a custom whiteout_native location via the WHITEOUT_NATIVE_PATH env var or Whiteout.Runtime.NativeLibraryPath static property — both win over the OS default loader.


Quick start

Textures — BLP → PNG round-trip

using Whiteout.Textures;

using var parser = new BlpParser();
using var tex    = parser.Parse(File.ReadAllBytes("texture.blp"))
                  ?? throw new InvalidDataException();
Console.WriteLine($"{tex.Width}x{tex.Height} {tex.Format}");

using var writer = new PngWriter();
File.WriteAllBytes("texture.png", writer.Write(tex));

if (parser.HasIssues)
    foreach (var msg in parser.Issues) Console.Error.WriteLine($"warn: {msg}");

Constructing textures from scratch:

using var tex = Texture.Create2D(PixelFormat.RGBA8, 1024, 1024, mipCount: 1);
tex.IsSrgb = true;
tex.Format = PixelFormat.BC7;            // re-encode in-place (CPU compression)

MDX (Warcraft III)

using Whiteout.Mdx;

var mdxBytes = File.ReadAllBytes("Footman.mdx");

using var parser = new Parser();
using var model = parser.ParseBufferFormat(mdxBytes, MDLXFormat.MDX);

Console.WriteLine($"{model.ModelName} v{model.Version}: {model.Bones.Count} bones");

// model.Bones, model.Geosets, model.Sequences are IReadOnlyList<T>
// backed by the C++ vectors. Elements are borrowed views — don't dispose.
foreach (var bone in model.Bones)
    Console.WriteLine($"  bone: {bone.Node.Name}");

// vector<Vector3f> fields use zero-copy ReadOnlySpan<Vector3>.
ReadOnlySpan<System.Numerics.Vector3> pivots = model.PivotPoints;
var firstPivot = pivots[0];

// MDX → MDL text (HiveWorkshop dialect):
using var writer = new Writer();
var mdlText = writer.WriteMdxFormatMdlFormat(model, MDLXFormat.MDL, MdlFormat.Hiveworkshop);
File.WriteAllBytes("Footman.mdl", mdlText);

M2 (World of Warcraft) — multi-file via a VFS

using Whiteout.Host;
using Whiteout.M2;

// The codegen-emitted OsFileSystem already derives from
// Whiteout.Host.VirtualPathFileSystem — pass it straight in.
using var fs = new OsFileSystem(@"C:\path\to\models");

using var parser = new Parser();
using var model = parser.Parse(fs, "character/HumanMale.m2");
Console.WriteLine($"{model.Bones.Count} bones, {model.Textures.Count} textures");

M3 (StarCraft II / Heroes of the Storm)

using Whiteout.M3;

using var parser = new Parser();
using var model = parser.ParseBuffer(File.ReadAllBytes("model.m3"));

MPQ (Warcraft III, WoW Classic)

using Whiteout.Host;
using MpqStorage = Whiteout.Mpq.Storage;

using var pool = new SimpleThreadPool(8);                  // ← WorkerPool
using var mpq  = MpqStorage.Open("war3.mpq", pool)
                ?? throw new IOException("open failed");

var blp = mpq.ReadFile("textures/character/footman.blp");
foreach (var path in mpq.ListFiles.Take(5))
    Console.WriteLine(path);

CASC (WoW retail, Diablo, Overwatch) — local install

using Whiteout.Host;
using CascStorage = Whiteout.Casc.Storage;

using var pool = new SimpleThreadPool(8);
using var casc = CascStorage.Open(@"C:\Games\WoW\_retail_\Data", pool)
                 ?? throw new IOException("open failed");

if (casc.IsLocal) Console.WriteLine($"root format: {casc.RootFormat}");
var adt = casc.ReadFile("World/Maps/Azeroth/Azeroth_31_46.adt");

Threaded mipmap generation

using Whiteout.Host;
using Whiteout.Textures;

using var pool = new SimpleThreadPool(8);
using var tex  = new PngParser().Parse(File.ReadAllBytes("source.png"))!;

tex.GenerateMipmapsPool(pool);              // parallel across the pool
tex.Format = PixelFormat.BC7;               // BC7 encode (single-threaded today)
File.WriteAllBytes("out.dds", new DdsWriter().Write(tex));

Math types

The recognised math types are global using aliases of System.Numerics:

// In Whiteout.Common/MathTypes.cs (project-wide aliases):
global using Vector2f = System.Numerics.Vector2;
global using Vector3f = System.Numerics.Vector3;
global using Vector4f = System.Numerics.Vector4;
global using Quaternion = System.Numerics.Quaternion;

The codegen does NOT emit SafeHandle wrappers for these types — they're value-type structs that cross the FFI boundary directly. Field accessors on bound classes use one of two paths:

  • vector<MathType> fields become zero-copy ReadOnlySpan<T> aliased to the underlying C++ buffer:
    ReadOnlySpan<Vector3> pivots = model.PivotPoints;   // no allocation, no copy
  • Scalar math fields read via Unsafe.Read<T> from a borrowed pointer + write via Unsafe.Write<T> to a temporary allocation:
    Extent extent = ...;
    extent.Minimum = new System.Numerics.Vector3(-1f, -2f, -3f);
    var max = extent.Maximum;
    Console.WriteLine($"({max.X}, {max.Y}, {max.Z})");

This means a Whiteout.Mdx.Bone translation is interchangeable with a System.Numerics.Vector3 from your existing SIMD code with no conversion.


Subclassing the abstract bases

Three C++ abstract bases are bound as managed abstract classes under Whiteout.Host. Subclass and provide implementations to plug your own storage / HTTP / threading into APIs that consume them.

Custom storage — VirtualPathFileSystem

using Whiteout.Host;

sealed class ZipVfs : VirtualPathFileSystem
{
    private readonly ZipArchive _zip;
    public ZipVfs(string zipPath) { _zip = ZipFile.OpenRead(zipPath); }

    public override byte[] ReadFile(string path)
    {
        var entry = _zip.GetEntry(path);
        if (entry is null) return Array.Empty<byte>();
        using var stream = entry.Open();
        using var ms = new MemoryStream();
        stream.CopyTo(ms);
        return ms.ToArray();
    }

    public override bool FileExists(string path) => _zip.GetEntry(path) is not null;
    public override bool WriteFile(string path, ReadOnlySpan<byte> data) => false;  // read-only

    protected override bool ReleaseHandle() { _zip.Dispose(); return base.ReleaseHandle(); }
}

// Use it the same as any other VFS:
using var vfs = new ZipVfs("character.zip");
using var m2 = new M2.Parser().Parse(vfs, "models/Human.m2");

Custom HTTP backend — HttpHandler

sealed class HttpClientHandler : HttpHandler
{
    private readonly HttpClient _client = new();

    public override void GetAsync(string url, Action<HttpResponse> callback)
    {
        _ = Task.Run(async () =>
        {
            try
            {
                using var resp = await _client.GetAsync(url);
                var body = await resp.Content.ReadAsByteArrayAsync();
                callback(new HttpResponse((int)resp.StatusCode, body, ""));
            }
            catch (Exception ex)
            {
                callback(HttpResponse.Failure(ex.Message));
            }
        });
    }

    public override void GetRangeAsync(string url, ulong start, ulong end,
                                       Action<HttpResponse> callback)
    {
        _ = Task.Run(async () =>
        {
            var req = new HttpRequestMessage(HttpMethod.Get, url);
            req.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(
                (long)start, (long)end);
            try
            {
                using var resp = await _client.SendAsync(req);
                var body = await resp.Content.ReadAsByteArrayAsync();
                callback(new HttpResponse((int)resp.StatusCode, body, ""));
            }
            catch (Exception ex) { callback(HttpResponse.Failure(ex.Message)); }
        });
    }

    public override uint Capabilities => (uint)HttpCapabilities.None;
}

The callback is single-shot: the first callback(...) invocation fires the underlying C++ std::function and frees it; subsequent calls are no-ops. If your implementation throws before invoking the callback, the bridge auto-fires a cancellation response so library code waiting on the request doesn't hang.

Custom thread pool — WorkerPool

sealed class TaskSchedulerPool : WorkerPool
{
    private readonly int _threads;
    private int _inFlight;
    private readonly ManualResetEventSlim _idle = new(initialState: true);

    public TaskSchedulerPool(int threads) { _threads = threads; }

    public override ulong ThreadCount => (ulong)_threads;

    public override void Submit(WorkerTask task)
    {
        Interlocked.Increment(ref _inFlight);
        _idle.Reset();
        _ = Task.Run(() =>
        {
            try { task.Run(); }
            finally { if (Interlocked.Decrement(ref _inFlight) == 0) _idle.Set(); }
        });
    }

    public override void WaitIdle() => _idle.Wait();
}

WorkerTask.Run() is single-shot — it threads through optional wait / signal timeline-semaphore coordination automatically, then frees the C++ std::function after invocation.


Polymorphism

The codegen-emitted concrete impls inherit from the matching managed abstract base, so they flow into APIs that expect the base directly — no adapter wrapper needed:

WorkerPool         pool = new SimpleThreadPool(8);
VirtualPathFileSystem vfs = new OsFileSystem(@"C:\models");
HttpHandler        http = new SimpleHttpHandler();

using var mpq = Mpq.Storage.Open("war3.mpq", pool);
using var m2  = new M2.Parser().Parse(vfs, "character/Human.m2");
// Casc online would take `http` similarly when openOnline is bound.

For native concrete impls, the codegen emits throwing-stub overrides for abstract methods the C ABI doesn't expose (e.g. SimpleThreadPool.Submit isn't bound, so calling it on a managed reference throws). The throwing stubs are unreachable in practice because C++ virtual dispatch goes straight to the concrete impl without ever crossing back to managed code. The pattern lets the type system accept the concrete as the base; library code drives it natively.


Memory and lifetime

  • SafeHandle-based ownership. Every native handle wrapper derives from Whiteout.Common.WhiteoutHandle : SafeHandle. Dispose() / using releases the underlying C++ allocation; finalisation does too if Dispose was missed.
  • Borrowed views. IReadOnlyList<T> elements (e.g. model.Bones[0]) are borrowed handles into the parent's storage — the codegen passes owned: false to the constructor so disposing the element is a no-op. Don't dispose elements; dispose the parent.
  • Span/ReadOnlySpan invalidation. Spans returned by View() or vector<MathType> fields are aliased to native memory. Any operation that may reallocate the underlying C++ vector (append/resize/...) invalidates the span. Re-acquire after a size change, or .ToArray() for a stable snapshot.
  • Trampoline lifetime. Managed subclasses of the trampoline bases (VirtualPathFileSystem etc.) allocate a GCHandle in their constructor to keep themselves alive while C++ holds a pointer. Don't let your subclass instance be collected before every consumer of it is done — e.g. hold a strong reference to your HttpHandler until every CascStorage using it is closed.

AOT (PublishAot) support

The bindings are AOT-clean:

  • All P/Invoke goes through [LibraryImport] (source-generated, no runtime marshaller)
  • Trampolines are [UnmanagedCallersOnly] static methods + GCHandle (no Marshal.GetDelegateForFunctionPointer)
  • No reflection in the public surface

The csproj sets <IsAotCompatible>true</IsAotCompatible> and <EnableTrimAnalyzer>true</EnableTrimAnalyzer>. dotnet publish -r <rid> /p:PublishAot=true should produce a single native binary plus whiteout_native.dll.


Building from source

Requires:

  • A C++ 20 toolchain (MSVC 2022 / Clang 16+ / GCC 13+)
  • CMake 3.15+
  • Python 3.9+ with the clang package (pip install clang)
  • .NET 8 SDK

From the repo root on Windows:

.\scripts\build-csharp.ps1

The script:

  1. Runs tools/codegen/codegen.py for every C# module across the c-header, c-source, and csharp backends.
  2. Configures + builds whiteout_native.dll via CMake into build-csharp/c-dist/. CASC and MPQ are enabled.
  3. Stages the DLL into packages/csharp/runtimes/<rid>/native/.
  4. dotnet builds the C# solution.
  5. Runs the xUnit smoke tests with WHITEOUT_NATIVE_PATH pointing at the staged DLL.

Codegen-only (no native rebuild):

$env:PYTHONIOENCODING = "utf-8"
foreach ($mod in 'textures','mdx','m2','m3','utils','host','mpq','casc') {
    python -m tools.codegen.codegen $mod --backend c-header
    python -m tools.codegen.codegen $mod --backend c-source
    python -m tools.codegen.codegen $mod --backend csharp
}

Layout

bindings/csharp/
├── README.md                           (this file)
├── Whiteout.sln
├── Whiteout/
│   ├── Whiteout.csproj                 net8.0, nullable, AOT-clean
│   ├── Runtime.cs                      Runtime.NativeLibraryPath override
│   ├── WhiteoutException.cs
│   ├── Internal/
│   │   └── NativeLibraryResolver.cs    [ModuleInitializer] + WHITEOUT_NATIVE_PATH resolver
│   ├── Common/                         hand-written runtime support
│   │   ├── WhiteoutHandle.cs           SafeHandle base
│   │   ├── NativeBytes.cs              whiteout_Bytes + whiteout_CString marshal
│   │   ├── NativeCommon.cs             P/Invoke for shared free helpers
│   │   ├── NativeListView.cs           IReadOnlyList<T> over count+at
│   │   ├── MathTypes.cs                global using to System.Numerics
│   │   └── NativeMath.cs               math allocator stubs (Vector3f_new/delete, ...)
│   ├── Host/                           hand-written trampoline bases
│   │   ├── HttpHandler.cs              abstract; async callback bridge
│   │   ├── HttpResponse.cs             record + HttpCapabilities enum
│   │   ├── VirtualPathFileSystem.cs    abstract
│   │   ├── WorkerPool.cs               abstract
│   │   ├── WorkerTask.cs               single-shot Run() with semaphore coordination
│   │   ├── NativeShims.cs              P/Invoke for the C# shim entry points
│   │   ├── BlizzardGameFinder.cs       AUTOGENERATED  (and below)
│   │   ├── OsFileSystem.cs             AUTOGENERATED  ── inherits VirtualPathFileSystem
│   │   ├── SimpleHttpHandler.cs        AUTOGENERATED  ── inherits HttpHandler
│   │   ├── SimpleThreadPool.cs         AUTOGENERATED  ── inherits WorkerPool
│   │   └── ...
│   ├── Textures/  Mdx/  M2/  M3/  Mpq/  Casc/
│   │                                   AUTOGENERATED per-module classes
│   └── ...
└── Whiteout.Tests/                     xUnit smoke tests (57 tests)
    ├── Whiteout.Tests.csproj
    ├── SmokeTest.cs                    textures + mdx + m3 + format-by-format
    ├── HostConcreteImplsTest.cs        OsFileSystem reads from disk, etc.
    ├── VfsTrampolineTest.cs            C# → C++ → C# round-trips for VFS
    ├── HttpHandlerTrampolineTest.cs    async callback, threading, cancellation
    ├── WorkerPoolTrampolineTest.cs     task submission + WaitIdle
    ├── MpqCascTest.cs                  war3.mpq fixture, ListFiles, ReadFile
    └── PolymorphismTest.cs             SimpleThreadPool is-a WorkerPool, etc.

bindings/c/whiteout_csharp_shims.cpp    Hand-written C++ trampoline shims +
                                        smoke-test invokers
packages/csharp/                        Built artefacts (whiteout_native.dll
                                        staged under runtimes/<rid>/native/,
                                        Whiteout.<version>.nupkg)

Smoke-test invokers

Each trampoline base exposes InvokeXViaTrampoline(...) public methods that drive the C++ virtual dispatch path explicitly. Useful when you want to prove a managed subclass routes correctly:

var vfs = new MyZipVfs("models.zip");
var bytes = vfs.InvokeReadFileViaTrampoline("path/in/zip");
//   ↑ goes C# → C++ shim → fn pointer → managed ReadFile override

For native concrete impls (OsFileSystem), both invocation paths converge on the same C++ method, so the override and the trampoline-path invoker return identical bytes — the PolymorphismTest suite locks in this guarantee.


Troubleshooting

Symptom Cause / fix
DllNotFoundException: Unable to load DLL 'whiteout_native' NuGet runtimes/<rid>/native/ not staged for your platform. Set WHITEOUT_NATIVE_PATH=... to the absolute path, or set Whiteout.Runtime.NativeLibraryPath before any other Whiteout type is used.
EntryPointNotFoundException: ... Native library is older than the generated managed code. Rebuild whiteout_native via CMake + restage.
NullReferenceException on element returned by IReadOnlyList<T> Parent handle was disposed before the borrowed element view. Owned handles in using should outlive any borrowed view.
Subclassing HttpHandler leaks native callbacks Hold a strong reference to your handler until every CascStorage using it is closed — the bridge keeps a GCHandle but library code may outlive it.
ReadOnlySpan<T> returns garbage after mutating the parent vector The C++ vector reallocated and the span points at freed memory. Re-acquire after the mutation, or .ToArray() for a snapshot.
MPQ open fails with no error The MPQ may need a specific worker pool config — try new SimpleThreadPool(1). Inspect via storage.HasIssues / storage.Issues if available.

What's not bound

  • SimpleThreadPool.Submit / SimpleHttpHandler.GetAsync direct calls — the codegen emits throwing-stub overrides. The methods are reachable only through C++ virtual dispatch, which is how the library uses them in practice.
  • CascStorage.OpenOnline(string product, HttpHandler http) — the C ABI doesn't expose it yet; coming in a follow-up.
  • Whiteout.Utils math operations (Vector3f.Dot, Cross, ...) — use System.Numerics's built-in operators on the aliased types.
  • std::vector<Whiteout.Common.Vector3> mutating setters — ReadOnlySpan<Vector3> is read-only today. Writing via Span<Vector3>
    • the C ABI's _assign shape is a known-gap follow-up.
  • Matrix33f / Matrix44f — pending layout-parity confirmation against System.Numerics.Matrix4x4.

See also

License

BSD-3-Clause. See LICENSE.