From 829b9cc0256aa191666dfbd6bab3a5a1bf97ac7d Mon Sep 17 00:00:00 2001 From: Sergey Shumakov Date: Sun, 23 Aug 2026 10:44:32 +0300 Subject: [PATCH 1/4] =?UTF-8?q?csharp:=20the=20M2=20record=20families=20ge?= =?UTF-8?q?t=20welded=20bases=20=E2=80=94=20counts=20read=20through=20M2Ro?= =?UTF-8?q?ot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User report: reading root.Sequences.Count through the common base needed a six-arm per-era switch — the M2 record families were the ONE group still welding standalone (the old BASED distinction), so their collection members never hoisted onto M2Root. Structural fix, riding welder-csharp 3cdf95b (pin bumped): - ~24 version-agnostic bases: M2SequenceBase / M2CompBoneBase / M2CameraBase / M2ParticleBase / M2RibbonBase / the material and scene records / both skin records / M2EventTrackBase (the versioned detail::M2TrackBase event family — mind the pre-existing name), plus template M2TrackFamilyBase for the nine M2Track value families (alias-welded in gen_m2 via M2Track*Fam aliases in m2_ranges.hpp). All carry weld_as(family name) + the family-surface macro, and every base defaults operator== — the concretes' defaulted equality requires comparable bases (first build failure). - All 24 joined the facade BASED set: ForVersion works on every record family, and the standalone factory partials became real base partials. - The multi-level family surface then hoists M2Root.Sequences / Bones / ParticleEmitters / Attachments / ... as FamilyVector — the user's switch collapses to root.Sequences.Count on the base, with era-checked element access riding along. Same for M2SkinProfile.Sections and every other record collection. Upstream fixes this shape forced (welder-csharp PR#6): base upcast thunks now survive alias-welded SPECIALIZATION bases (cpp_name/upath collapse to the namespace for a specialization — deferred anchor + symtok instead), and the AsSpan diagnostics name the right call per element kind (the "specific ADT" report was Vector/Vector> misuse of the scalar AsSpan, unconditional but caught per tile). FamilySurfaceTests locks the reported scenario: counts through the base, population through the concrete observed via the base view, and M2Sequence.ForVersion. Full native rebuild; dotnet test 36/36. Co-Authored-By: Claude Fable 5 --- .claude/context/csharp-bindings.md | 22 ++++++ bindings/csharp/gen_m2.cpp | 13 +++ bindings/instantiations/m2_ranges.hpp | 14 ++++ cmake/Dependencies.cmake | 2 +- src/wowlib/formats/m2/root/record/bone.hpp | 22 +++++- src/wowlib/formats/m2/root/record/effects.hpp | 45 +++++++++-- .../formats/m2/root/record/material.hpp | 77 +++++++++++++++++- src/wowlib/formats/m2/root/record/scene.hpp | 79 +++++++++++++++++-- .../formats/m2/root/record/sequence.hpp | 24 +++++- src/wowlib/formats/m2/root/record/track.hpp | 43 +++++++++- src/wowlib/formats/m2/skin/records.hpp | 41 +++++++++- tests/csharp/FamilySurfaceTests.cs | 27 +++++++ tools/gen_cs_format_facades.py | 10 +++ 13 files changed, 392 insertions(+), 27 deletions(-) diff --git a/.claude/context/csharp-bindings.md b/.claude/context/csharp-bindings.md index 9b4c775..8313eeb 100644 --- a/.claude/context/csharp-bindings.md +++ b/.claude/context/csharp-bindings.md @@ -1,5 +1,27 @@ # C#/.NET bindings (welder-csharp rod) +## 2026-08-23: M2 record family bases (user feedback round 3) + +- **User report 1** ("AsSpan() requires a scalar or enum element type" on a + "specific ADT"): NOT data-dependent — the consumer called scalar AsSpan() + on `chunk.Layers` (Vector, records → AsDataSpan()) and + `chunk.AlphaMaps` (Vector>, nested → iterate). The per-tile + catch made it look file-specific. welder-csharp PR#6: the three failure + messages now name the element type and the correct call. +- **User report 2** (root.Sequences.Count needed a 6-arm era switch): the + M2 record families welded STANDALONE (the old BASED distinction), so + collection members never hoisted to M2Root. Fixed structurally: ~24 + version-agnostic bases (M2SequenceBase etc. + template + M2TrackFamilyBase for the 9 track value families, alias-welded in + gen_m2 as M2Track*Fam aliases in m2_ranges.hpp; M2EventTrackBase for the + M2TrackBase event family — note the pre-existing name collision: + detail::M2TrackBase is the VERSIONED event-track layout). All carry + weld_as(family name) + the family_surface macro; all joined the facade + BASED set (ForVersion works on every record family now). Multi-level + surfaces then hoist M2Root.Sequences/Bones/... as + FamilyVector/... with Count + era-checked element access. + BASE-CLASS CHANGES NEED THE FULL NATIVE REBUILD (new upcast thunks). + ## 2026-08-22 (evening): entity hierarchy + copy ctors (user feedback round 2) - **Q1 (ADT generics): assessed INFEASIBLE, by design of C#** diff --git a/bindings/csharp/gen_m2.cpp b/bindings/csharp/gen_m2.cpp index 88ffd54..f8d4a2f 100644 --- a/bindings/csharp/gen_m2.cpp +++ b/bindings/csharp/gen_m2.cpp @@ -24,6 +24,19 @@ namespace wowlib_cs using W = ::welder::welder; auto m0 = wcs::rod::at(doc, "Formats.M2.Root.Record"); + // The M2Track value families' BASES (alias-welded — templates are + // invisible to the namespace walk), named as the families ARE: + namespace rec = ::wowlib::formats::m2::root::record; + W::weld_type<^^rec::M2TrackC3VectorFam>(m0, "M2TrackC3Vector"); + W::weld_type<^^rec::M2TrackC4QuaternionFam>(m0, "M2TrackC4Quaternion"); + W::weld_type<^^rec::M2TrackCompQuatFam>(m0, "M2TrackCompQuat"); + W::weld_type<^^rec::M2TrackFloatFam>(m0, "M2TrackFloat"); + W::weld_type<^^rec::M2TrackFixed16Fam>(m0, "M2TrackFixed16"); + W::weld_type<^^rec::M2TrackUInt8Fam>(m0, "M2TrackUInt8"); + W::weld_type<^^rec::M2TrackUInt16Fam>(m0, "M2TrackUInt16"); + W::weld_type<^^rec::M2TrackSplineC3VectorFam>(m0, "M2TrackSplineC3Vector"); + W::weld_type<^^rec::M2TrackSplineFloatFam>(m0, "M2TrackSplineFloat"); + // The version-INDEPENDENT payload aliases the track types reference // (declared standalone in m2_ranges.hpp, outside the X-macro tables — // the old single-TU walk welded them through its alias branch). diff --git a/bindings/instantiations/m2_ranges.hpp b/bindings/instantiations/m2_ranges.hpp index 63b73b8..ebfe039 100644 --- a/bindings/instantiations/m2_ranges.hpp +++ b/bindings/instantiations/m2_ranges.hpp @@ -98,6 +98,20 @@ // comes last. namespace wowlib::formats::m2::root::record { + // The version-agnostic BASES of the M2Track VALUE families: one + // M2TrackFamilyBase per value type, alias-welded (gen_m2.cpp) under the + // family name so each per-value family gets its common supertype — the + // non-template record bases weld through the namespace walk instead. + using M2TrackC3VectorFam = M2TrackFamilyBase; + using M2TrackC4QuaternionFam = M2TrackFamilyBase; + using M2TrackCompQuatFam = M2TrackFamilyBase; + using M2TrackFloatFam = M2TrackFamilyBase; + using M2TrackFixed16Fam = M2TrackFamilyBase; + using M2TrackUInt8Fam = M2TrackFamilyBase; + using M2TrackUInt16Fam = M2TrackFamilyBase; + using M2TrackSplineC3VectorFam = M2TrackFamilyBase>; + using M2TrackSplineFloatFam = M2TrackFamilyBase>; + // element/ramp types with no version axis, named once using M2SplineKeyC3Vector = M2SplineKey; using M2SplineKeyFloat = M2SplineKey; diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index 203866e..2d63385 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 1eea11a4f0a8404db659ba97e2e75f9c01d1586b) + GIT_TAG 3cdf95bc5b8c6d877d610a8c0a449732e298a157) # --- 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/src/wowlib/formats/m2/root/record/bone.hpp b/src/wowlib/formats/m2/root/record/bone.hpp index efd9786..039abdd 100644 --- a/src/wowlib/formats/m2/root/record/bone.hpp +++ b/src/wowlib/formats/m2/root/record/bone.hpp @@ -9,12 +9,30 @@ #include +#include #include #include #include namespace wowlib::formats::m2::root::record { + /** The version-agnostic base of every M2CompBone (welded as "M2CompBone"). + Bindings-only, like every *Base: it gives the per-version classes a + common welded supertype, so the family surface hoists their shared + members and containers of them carry a base-typed live view. */ + struct [[ + =welder::weld, + =welder::weld_as("M2CompBone"), + WOWLIB_CS_FAMILY_SURFACE + =welder::doc(R"( + One skeleton bone. Abstract over the client version; construct a concrete + version with M2CompBone.ForVersion / for_version.)") + ]] M2CompBoneBase + { + // The concretes default operator== — the base must be comparable too. + bool operator==(const M2CompBoneBase&) const = default; + }; + enum class [[ =welder::weld, =welder::doc("M2CompBone flags: parent-transform exemptions, billboarding " @@ -46,7 +64,7 @@ namespace wowlib::formats::m2::root::record struct [[ =welder::weld, =welder::doc("A bone, vanilla layout: raw-quaternion rotations, no name CRC.") - ]] M2CompBone + ]] M2CompBone : M2CompBoneBase { [[=welder::doc("Key-bone-lookup back reference, -1 if none.")]] std::int32_t key_bone_id = -1; @@ -74,7 +92,7 @@ namespace wowlib::formats::m2::root::record =welder::weld, =welder::doc("A bone (TBC+): compressed-quaternion rotations plus the debug name " "CRC; the track era inside follows the entity version.") - ]] M2CompBone + ]] M2CompBone : M2CompBoneBase { [[=welder::doc("Key-bone-lookup back reference, -1 if none.")]] std::int32_t key_bone_id = -1; diff --git a/src/wowlib/formats/m2/root/record/effects.hpp b/src/wowlib/formats/m2/root/record/effects.hpp index 8bb5a8a..0f616f6 100644 --- a/src/wowlib/formats/m2/root/record/effects.hpp +++ b/src/wowlib/formats/m2/root/record/effects.hpp @@ -13,6 +13,7 @@ #include +#include #include #include #include @@ -20,6 +21,40 @@ namespace wowlib::formats::m2::root::record { + /** The version-agnostic base of every M2Ribbon (welded as "M2Ribbon"). + Bindings-only, like every *Base: it gives the per-version classes a + common welded supertype, so the family surface hoists their shared + members and containers of them carry a base-typed live view. */ + struct [[ + =welder::weld, + =welder::weld_as("M2Ribbon"), + WOWLIB_CS_FAMILY_SURFACE + =welder::doc(R"( + One ribbon emitter. Abstract over the client version; construct a concrete + version with M2Ribbon.ForVersion / for_version.)") + ]] M2RibbonBase + { + // The concretes default operator== — the base must be comparable too. + bool operator==(const M2RibbonBase&) const = default; + }; + + /** The version-agnostic base of every M2Particle (welded as "M2Particle"). + Bindings-only, like every *Base: it gives the per-version classes a + common welded supertype, so the family surface hoists their shared + members and containers of them carry a base-typed live view. */ + struct [[ + =welder::weld, + =welder::weld_as("M2Particle"), + WOWLIB_CS_FAMILY_SURFACE + =welder::doc(R"( + One particle emitter. Abstract over the client version; construct a concrete + version with M2Particle.ForVersion / for_version.)") + ]] M2ParticleBase + { + // The concretes default operator== — the base must be comparable too. + bool operator==(const M2ParticleBase&) const = default; + }; + namespace detail { // The annotated era layouts; instantiate through the canonicalizing @@ -29,7 +64,7 @@ namespace detail =welder::weld, =welder::doc("A ribbon (trail) emitter; the priority/color-index tail exists " "WotLK+.") - ]] M2Ribbon + ]] M2Ribbon : M2RibbonBase { [[=welder::doc("Always -1 in known files.")]] std::uint32_t ribbon_id = 0xFFFFFFFF; @@ -119,7 +154,7 @@ namespace detail =welder::weld, =welder::doc("A particle emitter, vanilla layout: wide u16 blending/emitter header, " "static color/scale/UV ramps, a single spin value.") - ]] M2Particle + ]] M2Particle : M2ParticleBase { [[=welder::doc("Always -1 in known files.")]] std::uint32_t particle_id = 0xFFFFFFFF; @@ -232,7 +267,7 @@ namespace detail =welder::weld, =welder::doc("A particle emitter, TBC layout: byte-packed blending/emitter beside " "the ParticleColor.dbc index; ramps still static.") - ]] M2Particle + ]] M2Particle : M2ParticleBase { [[=welder::doc("Always -1 in known files.")]] std::uint32_t particle_id = 0xFFFFFFFF; @@ -347,7 +382,7 @@ namespace detail =welder::weld, =welder::doc("A particle emitter, WotLK-era layout (476 bytes): FBlock ramps, " "lifespan/emission variation, four spin fields.") - ]] M2Particle + ]] M2Particle : M2ParticleBase { [[=welder::doc("Always -1 in known files.")]] std::uint32_t particle_id = 0xFFFFFFFF; @@ -469,7 +504,7 @@ namespace detail =welder::weld, =welder::doc("A particle emitter (Cata+, 492 bytes): multi-textured — packed " "texture ids, multiTexScale, trailing scroll parameters.") - ]] M2Particle + ]] M2Particle : M2ParticleBase { [[=welder::doc("Always -1 in known files.")]] std::uint32_t particle_id = 0xFFFFFFFF; diff --git a/src/wowlib/formats/m2/root/record/material.hpp b/src/wowlib/formats/m2/root/record/material.hpp index fb61455..e91376c 100644 --- a/src/wowlib/formats/m2/root/record/material.hpp +++ b/src/wowlib/formats/m2/root/record/material.hpp @@ -11,12 +11,81 @@ #include +#include #include #include #include namespace wowlib::formats::m2::root::record { + /** The version-agnostic base of every M2Color (welded as "M2Color"). + Bindings-only, like every *Base: it gives the per-version classes a + common welded supertype, so the family surface hoists their shared + members and containers of them carry a base-typed live view. */ + struct [[ + =welder::weld, + =welder::weld_as("M2Color"), + WOWLIB_CS_FAMILY_SURFACE + =welder::doc(R"( + One color + alpha animation pair. Abstract over the client version; construct a concrete + version with M2Color.ForVersion / for_version.)") + ]] M2ColorBase + { + // The concretes default operator== — the base must be comparable too. + bool operator==(const M2ColorBase&) const = default; + }; + + /** The version-agnostic base of every M2TextureWeight (welded as "M2TextureWeight"). + Bindings-only, like every *Base: it gives the per-version classes a + common welded supertype, so the family surface hoists their shared + members and containers of them carry a base-typed live view. */ + struct [[ + =welder::weld, + =welder::weld_as("M2TextureWeight"), + WOWLIB_CS_FAMILY_SURFACE + =welder::doc(R"( + One texture weight (transparency) track. Abstract over the client version; construct a concrete + version with M2TextureWeight.ForVersion / for_version.)") + ]] M2TextureWeightBase + { + // The concretes default operator== — the base must be comparable too. + bool operator==(const M2TextureWeightBase&) const = default; + }; + + /** The version-agnostic base of every M2TextureFlipbook (welded as "M2TextureFlipbook"). + Bindings-only, like every *Base: it gives the per-version classes a + common welded supertype, so the family surface hoists their shared + members and containers of them carry a base-typed live view. */ + struct [[ + =welder::weld, + =welder::weld_as("M2TextureFlipbook"), + WOWLIB_CS_FAMILY_SURFACE + =welder::doc(R"( + One texture flipbook animation. Abstract over the client version; construct a concrete + version with M2TextureFlipbook.ForVersion / for_version.)") + ]] M2TextureFlipbookBase + { + // The concretes default operator== — the base must be comparable too. + bool operator==(const M2TextureFlipbookBase&) const = default; + }; + + /** The version-agnostic base of every M2TextureTransform (welded as "M2TextureTransform"). + Bindings-only, like every *Base: it gives the per-version classes a + common welded supertype, so the family surface hoists their shared + members and containers of them carry a base-typed live view. */ + struct [[ + =welder::weld, + =welder::weld_as("M2TextureTransform"), + WOWLIB_CS_FAMILY_SURFACE + =welder::doc(R"( + One texture UV transform animation. Abstract over the client version; construct a concrete + version with M2TextureTransform.ForVersion / for_version.)") + ]] M2TextureTransformBase + { + // The concretes default operator== — the base must be comparable too. + bool operator==(const M2TextureTransformBase&) const = default; + }; + struct [[ =welder::weld, =welder::doc("An M2 vertex: position, 4-bone weights/indices, normal and " @@ -93,7 +162,7 @@ namespace detail =welder::weld, =welder::doc("A vertex color + alpha animation pair, referenced from skin batches " "by color index.") - ]] M2Color + ]] M2Color : M2ColorBase { [[=welder::doc("RGB vertex color.")]] record::M2Track color{}; @@ -107,7 +176,7 @@ namespace detail struct [[ =welder::weld, =welder::doc("A global texture weight (transparency) track.") - ]] M2TextureWeight + ]] M2TextureWeight : M2TextureWeightBase { [[=welder::doc("0 transparent .. 0x7FFF opaque; multiplies the color " "block's alpha.")]] @@ -120,7 +189,7 @@ namespace detail struct [[ =welder::weld, =welder::doc("A pre-WotLK texture flipbook slot; never observed engaged in files.") - ]] M2TextureFlipbook + ]] M2TextureFlipbook : M2TextureFlipbookBase { [[=welder::doc("Frame index keyframes.")]] record::M2Track frames{}; @@ -133,7 +202,7 @@ namespace detail =welder::weld, =welder::doc("A UV animation: translation/rotation/scaling keyframes for the " "texture matrix (rotation pivots at texture center 0.5, 0.5).") - ]] M2TextureTransform + ]] M2TextureTransform : M2TextureTransformBase { [[=welder::doc("UV translation keyframes.")]] record::M2Track translation{}; diff --git a/src/wowlib/formats/m2/root/record/scene.hpp b/src/wowlib/formats/m2/root/record/scene.hpp index 33b0a00..a819b33 100644 --- a/src/wowlib/formats/m2/root/record/scene.hpp +++ b/src/wowlib/formats/m2/root/record/scene.hpp @@ -8,12 +8,81 @@ #include +#include #include #include #include namespace wowlib::formats::m2::root::record { + /** The version-agnostic base of every M2Attachment (welded as "M2Attachment"). + Bindings-only, like every *Base: it gives the per-version classes a + common welded supertype, so the family surface hoists their shared + members and containers of them carry a base-typed live view. */ + struct [[ + =welder::weld, + =welder::weld_as("M2Attachment"), + WOWLIB_CS_FAMILY_SURFACE + =welder::doc(R"( + One attachment point. Abstract over the client version; construct a concrete + version with M2Attachment.ForVersion / for_version.)") + ]] M2AttachmentBase + { + // The concretes default operator== — the base must be comparable too. + bool operator==(const M2AttachmentBase&) const = default; + }; + + /** The version-agnostic base of every M2Event (welded as "M2Event"). + Bindings-only, like every *Base: it gives the per-version classes a + common welded supertype, so the family surface hoists their shared + members and containers of them carry a base-typed live view. */ + struct [[ + =welder::weld, + =welder::weld_as("M2Event"), + WOWLIB_CS_FAMILY_SURFACE + =welder::doc(R"( + One timed event. Abstract over the client version; construct a concrete + version with M2Event.ForVersion / for_version.)") + ]] M2EventBase + { + // The concretes default operator== — the base must be comparable too. + bool operator==(const M2EventBase&) const = default; + }; + + /** The version-agnostic base of every M2Light (welded as "M2Light"). + Bindings-only, like every *Base: it gives the per-version classes a + common welded supertype, so the family surface hoists their shared + members and containers of them carry a base-typed live view. */ + struct [[ + =welder::weld, + =welder::weld_as("M2Light"), + WOWLIB_CS_FAMILY_SURFACE + =welder::doc(R"( + One placed light. Abstract over the client version; construct a concrete + version with M2Light.ForVersion / for_version.)") + ]] M2LightBase + { + // The concretes default operator== — the base must be comparable too. + bool operator==(const M2LightBase&) const = default; + }; + + /** The version-agnostic base of every M2Camera (welded as "M2Camera"). + Bindings-only, like every *Base: it gives the per-version classes a + common welded supertype, so the family surface hoists their shared + members and containers of them carry a base-typed live view. */ + struct [[ + =welder::weld, + =welder::weld_as("M2Camera"), + WOWLIB_CS_FAMILY_SURFACE + =welder::doc(R"( + One model camera. Abstract over the client version; construct a concrete + version with M2Camera.ForVersion / for_version.)") + ]] M2CameraBase + { + // The concretes default operator== — the base must be comparable too. + bool operator==(const M2CameraBase&) const = default; + }; + namespace detail { // The annotated era layouts; instantiate through the canonicalizing @@ -23,7 +92,7 @@ namespace detail =welder::weld, =welder::doc("An attachment point (weapons, effects, name plates), relative to a " "bone.") - ]] M2Attachment + ]] M2Attachment : M2AttachmentBase { [[=welder::doc("Attachment slot (see wowdev's attachment id table).")]] std::uint32_t id = 0; @@ -45,7 +114,7 @@ namespace detail =welder::weld, =welder::doc("A timed event ($DTH death thud, footsteps, sounds); every " "enabled-track key fires.") - ]] M2Event + ]] M2Event : M2EventBase { [[=welder::doc("Usually a '$xxx' four-char tag stored raw.")]] std::uint32_t identifier = 0; @@ -65,7 +134,7 @@ namespace detail struct [[ =welder::weld, =welder::doc("A model light: type 0 directional (login screens only), 1 point.") - ]] M2Light + ]] M2Light : M2LightBase { [[=welder::doc("0 directional, 1 point.")]] std::uint16_t type = 1; @@ -100,7 +169,7 @@ namespace detail =welder::weld, =welder::doc("A camera, pre-Cata layout: a static diagonal FOV plus " "position/target/roll spline tracks.") - ]] M2Camera + ]] M2Camera : M2CameraBase { [[=welder::doc("0 portrait, 1 character info, -1 flyby.")]] std::uint32_t type = 0; @@ -130,7 +199,7 @@ namespace detail struct [[ =welder::weld, =welder::doc("A camera (Cata+): the FOV becomes a spline track at the record tail.") - ]] M2Camera + ]] M2Camera : M2CameraBase { [[=welder::doc("0 portrait, 1 character info, -1 flyby.")]] std::uint32_t type = 0; diff --git a/src/wowlib/formats/m2/root/record/sequence.hpp b/src/wowlib/formats/m2/root/record/sequence.hpp index bacec72..21dfad6 100644 --- a/src/wowlib/formats/m2/root/record/sequence.hpp +++ b/src/wowlib/formats/m2/root/record/sequence.hpp @@ -10,6 +10,7 @@ #include +#include #include #include #include @@ -17,6 +18,23 @@ namespace wowlib::formats::m2::root::record { + /** The version-agnostic base of every M2Sequence (welded as "M2Sequence"). + Bindings-only, like every *Base: it gives the per-version classes a + common welded supertype, so the family surface hoists their shared + members and containers of them carry a base-typed live view. */ + struct [[ + =welder::weld, + =welder::weld_as("M2Sequence"), + WOWLIB_CS_FAMILY_SURFACE + =welder::doc(R"( + One animation sequence entry. Abstract over the client version; construct a concrete + version with M2Sequence.ForVersion / for_version.)") + ]] M2SequenceBase + { + // The concretes default operator== — the base must be comparable too. + bool operator==(const M2SequenceBase&) const = default; + }; + enum class [[ =welder::weld, =welder::doc("M2Sequence flags — the 0x20/0x40/0x130 combination decides " @@ -60,7 +78,7 @@ namespace wowlib::formats::m2::root::record =welder::weld, =welder::doc("An animation sequence, pre-WotLK layout: global start/end timestamps, " "one u32 blend time.") - ]] M2Sequence + ]] M2Sequence : M2SequenceBase { [[=welder::doc("Animation id in AnimationData.dbc.")]] std::uint16_t id = 0; @@ -113,7 +131,7 @@ namespace wowlib::formats::m2::root::record =welder::weld, =welder::doc("An animation sequence, WotLK through MoP layout: an own duration, one " "u32 blend time.") - ]] M2Sequence + ]] M2Sequence : M2SequenceBase { [[=welder::doc("Animation id in AnimationData.dbc.")]] std::uint16_t id = 0; @@ -164,7 +182,7 @@ namespace wowlib::formats::m2::root::record =welder::weld, =welder::doc("An animation sequence, WoD+ layout: an own duration, split " "blend-in/out times.") - ]] M2Sequence + ]] M2Sequence : M2SequenceBase { [[=welder::doc("Animation id in AnimationData.dbc.")]] std::uint16_t id = 0; diff --git a/src/wowlib/formats/m2/root/record/track.hpp b/src/wowlib/formats/m2/root/record/track.hpp index 84d4997..1fb5bfc 100644 --- a/src/wowlib/formats/m2/root/record/track.hpp +++ b/src/wowlib/formats/m2/root/record/track.hpp @@ -19,6 +19,7 @@ #include +#include #include #include #include @@ -26,6 +27,40 @@ namespace wowlib::formats::m2::root::record { + /** The version-agnostic base of every M2Track VALUE FAMILY, one + base per value type T (welded per family as "M2TrackC3Vector", ... — + see the alias tables in bindings/instantiations/m2_ranges.hpp). + Bindings-only, like every *Base. */ + template + struct [[ + =welder::weld, + WOWLIB_CS_FAMILY_SURFACE + =welder::doc(R"( + An animation track family for one value type, abstract over the + client version; the per-version classes are subclasses.)") + ]] M2TrackFamilyBase + { + // The concretes default operator== — the base must be comparable too. + bool operator==(const M2TrackFamilyBase&) const = default; + }; + + /** The version-agnostic base of every M2EventTrack (welded as "M2EventTrack"). + Bindings-only, like every *Base: it gives the per-version classes a + common welded supertype, so the family surface hoists their shared + members and containers of them carry a base-typed live view. */ + struct [[ + =welder::weld, + =welder::weld_as("M2EventTrack"), + WOWLIB_CS_FAMILY_SURFACE + =welder::doc(R"( + A timestamp-only event track (every key fires). Abstract over the client version; construct a concrete + version with M2EventTrack.ForVersion / for_version.)") + ]] M2EventTrackBase + { + // The concretes default operator== — the base must be comparable too. + bool operator==(const M2EventTrackBase&) const = default; + }; + struct [[ =welder::weld, =welder::doc("An inclusive u32 range: pre-WotLK track interpolation " @@ -145,7 +180,7 @@ namespace wowlib::formats::m2::root::record =welder::weld, =welder::doc("An animation track, pre-WotLK layout: one global timeline with " "per-sequence interpolation ranges.") - ]] M2Track + ]] M2Track : M2TrackFamilyBase { [[=welder::doc("Interpolation: 0 none, 1 linear, 2 bezier, 3 hermite " "(spline types only valid for spline-key tracks).")]] @@ -175,7 +210,7 @@ namespace wowlib::formats::m2::root::record =welder::weld, =welder::doc("An animation track, WotLK+ layout: one timestamp/value array per " "sequence; an external sequence keeps its arrays in the .anim file.") - ]] M2Track + ]] M2Track : M2TrackFamilyBase { [[=welder::doc("Interpolation: 0 none, 1 linear, 2 bezier, 3 hermite " "(spline types only valid for spline-key tracks).")]] @@ -209,7 +244,7 @@ namespace wowlib::formats::m2::root::record struct [[ =welder::weld, =welder::doc("A timestamp-only event track, pre-WotLK layout (every key fires).") - ]] M2TrackBase + ]] M2TrackBase : M2EventTrackBase { [[=welder::doc("Interpolation: 0 none, 1 linear (keys fire, no value to " "interpolate).")]] @@ -233,7 +268,7 @@ namespace wowlib::formats::m2::root::record struct [[ =welder::weld, =welder::doc("A timestamp-only event track, WotLK+ layout (every key fires).") - ]] M2TrackBase + ]] M2TrackBase : M2EventTrackBase { [[=welder::doc("Interpolation: 0 none, 1 linear (keys fire, no value to " "interpolate).")]] diff --git a/src/wowlib/formats/m2/skin/records.hpp b/src/wowlib/formats/m2/skin/records.hpp index 2a8ede3..ab64748 100644 --- a/src/wowlib/formats/m2/skin/records.hpp +++ b/src/wowlib/formats/m2/skin/records.hpp @@ -16,6 +16,7 @@ #include +#include #include #include #include @@ -24,6 +25,40 @@ namespace wowlib::formats::m2::skin { + /** The version-agnostic base of every M2SkinSection (welded as "M2SkinSection"). + Bindings-only, like every *Base: it gives the per-version classes a + common welded supertype, so the family surface hoists their shared + members and containers of them carry a base-typed live view. */ + struct [[ + =welder::weld, + =welder::weld_as("M2SkinSection"), + WOWLIB_CS_FAMILY_SURFACE + =welder::doc(R"( + One renderable geometry section (submesh). Abstract over the client version; construct a concrete + version with M2SkinSection.ForVersion / for_version.)") + ]] M2SkinSectionBase + { + // The concretes default operator== — the base must be comparable too. + bool operator==(const M2SkinSectionBase&) const = default; + }; + + /** The version-agnostic base of every M2SkinProfile (welded as "M2SkinProfile"). + Bindings-only, like every *Base: it gives the per-version classes a + common welded supertype, so the family surface hoists their shared + members and containers of them carry a base-typed live view. */ + struct [[ + =welder::weld, + =welder::weld_as("M2SkinProfile"), + WOWLIB_CS_FAMILY_SURFACE + =welder::doc(R"( + One skin profile (a whole LOD's sections and batches). Abstract over the client version; construct a concrete + version with M2SkinProfile.ForVersion / for_version.)") + ]] M2SkinProfileBase + { + // The concretes default operator== — the base must be comparable too. + bool operator==(const M2SkinProfileBase&) const = default; + }; + namespace detail { // The annotated era layouts; instantiate through the canonicalizing @@ -36,7 +71,7 @@ namespace detail struct [[ =welder::weld, =welder::doc("A skin submesh, vanilla layout (no sort data yet).") - ]] M2SkinSection + ]] M2SkinSection : M2SkinSectionBase { [[=welder::doc("Mesh part (geoset) id.")]] std::uint16_t skin_section_id = 0; @@ -69,7 +104,7 @@ namespace detail struct [[ =welder::weld, =welder::doc("A skin submesh (TBC+): adds the sort center and radius.") - ]] M2SkinSection + ]] M2SkinSection : M2SkinSectionBase { [[=welder::doc("Mesh part (geoset) id.")]] std::uint16_t skin_section_id = 0; @@ -184,7 +219,7 @@ namespace detail =welder::doc("One LOD view: local vertex/bone lookups into the model, submeshes and " "render batches; embedded in the model pre-WotLK, an own .skin file " "after.") - ]] M2SkinProfile + ]] M2SkinProfile : M2SkinProfileBase { [[=welder::doc("Local -> global vertex lookup.")]] std::vector vertices; diff --git a/tests/csharp/FamilySurfaceTests.cs b/tests/csharp/FamilySurfaceTests.cs index b40d265..0781864 100644 --- a/tests/csharp/FamilySurfaceTests.cs +++ b/tests/csharp/FamilySurfaceTests.cs @@ -101,6 +101,33 @@ public void FileEntityRootRunsTheSharedContract() Assert.NotNull(copy); } + [Fact] + public void RecordCountsReadThroughTheBase() + { + // The user-reported flaw: reading root.Sequences.Count needed a + // six-arm per-era switch, because the M2 record families welded + // standalone. They have bases now, so the collection members hoist + // as FamilyVector — Count and element views included. + using var root = Formats.M2.Root.M2Root.ForVersion(Expansion.Wotlk); + Assert.Equal(0, root.Sequences.Count); + Assert.Equal(0, root.Bones.Count); + Assert.Equal(0, root.ParticleEmitters.Count); + Assert.Equal(0, root.Attachments.Count); + + // Populated through the concrete, counted and read through the base. + var concrete = + Assert.IsAssignableFrom(root); + concrete.Sequences.Add( + new Formats.M2.Root.Record.M2SequenceWotlkToMop()); + Assert.Equal(1, root.Sequences.Count); + Formats.M2.Root.Record.M2Sequence seq = root.Sequences[0]; + Assert.NotNull(seq); + // The record family base has its own ForVersion now, like every base. + using var anySeq = Formats.M2.Root.Record.M2Sequence.ForVersion( + Expansion.Wotlk); + Assert.IsAssignableFrom(anySeq); + } + [Fact] public unsafe void BlittableMirrorsCoverTheHotRecords() { diff --git a/tools/gen_cs_format_facades.py b/tools/gen_cs_format_facades.py index a7e7949..e4110bf 100644 --- a/tools/gen_cs_format_facades.py +++ b/tools/gen_cs_format_facades.py @@ -99,6 +99,16 @@ "WMO", "M2Root", "M2ChunkedFile", "Skin", "Skeleton", "M2", "ADT", "MapChunk", "WDTRoot", "WDTHeader", "WDTOcclusion", "WDTLights", "WDTFogs", "WDTParticulates", "WDT", "WDL", + # The M2 record families gained welded bases (feature/csharp-m2-record- + # bases) — user report: reading root.Sequences.Count through the base + # needed a per-era switch. Every family is BASED now. + "M2TrackC3Vector", "M2TrackC4Quaternion", "M2TrackCompQuat", + "M2TrackFloat", "M2TrackFixed16", "M2TrackUInt8", "M2TrackUInt16", + "M2TrackSplineC3Vector", "M2TrackSplineFloat", "M2EventTrack", + "M2Color", "M2TextureWeight", "M2TextureFlipbook", "M2TextureTransform", + "M2Attachment", "M2Event", "M2Light", "M2Ribbon", + "M2Sequence", "M2CompBone", "M2Camera", "M2Particle", + "M2SkinSection", "M2SkinProfile", } From 78f2b255ead3768c67e4d01a9d68e020ab8b460c Mon Sep 17 00:00:00 2001 From: Sergey Shumakov Date: Sun, 23 Aug 2026 11:04:24 +0300 Subject: [PATCH 2/4] csharp: welder-csharp pin -> 93fd7e1, the diagnostics+upcast merge on main Tree byte-identical to the 3cdf95b branch head this previously pinned. Co-Authored-By: Claude Fable 5 --- cmake/Dependencies.cmake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index 2d63385..ee4dd98 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 3cdf95bc5b8c6d877d610a8c0a449732e298a157) + GIT_TAG 93fd7e19eb511c1d5b2d17e0e1b106d9a2df5379) # --- 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 From 88aab0b3b45eca33a16c5273e1dd10a26e904a4c Mon Sep 17 00:00:00 2001 From: Sergey Shumakov Date: Sun, 23 Aug 2026 11:39:17 +0300 Subject: [PATCH 3/4] csharp/m2: the TIMELINE surface, IAnimTimeline, and the missing Python track-base welds Three pieces riding the record-bases branch: 1. THE CANONICAL TIMELINE SURFACE (C++, welded -> all three languages): timeline_count / key_count / timeline_timestamps / timeline_values on both M2Track layouts, and the values-less trio on both event-track layouts. Pre-WotLK slices the shared global timeline by the inclusive interpolation ranges (clamped; a global-sequence-driven or rangeless track exposes the whole array as timeline 0); WotLK+ indexes the per-sequence arrays (an external sequence's timeline is empty until its .anim loads); out-of-range timelines error (InvalidEntityState). Identical signatures per family -> the family surface hoists them onto M2TrackC3Vector etc., so the TBC->WotLK track redesign no longer forces a branch: C# reads through the base, typed Python writes one Protocol[V] the stubs satisfy structurally. Accessors COPY (correctness first); hot loops keep the two-arm concrete/span path. A native semantics test covers slicing, clamping and errors on both layouts; guide/m2.md walks the same loop trilingually. 2. IAnimTimeline (facade-declared, user-requested): the timing trio is signature-identical across every track family and the event tracks, so the facade partials declare ONE interface the synthesized dispatchers implement implicitly - timing-driven systems hold IAnimTimeline over all ten families, era-agnostic. Value access stays per-family typed. 3. CI FIX: the record-bases commit broke the Python module - the module walk registers the fifteen PLAIN record bases, but the nine M2TrackFamilyBase INSTANTIATIONS are invisible to a walk, so the track concretes bound an unregistered base and nanobind aborted at import (the macOS bindings job's stubgen crash). welds_m2.cpp now welds the nine Fam aliases before the concretes; wowlib_py + wowlib_pyi build clean locally and the stubs carry the bases + timeline defs. dotnet test: 37/37 (timeline dispatch through the base across both eras, the IAnimTimeline loop over track+event families, out-of-range errors). Co-Authored-By: Claude Fable 5 --- .claude/context/csharp-bindings.md | 20 ++ bindings/python/formats/welds_m2.cpp | 15 ++ docs/content/guide/m2.md | 65 ++++++ src/wowlib/formats/m2/root/record/track.hpp | 216 ++++++++++++++++++++ tests/csharp/FamilySurfaceTests.cs | 53 +++++ tools/gen_cs_format_facades.py | 37 +++- 6 files changed, 405 insertions(+), 1 deletion(-) diff --git a/.claude/context/csharp-bindings.md b/.claude/context/csharp-bindings.md index 8313eeb..465707c 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-23 (later): track TIMELINE surface + the M2Track generics decline + +- **Canonical track accessors** (C++, welded → all languages): + timeline_count / key_count / timeline_timestamps / timeline_values on + BOTH M2Track layouts + both event-track layouts (values-less). Pre-WotLK + slices the global timeline by interpolation_ranges (M2Range + minimum/maximum INCLUSIVE, clamped; global-sequence or rangeless track = + single timeline 0); WotLK+ indexes the per-sequence arrays (external + .anim sequences are empty until loaded). Result<> error on out-of-range + (InvalidEntityState). Identical signatures per family → the family + surface hoists them onto M2TrackC3Vector etc. — version-agnostic in C# + through the base, duck-typed in Python/Lua. Accessors COPY; hot loops + still branch once on the two range classes for spans. +- **M2Track C# generics: declined.** The wrapper machinery + (per-class handles/thunks/erased offsets) is per-native-class; a generic + entity wrapper would need entity-level ops tables — the container trick + at entity scale — for a purely cosmetic rename of M2TrackC3Vector, whose + BASE is what users hold anyway now that the timeline surface hoists. + C# also cannot alias generics (using aliases take no type params). + ## 2026-08-23: M2 record family bases (user feedback round 3) - **User report 1** ("AsSpan() requires a scalar or enum element type" on a diff --git a/bindings/python/formats/welds_m2.cpp b/bindings/python/formats/welds_m2.cpp index 0744323..ebbe94b 100644 --- a/bindings/python/formats/welds_m2.cpp +++ b/bindings/python/formats/welds_m2.cpp @@ -62,6 +62,21 @@ namespace wowlib_py::formats // The version-INDEPENDENT payload aliases (declared standalone in the // ranges header, outside the X-macro tables). namespace rec = ::wowlib::formats::m2::root::record; + + // The M2Track VALUE families' version-agnostic BASES: class-template + // instantiations, invisible to the module walk that registers every + // plain *Base — weld them here, BEFORE the per-era concretes that + // derive them (nanobind requires a base registered first; missing one + // is a critical error at import, which is how their absence surfaced). + W::weld_type(m0, "M2TrackC3Vector"); + W::weld_type(m0, "M2TrackC4Quaternion"); + W::weld_type(m0, "M2TrackCompQuat"); + W::weld_type(m0, "M2TrackFloat"); + W::weld_type(m0, "M2TrackFixed16"); + W::weld_type(m0, "M2TrackUInt8"); + W::weld_type(m0, "M2TrackUInt16"); + W::weld_type(m0, "M2TrackSplineC3Vector"); + W::weld_type(m0, "M2TrackSplineFloat"); W::weld_type(m0, "M2SplineKeyC3Vector"); W::weld_type(m0, "M2SplineKeyFloat"); W::weld_type(m0, "FBlockC3Vector"); diff --git a/docs/content/guide/m2.md b/docs/content/guide/m2.md index 7f2917d..701550b 100644 --- a/docs/content/guide/m2.md +++ b/docs/content/guide/m2.md @@ -108,3 +108,68 @@ legion_model.write(fs, wowlib.FileKey("Creature/Murloc/Murloc.m2")) rather than WMO/WDT's byte-identical one. `validate()` before writing catches inconsistent cross-references; see **[Validating before writing](validation.md)**. + +## Animation tracks are two layouts, one surface + +Track storage changed at WotLK: pre-WotLK tracks share **one global +timeline**, sliced per sequence by interpolation ranges; WotLK+ tracks nest +**one timestamp/value array per sequence** (external sequences keep theirs in +the `.anim` file). The raw members reflect whichever layout the entity is — +but every track also carries the canonical **timeline surface**, so +version-agnostic code never branches: + +- `timeline_count()` — one timeline per sequence (or a single timeline for a + global-sequence-driven track); +- `key_count(timeline)`, `timeline_timestamps(timeline)`, + `timeline_values(timeline)` — the slice pre-WotLK, the nested array WotLK+, + returned as copies; out-of-range timelines error. + +=== "C++" + + ```cpp + template + void dump_keys(const wowlib::formats::m2::root::record::M2Track< + wowlib::formats::C3Vector, V>& track) + { + for (std::size_t s = 0; s < track.timeline_count(); ++s) + if (auto values = track.timeline_values(s)) + use(*values); + } + ``` + +=== "Python" + + ```python + def dump_keys(track): # any era's track, duck-typed + for s in range(track.timeline_count()): + use(track.timeline_timestamps(s), track.timeline_values(s)) + ``` + +=== "C#" + + ```csharp + // Hoisted onto the family base: no pattern matching, either era. + static void DumpKeys(Formats.M2.Root.Record.M2TrackC3Vector track) + { + for (ulong s = 0; s < track.TimelineCount(); s++) + Use(track.TimelineTimestamps(s), track.TimelineValues(s)); + } + ``` + +In C#, the timing half of that surface is also a real interface — +**`IAnimTimeline`** (`TimelineCount`/`KeyCount`/`TimelineTimestamps`), +implemented by every track family base — so sequencers and event schedulers +hold one interface across all nine value families and the event tracks: + +```csharp +static ulong TotalKeys(IEnumerable tracks) + => tracks.Aggregate(0UL, (n, t) => { + for (ulong s = 0; s < t.TimelineCount(); s++) n += t.KeyCount(s); + return n; + }); +``` + +The raw members stay available on the concrete classes for zero-copy work — +the accessors return copies, which is the right trade for correctness-first +code; renderers hot-looping keys can still branch once on the two range +classes and use spans. diff --git a/src/wowlib/formats/m2/root/record/track.hpp b/src/wowlib/formats/m2/root/record/track.hpp index 1fb5bfc..4867b9f 100644 --- a/src/wowlib/formats/m2/root/record/track.hpp +++ b/src/wowlib/formats/m2/root/record/track.hpp @@ -14,13 +14,17 @@ sequence's data lives in its .anim file, routed through the offset I/O contexts. */ +#include #include +#include +#include #include #include #include #include +#include #include #include #include @@ -201,6 +205,81 @@ namespace wowlib::formats::m2::root::record =welder::doc("The keyframe values, one per timestamp.")]] std::vector values; + // --- the version-agnostic TIMELINE surface ----------------------------- + // Keys live on TIMELINES: here (pre-WotLK) one shared global array, + // sliced per sequence by interpolation_ranges; a global-sequence or + // rangeless track exposes the whole array as its single timeline 0. + // WotLK+ stores one array per sequence outright - same accessors, + // different backing, so version-agnostic code never branches. + + [[=welder::doc("The number of timelines this track carries: one per " + "interpolation range, or a single timeline 0 for a " + "global-sequence-driven or rangeless track (0 when the " + "track is empty).")]] + std::size_t timeline_count() const + { + if (global_sequence != 0xFFFF || interpolation_ranges.empty()) + return timestamps.empty() ? std::size_t{0} : std::size_t{1}; + return interpolation_ranges.size(); + } + + [[nodiscard]] + [[=welder::doc("The number of keys on one timeline."), + =welder::returns("the key count; errors when timeline is out of range")]] + Result key_count(std::size_t timeline + [[=welder::doc("the timeline index")]]) const + { + return timeline_slice(timeline).transform([](auto s) { return s.second; }); + } + + [[nodiscard]] + [[=welder::doc("One timeline's keyframe timestamps, as a copy."), + =welder::returns("the timestamps; errors when timeline is out of range")]] + Result> timeline_timestamps( + std::size_t timeline [[=welder::doc("the timeline index")]]) const + { + return timeline_slice(timeline).transform([this](auto s) { + return std::vector(timestamps.begin() + s.first, + timestamps.begin() + s.first + s.second); + }); + } + + [[nodiscard]] + [[=welder::doc("One timeline's keyframe values, as a copy."), + =welder::returns("the values; errors when timeline is out of range")]] + Result> timeline_values( + std::size_t timeline [[=welder::doc("the timeline index")]]) const + { + return timeline_slice(timeline).transform([this](auto s) { + const std::size_t max_first = std::min(s.first, values.size()); + const std::size_t count = std::min(s.second, values.size() - max_first); + return std::vector(values.begin() + max_first, + values.begin() + max_first + count); + }); + } + + private: + /** The [first, count) window of @a timeline into the global arrays: + the whole array for a global-sequence/rangeless track, else the + inclusive interpolation range clamped to the array. + @param timeline the timeline index. + @return the window, or InvalidEntityState past timeline_count(). */ + Result> timeline_slice(std::size_t timeline) const + { + if (timeline >= timeline_count()) + return make_error(ErrorCode::InvalidEntityState, + std::format("timeline {} out of range ({} timelines)", + timeline, timeline_count())); + if (global_sequence != 0xFFFF || interpolation_ranges.empty()) + return std::pair{0, timestamps.size()}; + const M2Range& r = interpolation_ranges[timeline]; + const std::size_t first = std::min(r.minimum, timestamps.size()); + const std::size_t end = + std::min(std::size_t{r.maximum} + 1, timestamps.size()); + return std::pair{first, end < first ? 0 : end - first}; + } + + public: bool operator==(const M2Track&) const = default; }; @@ -231,6 +310,54 @@ namespace wowlib::formats::m2::root::record =welder::doc("Keyframe values, per sequence, parallel to timestamps.")]] std::vector> values; + // --- the version-agnostic TIMELINE surface ----------------------------- + // Same accessors as the pre-WotLK layout, over the native per-sequence + // arrays - version-agnostic code never branches on the era. + + [[=welder::doc("The number of timelines this track carries: one per " + "sequence (a global-sequence-driven track stores a " + "single timeline; an external sequence's timeline is " + "empty until its .anim data is loaded).")]] + std::size_t timeline_count() const { return timestamps.size(); } + + [[nodiscard]] + [[=welder::doc("The number of keys on one timeline."), + =welder::returns("the key count; errors when timeline is out of range")]] + Result key_count(std::size_t timeline + [[=welder::doc("the timeline index")]]) const + { + if (timeline >= timestamps.size()) + return make_error(ErrorCode::InvalidEntityState, + std::format("timeline {} out of range ({} timelines)", + timeline, timestamps.size())); + return timestamps[timeline].size(); + } + + [[nodiscard]] + [[=welder::doc("One timeline's keyframe timestamps, as a copy."), + =welder::returns("the timestamps; errors when timeline is out of range")]] + Result> timeline_timestamps( + std::size_t timeline [[=welder::doc("the timeline index")]]) const + { + if (timeline >= timestamps.size()) + return make_error(ErrorCode::InvalidEntityState, + std::format("timeline {} out of range ({} timelines)", + timeline, timestamps.size())); + return timestamps[timeline]; + } + + [[nodiscard]] + [[=welder::doc("One timeline's keyframe values, as a copy."), + =welder::returns("the values; errors when timeline is out of range")]] + Result> timeline_values( + std::size_t timeline [[=welder::doc("the timeline index")]]) const + { + if (timeline >= values.size()) + return make_error(ErrorCode::InvalidEntityState, + std::format("timeline {} out of range ({} timelines)", + timeline, values.size())); + return values[timeline]; + } bool operator==(const M2Track&) const = default; }; @@ -260,6 +387,62 @@ namespace wowlib::formats::m2::root::record [[=welder::doc("The global timeline's trigger timestamps.")]] std::vector timestamps; + // The version-agnostic TIMELINE surface (see M2Track): the event + // track's timestamps only. + + [[=welder::doc("The number of timelines this track carries: one per " + "interpolation range, or a single timeline 0 for a " + "global-sequence-driven or rangeless track (0 when the " + "track is empty).")]] + std::size_t timeline_count() const + { + if (global_sequence != 0xFFFF || interpolation_ranges.empty()) + return timestamps.empty() ? std::size_t{0} : std::size_t{1}; + return interpolation_ranges.size(); + } + + [[nodiscard]] + [[=welder::doc("The number of trigger keys on one timeline."), + =welder::returns("the key count; errors when timeline is out of range")]] + Result key_count(std::size_t timeline + [[=welder::doc("the timeline index")]]) const + { + return timeline_slice(timeline).transform([](auto s) { return s.second; }); + } + + [[nodiscard]] + [[=welder::doc("One timeline's trigger timestamps, as a copy."), + =welder::returns("the timestamps; errors when timeline is out of range")]] + Result> timeline_timestamps( + std::size_t timeline [[=welder::doc("the timeline index")]]) const + { + return timeline_slice(timeline).transform([this](auto s) { + return std::vector(timestamps.begin() + s.first, + timestamps.begin() + s.first + s.second); + }); + } + + private: + /** The [first, count) window of @a timeline into the global timeline + (see M2Track's twin). + @param timeline the timeline index. + @return the window, or InvalidEntityState past timeline_count(). */ + Result> timeline_slice(std::size_t timeline) const + { + if (timeline >= timeline_count()) + return make_error(ErrorCode::InvalidEntityState, + std::format("timeline {} out of range ({} timelines)", + timeline, timeline_count())); + if (global_sequence != 0xFFFF || interpolation_ranges.empty()) + return std::pair{0, timestamps.size()}; + const M2Range& r = interpolation_ranges[timeline]; + const std::size_t first = std::min(r.minimum, timestamps.size()); + const std::size_t end = + std::min(std::size_t{r.maximum} + 1, timestamps.size()); + return std::pair{first, end < first ? 0 : end - first}; + } + + public: bool operator==(const M2TrackBase&) const = default; }; @@ -283,6 +466,39 @@ namespace wowlib::formats::m2::root::record "sequence keeps its arrays in the .anim file).")]] std::vector> timestamps; + // The version-agnostic TIMELINE surface (see M2Track): the event + // track's timestamps only, over the native per-sequence arrays. + + [[=welder::doc("The number of timelines this track carries: one per " + "sequence (an external sequence's timeline is empty " + "until its .anim data is loaded).")]] + std::size_t timeline_count() const { return timestamps.size(); } + + [[nodiscard]] + [[=welder::doc("The number of trigger keys on one timeline."), + =welder::returns("the key count; errors when timeline is out of range")]] + Result key_count(std::size_t timeline + [[=welder::doc("the timeline index")]]) const + { + if (timeline >= timestamps.size()) + return make_error(ErrorCode::InvalidEntityState, + std::format("timeline {} out of range ({} timelines)", + timeline, timestamps.size())); + return timestamps[timeline].size(); + } + + [[nodiscard]] + [[=welder::doc("One timeline's trigger timestamps, as a copy."), + =welder::returns("the timestamps; errors when timeline is out of range")]] + Result> timeline_timestamps( + std::size_t timeline [[=welder::doc("the timeline index")]]) const + { + if (timeline >= timestamps.size()) + return make_error(ErrorCode::InvalidEntityState, + std::format("timeline {} out of range ({} timelines)", + timeline, timestamps.size())); + return timestamps[timeline]; + } bool operator==(const M2TrackBase&) const = default; }; } diff --git a/tests/csharp/FamilySurfaceTests.cs b/tests/csharp/FamilySurfaceTests.cs index 0781864..52208e5 100644 --- a/tests/csharp/FamilySurfaceTests.cs +++ b/tests/csharp/FamilySurfaceTests.cs @@ -128,6 +128,59 @@ public void RecordCountsReadThroughTheBase() Assert.IsAssignableFrom(anySeq); } + [Fact] + public void TrackTimelinesAreVersionAgnostic() + { + // The canonical key surface: the two track layouts (global timeline + // + ranges pre-WotLK vs per-sequence arrays WotLK+) answer the SAME + // hoisted accessors, so version-agnostic code never branches. + using var pre = Formats.M2.Root.Record.M2TrackC3Vector.ForVersion( + Expansion.Tbc); + var preC = Assert.IsAssignableFrom< + Formats.M2.Root.Record.M2TrackC3VectorVanillaToTbc>(pre); + preC.Timestamps.Add(0); preC.Timestamps.Add(10); + preC.Timestamps.Add(20); preC.Timestamps.Add(30); + for (int i = 0; i < 4; i++) + using (var v = new Formats.Common.C3Vector()) + preC.Values.Add(v); + using (var r0 = new Formats.M2.Root.Record.M2Range()) + using (var r1 = new Formats.M2.Root.Record.M2Range()) + { + r0.Minimum = 0; r0.Maximum = 1; + r1.Minimum = 2; r1.Maximum = 3; + preC.InterpolationRanges.Add(r0); + preC.InterpolationRanges.Add(r1); + } + + using var post = Formats.M2.Root.Record.M2TrackC3Vector.ForVersion( + Expansion.Wotlk); + var postC = Assert.IsAssignableFrom< + Formats.M2.Root.Record.M2TrackC3VectorWotlkPlus>(post); + postC.Timestamps.Add(new uint[] { 20, 30 }); + + // One loop over BASE-typed tracks spanning both eras: + foreach (var track in new[] { pre, post }) + { + Assert.True(track.TimelineCount() >= 1); + var last = track.TimelineCount() - 1; + Assert.Equal(2UL, track.KeyCount(last)); + var times = track.TimelineTimestamps(last); + Assert.Equal(new uint[] { 20, 30 }, times); + } + // Out-of-range errors uniformly (the Result error channel). + Assert.ThrowsAny(() => pre.KeyCount(99)); + + // IAnimTimeline: ONE interface over every track family and era — + // the timing trio dispatches through the family surface. + using var evt = Formats.M2.Root.Record.M2EventTrack.ForVersion( + Expansion.Wotlk); + var timelines = new Formats.M2.Root.Record.IAnimTimeline[] { pre, post, evt }; + foreach (var tl in timelines) + for (ulong s = 0; s < tl.TimelineCount(); s++) + Assert.Equal((int)tl.KeyCount(s), + tl.TimelineTimestamps(s).Length); + } + [Fact] public unsafe void BlittableMirrorsCoverTheHotRecords() { diff --git a/tools/gen_cs_format_facades.py b/tools/gen_cs_format_facades.py index e4110bf..3a31738 100644 --- a/tools/gen_cs_format_facades.py +++ b/tools/gen_cs_format_facades.py @@ -112,6 +112,36 @@ } +# The track families implementing IAnimTimeline: their hoisted timing trio +# (TimelineCount/KeyCount/TimelineTimestamps) is signature-identical, so the +# facade partial declares the interface and the synthesized dispatchers +# implement it implicitly — timing-driven systems (sequencers, event +# schedulers) get real C# interface polymorphism across every track family +# and era. Value access stays per-family typed (TimelineValues' type differs). +ANIM_TIMELINE = { + "M2TrackC3Vector", "M2TrackC4Quaternion", "M2TrackCompQuat", + "M2TrackFloat", "M2TrackFixed16", "M2TrackUInt8", "M2TrackUInt16", + "M2TrackSplineC3Vector", "M2TrackSplineFloat", "M2EventTrack", +} + +ANIM_TIMELINE_DECL = """ /// The timing half every animation track shares, era- and + /// value-type-agnostic: implemented by every track family base, so + /// timing-driven systems (sequencers, event schedulers) hold ONE + /// interface over all of them. Value access stays on the family bases + /// (TimelineValues' element type is the family's own). + public interface IAnimTimeline + { + /// The number of timelines (one per sequence; a single + /// timeline for a global-sequence-driven track). + ulong TimelineCount(); + /// The number of keys on one timeline. + ulong KeyCount(ulong timeline); + /// One timeline's keyframe timestamps, as a copy. + uint[] TimelineTimestamps(ulong timeline); + } +""" + + def ranges_of(text: str, macro: str) -> list[tuple[str, str]]: """The (suffix, first_era) rows of one X-macro table.""" block = re.search(rf"#define {macro}\(X\)(.*?)\n\n", text, re.S) @@ -147,7 +177,12 @@ def main() -> int: f" and the nested Era factory mapping each expansion" f" to its covering range class at compile" f" time.") - lines.append(f" public partial class {family}") + if family in ANIM_TIMELINE and "IAnimTimeline" not in "".join(lines): + lines.append(ANIM_TIMELINE_DECL) + lines.append( + f" public partial class {family} : IAnimTimeline" + if family in ANIM_TIMELINE + else f" public partial class {family}") lines.append(" {") if family in BASED: lines.append(f" /// A fresh {family} for the" From 2d601ec488740139b212d87579b3c1d1207bcc7f Mon Sep 17 00:00:00 2001 From: Sergey Shumakov Date: Sun, 23 Aug 2026 19:04:32 +0300 Subject: [PATCH 4/4] m2/track: subspan the timeline windows; iterator + size_t trips CI's -Werror=sign-conversion The timeline copy accessors added begin() + size_t (operator+ takes the SIGNED difference_type); local builds carry no warning flags, so it only surfaced in the CI bindings jobs. std::span::subspan takes size_t natively - same windows, no arithmetic on iterators. All four track layouts instantiate clean under -Werror=sign-conversion locally; the semantic test still passes. Co-Authored-By: Claude Fable 5 --- src/wowlib/formats/m2/root/record/track.hpp | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/wowlib/formats/m2/root/record/track.hpp b/src/wowlib/formats/m2/root/record/track.hpp index 4867b9f..6651123 100644 --- a/src/wowlib/formats/m2/root/record/track.hpp +++ b/src/wowlib/formats/m2/root/record/track.hpp @@ -15,6 +15,7 @@ contexts. */ #include +#include #include #include #include @@ -239,8 +240,8 @@ namespace wowlib::formats::m2::root::record std::size_t timeline [[=welder::doc("the timeline index")]]) const { return timeline_slice(timeline).transform([this](auto s) { - return std::vector(timestamps.begin() + s.first, - timestamps.begin() + s.first + s.second); + const auto window = std::span(timestamps).subspan(s.first, s.second); + return std::vector(window.begin(), window.end()); }); } @@ -253,8 +254,8 @@ namespace wowlib::formats::m2::root::record return timeline_slice(timeline).transform([this](auto s) { const std::size_t max_first = std::min(s.first, values.size()); const std::size_t count = std::min(s.second, values.size() - max_first); - return std::vector(values.begin() + max_first, - values.begin() + max_first + count); + const auto window = std::span(values).subspan(max_first, count); + return std::vector(window.begin(), window.end()); }); } @@ -417,8 +418,8 @@ namespace wowlib::formats::m2::root::record std::size_t timeline [[=welder::doc("the timeline index")]]) const { return timeline_slice(timeline).transform([this](auto s) { - return std::vector(timestamps.begin() + s.first, - timestamps.begin() + s.first + s.second); + const auto window = std::span(timestamps).subspan(s.first, s.second); + return std::vector(window.begin(), window.end()); }); }