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
20 changes: 20 additions & 0 deletions .claude/context/csharp-bindings.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# C#/.NET bindings (welder-csharp rod)

## 2026-08-22 (evening): entity hierarchy + copy ctors (user feedback round 2)

- **Q1 (ADT<ClientVersion> generics): assessed INFEASIBLE, by design of C#**
— no non-type template parameters (generics take types, not values), and
one generic definition has ONE member set while the C++ template
instantiations differ per range; 11 era markers over 5 layouts would also
split type identity (ADT<Mop> != ADT<Legion> despite one native layout),
breaking is-checks and the family dispatch. The hierarchy below is the
C#-idiomatic answer.
- **Q2: `Formats.FileEntity`** (C++ formats::FileEntityBase, welded+marked
via the macro): derived by the six family bases AND BLP. welder-csharp
a56fca6 makes family surfaces MULTI-LEVEL (synthesized members join the
base's manifest; families process deepest-first), so FileEntity auto-hoists
exactly the shared contract: Validate()/EnsureValid(). fs Read/Write stay
family-level (ADT's alpha param breaks uniformity — intersection excludes
them automatically; do NOT hand-add them to the root).
- **Q3: `public T(T other)` copy constructors replace Clone()** everywhere
(BCL idiom); Dispose stays but every wrapper's doc now states it is
OPTIONAL deterministic release (SafeHandle finalizer covers collection).

## 2026-08-22 API polish (feature/csharp-api-polish + welder-csharp feature/generic-containers)

Four breaking (pre-1.0) surface changes, all four user-directed:
Expand Down
2 changes: 1 addition & 1 deletion cmake/Dependencies.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ FetchContent_Declare(welder
# — welder core resolves those through name_of, no rod feature involved.
FetchContent_Declare(welder_csharp
GIT_REPOSITORY https://github.com/skarndev/welder-csharp.git
GIT_TAG 258c5f756d7b54aa125b21615b65bb1eacd8f496)
GIT_TAG 1eea11a4f0a8404db659ba97e2e75f9c01d1586b)

# --- stb_dxt (BLP DXT/BC compression; single public-domain header) ---
# Pinned to the last commit that touched stb_dxt.h (2021-07-12); the URL_HASH
Expand Down
24 changes: 24 additions & 0 deletions docs/content/guide/maps.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,27 @@ including WotLK — Outland tiles still use it) lives on `chunk.legacy_liquid`.
See the **[ADT](../python/adt/index.md)**, **[WDT](../python/wdt/index.md)**
and **[WDL](../python/wdl/index.md)** format pages for the authoritative
shapes and per-field version badges.

## Bulk access to per-vertex records (C#)

Per-vertex chunk data — heights, normals, colors — is hot-path material for
renderers, and element-wise access through the live views pays interop cost
per element. Every POD record type carries a blittable `Data` mirror of the
native layout; reinterpret the whole buffer as one span instead:

```csharp
using WoWLib;

foreach (var chunk in tile.Chunks)
{
// ONE interop crossing for all 145 normals, zero allocations:
var normals = chunk.Normals.AsDataSpan(); // == AsSpan<McnrEntry.Data>()
for (int i = 0; i < normals.Length; i++)
Emit(normals[i].Normal[0], normals[i].Normal[1], normals[i].Normal[2]);
}
```

The same applies to any scalar vector (`Indices`, `Heights`, …) through the
non-generic `AsSpan()`. Both spans are zero-copy views of C++ memory — valid
until a size-changing operation or `Dispose`, and writable (edits land
directly in the entity).
27 changes: 27 additions & 0 deletions docs/content/guide/version-agnostic.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,33 @@ type systems keep you honest about the rest:
};
```

## One root above the families (C#)

Every file-level entity in C# — the six format family bases and `BLP` —
derives **`Formats.FileEntity`**, which carries the contract they all share
(`Validate`, `EnsureValid`). A mixed batch of loaded files processes
uniformly, no format named:

```csharp
using WoWLib;

static void AuditAll(IEnumerable<Formats.FileEntity> entities)
{
foreach (var e in entities)
{
using var report = e.Validate(); // dispatches format, then version
// ...
}
}
```

Reading and writing stay on the family bases (`Formats.WMO.WMO.Read(...)`,
…): ADT's verbs genuinely take an extra alpha-format argument, so a uniform
root signature would be a lie. Copies are the C# copy-constructor idiom —
`new WMOWotlk(model)` — and `Dispose` is optional deterministic cleanup (the
finalizer releases native memory on collection anyway; `using` just makes it
prompt).

## Converting between eras

`convert` is the bridge from generic reading to targeted writing — read from
Expand Down
3 changes: 2 additions & 1 deletion src/wowlib/formats/adt/adt.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
#include <wowlib/formats/adt/chunks/texture.hpp>
#include <wowlib/formats/adt/liquid.hpp>
#include <wowlib/formats/adt/map_chunk.hpp>
#include <wowlib/formats/common/file_entity.hpp>
#include <wowlib/formats/common/annotations.hpp>
#include <wowlib/formats/common/chunked_file.hpp>
#include <wowlib/formats/common/flags.hpp>
Expand Down Expand Up @@ -85,7 +86,7 @@ namespace wowlib::formats::adt
Construct the concrete version with ADT.for_version(expansion), then
read()/write(); the per-version ADT* classes are subclasses. See
https://wowdev.wiki/ADT/v18.)")
]] ADTBase
]] ADTBase : FileEntityBase
{
};

Expand Down
7 changes: 6 additions & 1 deletion src/wowlib/formats/blp/blp.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
#include <wowlib/formats/common/types.hpp>
#include <wowlib/formats/common/validation.hpp>
#include <wowlib/fs/filesystem.hpp>
#include <wowlib/formats/common/file_entity.hpp>

namespace wowlib::formats::blp
{
Expand Down Expand Up @@ -207,6 +208,10 @@ namespace wowlib::formats::blp

struct [[
=welder::weld,
// The dotnet style would coerce the all-caps identifier to Blp; the
// format acronym is the name (round-1 API feedback). Python already
// spells BLP, so the rename is cs-scoped.
=welder::weld_as(wowlib::lang::cs, "BLP"),
=welder::doc(R"(
A BLP2 texture file — every WoW client release reads the same layout,
so the class carries no client-version axis. read()/write() move the
Expand All @@ -216,7 +221,7 @@ namespace wowlib::formats::blp
settings) rebuilds the palette/compression/mip chain from one. Raw
payload access goes through mip()/set_mip(). See
https://wowdev.wiki/BLP.)")
]] BLP
]] BLP : FileEntityBase
{
[[=welder::doc("The header version field; 1 in every shipped file.")]]
std::uint32_t version = blp_version_1;
Expand Down
37 changes: 37 additions & 0 deletions src/wowlib/formats/common/file_entity.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#pragma once

#include <wowlib/core/lang.hpp>

namespace wowlib::formats
{
/** The version-agnostic root of every file-level entity (welded as
"FileEntity").

Like the per-format `*Base` classes, this empty base exists ENTIRELY for
the language bindings: it gives every file entity — the six versioned
family bases (WMO, M2, Skeleton, ADT, WDT, WDL) and the unversioned BLP —
one common welded supertype, so binding users can hold a heterogeneous
collection and run the contract every entity shares
(`Validate`/`EnsureValid`; the fs `Read`/`Write` pair stays on the family
bases, because ADT's genuinely takes an extra alpha-format argument).
In C# the family-surface synthesis derives that shared contract
automatically — it hoists exactly the members every child hierarchy ends
up binding identically. It has no role in the C++ API, where the
statically-typed entities are used directly.

@see https://wowdev.wiki/Main_Page */
struct [[
=welder::weld,
=welder::weld_as("FileEntity"),
WOWLIB_CS_FAMILY_SURFACE
=welder::doc(R"(
The version-agnostic root of every file-level entity: the WMO, M2,
Skeleton, ADT, WDT and WDL family bases and the BLP all derive it.
Holds the contract every entity shares (validate / ensure_valid), so a
heterogeneous collection of loaded files can be processed uniformly;
reading and writing stay on the per-format types, whose signatures
differ.)")
]] FileEntityBase
{
};
}
3 changes: 2 additions & 1 deletion src/wowlib/formats/m2/m2.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
#include <wowlib/core/error.hpp>
#include <wowlib/core/file_key.hpp>
#include <wowlib/core/lang.hpp>
#include <wowlib/formats/common/file_entity.hpp>
#include <wowlib/formats/common/flags.hpp>
#include <wowlib/formats/common/version_slot.hpp>
#include <wowlib/formats/m2/bone/bone.hpp>
Expand Down Expand Up @@ -59,7 +60,7 @@ namespace wowlib::formats::m2
its satellite files (.skin, .anim) baked in. Construct the concrete
version with M2.for_version(expansion), then read()/write(); the
per-version M2* classes are subclasses. See https://wowdev.wiki/M2.)")
]] M2Base
]] M2Base : FileEntityBase
{
bool operator==(const M2Base&) const = default;
};
Expand Down
3 changes: 2 additions & 1 deletion src/wowlib/formats/m2/skeleton.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
#include <wowlib/core/error.hpp>
#include <wowlib/core/file_key.hpp>
#include <wowlib/core/lang.hpp>
#include <wowlib/formats/common/file_entity.hpp>
#include <wowlib/formats/common/chunked_file.hpp>
#include <wowlib/formats/m2/offset_block.hpp>
#include <wowlib/formats/m2/boundaries.hpp>
Expand Down Expand Up @@ -215,7 +216,7 @@ namespace wowlib::formats::m2
version. Construct a concrete version with
Skeleton.for_version(expansion); the per-version Skeleton* classes are
subclasses. See https://wowdev.wiki/M2/.skel.)")
]] SkeletonBase
]] SkeletonBase : FileEntityBase
{
bool operator==(const SkeletonBase&) const = default;
};
Expand Down
3 changes: 2 additions & 1 deletion src/wowlib/formats/wdl/wdl.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
#include <wowlib/core/error.hpp>
#include <wowlib/core/file_key.hpp>
#include <wowlib/core/lang.hpp>
#include <wowlib/formats/common/file_entity.hpp>
#include <wowlib/formats/common/chunked_file.hpp>
#include <wowlib/formats/common/fourcc.hpp>
#include <wowlib/formats/common/map_placements.hpp>
Expand Down Expand Up @@ -64,7 +65,7 @@ namespace wowlib::formats::wdl
version with WDL.for_version(expansion), then read()/write(); the
per-version WDL* classes are subclasses. See
https://wowdev.wiki/WDL.)")
]] WDLBase
]] WDLBase : FileEntityBase
{
};

Expand Down
55 changes: 54 additions & 1 deletion src/wowlib/formats/wdt/wdt.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
#include <wowlib/core/error.hpp>
#include <wowlib/core/file_key.hpp>
#include <wowlib/core/lang.hpp>
#include <wowlib/formats/common/file_entity.hpp>
#include <wowlib/formats/common/version_slot.hpp>
#include <wowlib/formats/wdt/boundaries.hpp>
#include <wowlib/formats/wdt/fogs/fogs.hpp>
Expand Down Expand Up @@ -51,7 +52,7 @@ namespace wowlib::formats::wdt
entity. Construct the concrete version with WDT.for_version(expansion),
then read()/write(); the per-version WDT* classes are subclasses. See
https://wowdev.wiki/WDT.)")
]] WDTBase
]] WDTBase : FileEntityBase
{
};

Expand Down Expand Up @@ -153,6 +154,24 @@ namespace wowlib::formats::wdt
const FileKey& key
[[=welder::doc("the main file identity; must resolve to a path")]]) const;

[[=welder::doc(R"(
Check the logical integrity contracts this object must satisfy to
LOAD in the client — across the main file AND every engaged
satellite — which write() deliberately never enforces. Call it
before writing when you want to know the files will load. An object
read from a client and left unmodified reports no errors.)"),
=welder::returns(R"(every violated contract, each with its member path
("root..." / "occlusion..." / "lights..." /
"fogs..." / "particulates..."))")]]
ValidationReport validate() const;

[[nodiscard]]
[[=welder::doc("Validate and raise on the first error instead of "
"returning a report — the assert-style face of "
"validate()."),
=welder::returns("nothing; raises when validate() finds any error")]]
Result<void> ensure_valid() const;

private:
// --- internal fs-I/O helpers (definitions at the bottom of this header) --

Expand Down Expand Up @@ -273,6 +292,40 @@ namespace wowlib::formats::wdt
return {};
}

template <ClientVersion V>
ValidationReport detail::WDT<V>::validate() const
{
ValidationReport report;
{
const std::size_t mark = report.size();
formats::detail::validate_entity(root, report);
report.prefix_from(mark, "root");
}
// Each engaged satellite validates under its member path; the version
// ranges the entity does not carry cost nothing to gate on.
const auto validate_part = [&report](const auto& part, std::string_view name)
{
const std::size_t mark = report.size();
formats::detail::validate_entity(part, report);
report.prefix_from(mark, std::string{name});
};
if constexpr (requires { this->occlusion; })
validate_part(this->occlusion, "occlusion");
if constexpr (requires { this->lights; })
validate_part(this->lights, "lights");
if constexpr (requires { this->fogs; })
validate_part(this->fogs, "fogs");
if constexpr (requires { this->particulates; })
validate_part(this->particulates, "particulates");
return report;
}

template <ClientVersion V>
Result<void> detail::WDT<V>::ensure_valid() const
{
return validate().to_result();
}

template <ClientVersion V>
Result<void> detail::WDT<V>::write(fs::FileSystem& fs, const FileKey& key) const
{
Expand Down
3 changes: 2 additions & 1 deletion src/wowlib/formats/wmo/wmo.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

#include <wowlib/core/client_version.hpp>
#include <wowlib/core/error.hpp>
#include <wowlib/formats/common/file_entity.hpp>
#include <wowlib/core/file_key.hpp>
#include <wowlib/core/lang.hpp>
#include <wowlib/formats/wmo/group/group.hpp>
Expand Down Expand Up @@ -48,7 +49,7 @@ namespace wowlib::formats::wmo
file and all its group files as one entity. Construct the concrete
version with WMO.for_version(expansion), then read()/write(); the
per-version WMO* classes are subclasses. See https://wowdev.wiki/WMO.)")
]] WMOBase
]] WMOBase : FileEntityBase
{
};

Expand Down
43 changes: 43 additions & 0 deletions tests/csharp/FamilySurfaceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,49 @@ public void MethodsDispatchOnTheBase()
Assert.NotNull(report);
}

[Fact]
public void FileEntityRootRunsTheSharedContract()
{
// Every file-level entity derives Formats.FileEntity; the multi-level
// family surface hoists the contract they ALL bind (Validate /
// EnsureValid), so a heterogeneous collection processes uniformly —
// WMO family member and unversioned BLP alike.
using var wmo = Formats.WMO.WMO.ForVersion(Expansion.Wotlk);
using var blp = new Formats.BLP.BLP();
var entities = new Formats.FileEntity[] { wmo, blp };
foreach (var e in entities)
{
using var report = e.Validate();
Assert.NotNull(report);
}
// fs Read/Write do NOT leak to the root: ADT's signature differs, so
// the intersection correctly excludes them.
Assert.Null(typeof(Formats.FileEntity).GetMethod("Read"));
Assert.NotNull(typeof(Formats.WMO.WMO).GetMethod("Read"));
// The copy-constructor idiom replaced Clone().
Assert.Null(typeof(Formats.BLP.BLP).GetMethod("Clone"));
using var copy = new Formats.BLP.BLP(blp);
Assert.NotNull(copy);
}

[Fact]
public unsafe void BlittableMirrorsCoverTheHotRecords()
{
// The renderer-profile fix: per-vertex records read in BULK — one
// interop crossing per buffer via the blittable Data mirror, instead
// of several per element through live views.
using var normals = new Vector<Formats.ADT.Chunks.McnrEntry>();
for (int i = 0; i < 4; i++)
using (var e = new Formats.ADT.Chunks.McnrEntry())
normals.Add(e);
var span = normals.AsDataSpan(); // the generated sugar for AsSpan<McnrEntry.Data>()
Assert.Equal(4, span.Length);
span[2].Normal[1] = -7; // straight to native
Assert.Equal(-7, normals[2].Normal[1]); // seen by the live view
Assert.Throws<System.ArgumentException>(
() => normals.AsSpan<ulong>()); // size gate holds
}

[Fact]
public void BareBaseInstanceThrowsFromTheDispatchDefaultArm()
{
Expand Down
2 changes: 2 additions & 0 deletions tests/csharp/WoWLib.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
has (a dotnet 10 SDK need not carry the 8.0 runtime). -->
<RollForward>Major</RollForward>
<Nullable>enable</Nullable>
<!-- The blittable-mirror tests index fixed buffers (Data.Normal[i]). -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IsPackable>false</IsPackable>
<WowlibProject Condition="'$(WowlibProject)' == ''">$(MSBuildThisFileDirectory)../../build/csharp/bindings/WoWLib/WoWLib.csproj</WowlibProject>
</PropertyGroup>
Expand Down
Loading