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

## 2026-08-23 (later): track TIMELINE surface + the M2Track<T> 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<C3Vector> 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
"specific ADT"): NOT data-dependent — the consumer called scalar AsSpan()
on `chunk.Layers` (Vector<SMLayer>, records → AsDataSpan()) and
`chunk.AlphaMaps` (Vector<Vector<byte>>, 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<T> for the 9 track value families, alias-welded in
gen_m2 as M2Track*Fam aliases in m2_ranges.hpp; M2EventTrackBase for the
M2TrackBase<V> 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<M2Sequence>/... 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<ClientVersion> generics): assessed INFEASIBLE, by design of C#**
Expand Down
13 changes: 13 additions & 0 deletions bindings/csharp/gen_m2.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ namespace wowlib_cs
using W = ::welder::welder<wcs::rod, wcs::dotnet>;
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).
Expand Down
14 changes: 14 additions & 0 deletions bindings/instantiations/m2_ranges.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,20 @@
// comes last.
namespace wowlib::formats::m2::root::record
{
// The version-agnostic BASES of the M2Track VALUE families: one
// M2TrackFamilyBase<T> 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<C3Vector>;
using M2TrackC4QuaternionFam = M2TrackFamilyBase<C4Quaternion>;
using M2TrackCompQuatFam = M2TrackFamilyBase<M2CompQuat>;
using M2TrackFloatFam = M2TrackFamilyBase<float>;
using M2TrackFixed16Fam = M2TrackFamilyBase<fixed16>;
using M2TrackUInt8Fam = M2TrackFamilyBase<std::uint8_t>;
using M2TrackUInt16Fam = M2TrackFamilyBase<std::uint16_t>;
using M2TrackSplineC3VectorFam = M2TrackFamilyBase<M2SplineKey<C3Vector>>;
using M2TrackSplineFloatFam = M2TrackFamilyBase<M2SplineKey<float>>;

// element/ramp types with no version axis, named once
using M2SplineKeyC3Vector = M2SplineKey<C3Vector>;
using M2SplineKeyFloat = M2SplineKey<float>;
Expand Down
15 changes: 15 additions & 0 deletions bindings/python/formats/welds_m2.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<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");
W::weld_type<rec::M2SplineKeyC3Vector>(m0, "M2SplineKeyC3Vector");
W::weld_type<rec::M2SplineKeyFloat>(m0, "M2SplineKeyFloat");
W::weld_type<rec::FBlockC3Vector>(m0, "FBlockC3Vector");
Expand Down
65 changes: 65 additions & 0 deletions docs/content/guide/m2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <wowlib::ClientVersion V>
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<Formats.M2.Root.Record.IAnimTimeline> 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.
22 changes: 20 additions & 2 deletions src/wowlib/formats/m2/root/record/bone.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,30 @@

#include <welder/vocabulary.hpp>

#include <wowlib/core/lang.hpp>
#include <wowlib/core/client_version.hpp>
#include <wowlib/formats/m2/boundaries.hpp>
#include <wowlib/formats/m2/root/record/track.hpp>

namespace wowlib::formats::m2::root::record
{
/** The version-agnostic base of every M2CompBone<V> (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 "
Expand Down Expand Up @@ -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<V>
]] M2CompBone<V> : M2CompBoneBase
{
[[=welder::doc("Key-bone-lookup back reference, -1 if none.")]]
std::int32_t key_bone_id = -1;
Expand Down Expand Up @@ -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<V>
]] M2CompBone<V> : M2CompBoneBase
{
[[=welder::doc("Key-bone-lookup back reference, -1 if none.")]]
std::int32_t key_bone_id = -1;
Expand Down
45 changes: 40 additions & 5 deletions src/wowlib/formats/m2/root/record/effects.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,48 @@

#include <welder/vocabulary.hpp>

#include <wowlib/core/lang.hpp>
#include <wowlib/core/client_version.hpp>
#include <wowlib/formats/common/annotations.hpp>
#include <wowlib/formats/m2/boundaries.hpp>
#include <wowlib/formats/m2/root/record/track.hpp>

namespace wowlib::formats::m2::root::record
{
/** The version-agnostic base of every M2Ribbon<V> (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<V> (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
Expand All @@ -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;
Expand Down Expand Up @@ -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<V>
]] M2Particle<V> : M2ParticleBase
{
[[=welder::doc("Always -1 in known files.")]]
std::uint32_t particle_id = 0xFFFFFFFF;
Expand Down Expand Up @@ -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<V>
]] M2Particle<V> : M2ParticleBase
{
[[=welder::doc("Always -1 in known files.")]]
std::uint32_t particle_id = 0xFFFFFFFF;
Expand Down Expand Up @@ -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<V>
]] M2Particle<V> : M2ParticleBase
{
[[=welder::doc("Always -1 in known files.")]]
std::uint32_t particle_id = 0xFFFFFFFF;
Expand Down Expand Up @@ -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<V>
]] M2Particle<V> : M2ParticleBase
{
[[=welder::doc("Always -1 in known files.")]]
std::uint32_t particle_id = 0xFFFFFFFF;
Expand Down
Loading
Loading