diff --git a/.claude/context/csharp-bindings.md b/.claude/context/csharp-bindings.md index 0d339df..9b4c775 100644 --- a/.claude/context/csharp-bindings.md +++ b/.claude/context/csharp-bindings.md @@ -1,5 +1,25 @@ # C#/.NET bindings (welder-csharp rod) +## 2026-08-22 (evening): entity hierarchy + copy ctors (user feedback round 2) + +- **Q1 (ADT 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 != ADT 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: diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index 8280971..203866e 100644 --- a/cmake/Dependencies.cmake +++ b/cmake/Dependencies.cmake @@ -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 diff --git a/docs/content/guide/maps.md b/docs/content/guide/maps.md index 18d3724..8f835f2 100644 --- a/docs/content/guide/maps.md +++ b/docs/content/guide/maps.md @@ -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() + 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). diff --git a/docs/content/guide/version-agnostic.md b/docs/content/guide/version-agnostic.md index d244bc7..29b3af9 100644 --- a/docs/content/guide/version-agnostic.md +++ b/docs/content/guide/version-agnostic.md @@ -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 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 diff --git a/src/wowlib/formats/adt/adt.hpp b/src/wowlib/formats/adt/adt.hpp index 2b0fc1d..2077316 100644 --- a/src/wowlib/formats/adt/adt.hpp +++ b/src/wowlib/formats/adt/adt.hpp @@ -54,6 +54,7 @@ #include #include #include +#include #include #include #include @@ -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 { }; diff --git a/src/wowlib/formats/blp/blp.hpp b/src/wowlib/formats/blp/blp.hpp index 2d588be..355afe2 100644 --- a/src/wowlib/formats/blp/blp.hpp +++ b/src/wowlib/formats/blp/blp.hpp @@ -37,6 +37,7 @@ #include #include #include +#include namespace wowlib::formats::blp { @@ -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 @@ -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; diff --git a/src/wowlib/formats/common/file_entity.hpp b/src/wowlib/formats/common/file_entity.hpp new file mode 100644 index 0000000..b1b5c8b --- /dev/null +++ b/src/wowlib/formats/common/file_entity.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include + +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 + { + }; +} diff --git a/src/wowlib/formats/m2/m2.hpp b/src/wowlib/formats/m2/m2.hpp index 5fd55ba..ae0bf3e 100644 --- a/src/wowlib/formats/m2/m2.hpp +++ b/src/wowlib/formats/m2/m2.hpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -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; }; diff --git a/src/wowlib/formats/m2/skeleton.hpp b/src/wowlib/formats/m2/skeleton.hpp index e1429cc..9dd431b 100644 --- a/src/wowlib/formats/m2/skeleton.hpp +++ b/src/wowlib/formats/m2/skeleton.hpp @@ -33,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -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; }; diff --git a/src/wowlib/formats/wdl/wdl.hpp b/src/wowlib/formats/wdl/wdl.hpp index 8a22737..02ce506 100644 --- a/src/wowlib/formats/wdl/wdl.hpp +++ b/src/wowlib/formats/wdl/wdl.hpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -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 { }; diff --git a/src/wowlib/formats/wdt/wdt.hpp b/src/wowlib/formats/wdt/wdt.hpp index 50efb91..8847c21 100644 --- a/src/wowlib/formats/wdt/wdt.hpp +++ b/src/wowlib/formats/wdt/wdt.hpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -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 { }; @@ -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 ensure_valid() const; + private: // --- internal fs-I/O helpers (definitions at the bottom of this header) -- @@ -273,6 +292,40 @@ namespace wowlib::formats::wdt return {}; } + template + ValidationReport detail::WDT::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 + Result detail::WDT::ensure_valid() const + { + return validate().to_result(); + } + template Result detail::WDT::write(fs::FileSystem& fs, const FileKey& key) const { diff --git a/src/wowlib/formats/wmo/wmo.hpp b/src/wowlib/formats/wmo/wmo.hpp index ff2dbee..f192892 100644 --- a/src/wowlib/formats/wmo/wmo.hpp +++ b/src/wowlib/formats/wmo/wmo.hpp @@ -16,6 +16,7 @@ #include #include +#include #include #include #include @@ -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 { }; diff --git a/tests/csharp/FamilySurfaceTests.cs b/tests/csharp/FamilySurfaceTests.cs index d7eeb61..b40d265 100644 --- a/tests/csharp/FamilySurfaceTests.cs +++ b/tests/csharp/FamilySurfaceTests.cs @@ -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(); + 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() + 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( + () => normals.AsSpan()); // size gate holds + } + [Fact] public void BareBaseInstanceThrowsFromTheDispatchDefaultArm() { diff --git a/tests/csharp/WoWLib.Tests.csproj b/tests/csharp/WoWLib.Tests.csproj index 413a55b..910c523 100644 --- a/tests/csharp/WoWLib.Tests.csproj +++ b/tests/csharp/WoWLib.Tests.csproj @@ -17,6 +17,8 @@ has (a dotnet 10 SDK need not carry the 8.0 runtime). --> Major enable + + true false $(MSBuildThisFileDirectory)../../build/csharp/bindings/WoWLib/WoWLib.csproj