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.
dotnet add package WhiteoutThe 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.
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)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);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");using Whiteout.M3;
using var parser = new Parser();
using var model = parser.ParseBuffer(File.ReadAllBytes("model.m3"));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);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");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));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-copyReadOnlySpan<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 viaUnsafe.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.
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.
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");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.
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.
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.
SafeHandle-based ownership. Every native handle wrapper derives fromWhiteout.Common.WhiteoutHandle : SafeHandle.Dispose()/usingreleases the underlying C++ allocation; finalisation does too ifDisposewas missed.- Borrowed views.
IReadOnlyList<T>elements (e.g.model.Bones[0]) are borrowed handles into the parent's storage — the codegen passesowned: falseto the constructor so disposing the element is a no-op. Don't dispose elements; dispose the parent. - Span/ReadOnlySpan invalidation. Spans returned by
View()orvector<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
(
VirtualPathFileSystemetc.) allocate aGCHandlein 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 yourHttpHandleruntil everyCascStorageusing it is closed.
The bindings are AOT-clean:
- All P/Invoke goes through
[LibraryImport](source-generated, no runtime marshaller) - Trampolines are
[UnmanagedCallersOnly]static methods +GCHandle(noMarshal.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.
Requires:
- A C++ 20 toolchain (MSVC 2022 / Clang 16+ / GCC 13+)
- CMake 3.15+
- Python 3.9+ with the
clangpackage (pip install clang) - .NET 8 SDK
From the repo root on Windows:
.\scripts\build-csharp.ps1The script:
- Runs
tools/codegen/codegen.pyfor every C# module across thec-header,c-source, andcsharpbackends. - Configures + builds
whiteout_native.dllvia CMake intobuild-csharp/c-dist/. CASC and MPQ are enabled. - Stages the DLL into
packages/csharp/runtimes/<rid>/native/. dotnet builds the C# solution.- Runs the xUnit smoke tests with
WHITEOUT_NATIVE_PATHpointing 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
}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)
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 overrideFor 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.
| 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. |
SimpleThreadPool.Submit/SimpleHttpHandler.GetAsyncdirect 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.Utilsmath operations (Vector3f.Dot,Cross, ...) — useSystem.Numerics's built-in operators on the aliased types.std::vector<Whiteout.Common.Vector3>mutating setters —ReadOnlySpan<Vector3>is read-only today. Writing viaSpan<Vector3>- the C ABI's
_assignshape is a known-gap follow-up.
- the C ABI's
Matrix33f/Matrix44f— pending layout-parity confirmation againstSystem.Numerics.Matrix4x4.
bindings/python/README.md— Python sibling bindings.bindings/java/README.md— Java FFM sibling bindings.packages/js-ts/README.md— WebAssembly + TypeScript bindings.tools/codegen/README.md— codegen tool documentation.
BSD-3-Clause. See LICENSE.