whiteoutlib 0.2.0

Read and write Blizzard game assets from Rust: models (MDX, M2, M3), textures (BLP, DDS, PNG, JPEG, BMP, TGA, TIFF, GIF) and archives (CASC, MPQ).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 Fernando Sahmkow

#include <whiteout/sno/sno_reader.h>
#include "d3/sno_defs.h"
#include "d4/binary_types.h"
#include "d4/sno_defs.h"

#include "../common/binary_reader.h"
#include "../common/streams.h"

#include <algorithm>
#include <cstring>
#include <istream>

namespace whiteout {
namespace sno {

// ============================================================================
// Helpers
// ============================================================================

using common::BinaryReader;
using common::span_streambuf;

/// Read a trivially-copyable value at an absolute offset.
template <common::BinaryBlob T>
static T readAt(BinaryReader& r, size_t offset) {
    r.setPosition(static_cast<u32>(offset));
    return r.read<T>();
}

// Well-known basic type hashes (from definitions.json)
namespace TypeHash {
constexpr u32 DT_NULL = 1028442418;
constexpr u32 DT_BYTE = 1028015787;
constexpr u32 DT_WORD = 1028759507;
constexpr u32 DT_ENUM = 1028111660;
constexpr u32 DT_INT = 2764320258;
constexpr u32 DT_UINT = 1028680983;
constexpr u32 DT_FLOAT = 3864020909;
constexpr u32 DT_INT64 = 3867655596;
constexpr u32 DT_ACD_NETWORK_NAME = 2866333320;
constexpr u32 DT_SHARED_SERVER_DATA_ID = 3045283369;
constexpr u32 DT_SNO = 2764331143;
constexpr u32 DT_SNO_NAME = 3339108615;
constexpr u32 DT_GBID = 1028170061;
constexpr u32 DT_STARTLOC_NAME = 2193642883;
constexpr u32 DT_CSTRING = 3846829457;
constexpr u32 DT_CHARARRAY = 2175310548;
constexpr u32 DT_STRING_FORMULA = 2450313795;
constexpr u32 DT_RGBACOLOR = 2384880434;
constexpr u32 DT_RGBACOLORVALUE = 3212271855;
constexpr u32 DT_BCVEC2I = 1931092405;
constexpr u32 DT_VECTOR2D = 3124492544;
constexpr u32 DT_VECTOR3D = 3124492577;
constexpr u32 DT_VECTOR4D = 3124492610;
constexpr u32 DT_OPTIONAL = 3121633597;
constexpr u32 DT_RANGE = 3877855748;
constexpr u32 DT_FIXEDARRAY = 2388214534;
constexpr u32 DT_TAGMAP = 3493213809;
constexpr u32 DT_VARIABLEARRAY = 3244749660;
constexpr u32 DT_POLYMORPHIC_VARIABLEARRAY = 1683664497;
constexpr u32 DT_BINDABLEPROPERTY = 322094989;
} // namespace TypeHash

// ============================================================================
// Forward declaration of the recursive reader
// ============================================================================

enum class SnoFormat { D3, D4 };

struct ReadCtx {
    BinaryReader& reader;            // Reader over the payload (from offset 16)
    BinaryReader* payloadDataReader; // Reader over external payload data (nullptr if none)
    const SnoTypeRegistry& reg;
    SnoFormat format = SnoFormat::D4;
    size_t payloadSize = 0;     // Total payload buffer size
    size_t payloadDataSize = 0; // Total external payload buffer size
    size_t readLength = 0;      // Tracks bytes consumed

    /// Create a child context with readLength reset to 0.
    ReadCtx sub() const {
        return {reader, payloadDataReader, reg, format, payloadSize, payloadDataSize, 0};
    }
    /// Create a child context that reads from the external payload buffer.
    ReadCtx forExternalPayload() const {
        return {*payloadDataReader, payloadDataReader, reg, format,
                payloadDataSize,    payloadDataSize,   0};
    }
};

static SnoValue readStructure(ReadCtx& ctx, const u32 typeHashes[3], size_t offset,
                              const SnoFieldDef* field);

// ============================================================================
// Alignment helpers (matching parse.js getBasicTypeAlignment / getTypeAlignment)
// ============================================================================

static size_t getTypeAlignment(const SnoTypeRegistry& reg, const u32 typeHashes[3],
                               bool inTagMap = false);

/// Build the sub-type triple {typeHashes[1], typeHashes[2], DT_NULL}.
static void subTypeHashes(const u32 typeHashes[3], u32 out[3]) {
    out[0] = typeHashes[1];
    out[1] = typeHashes[2];
    out[2] = TypeHash::DT_NULL;
}

/// Does this header word look like a D4 format hash rather than a D3 version?
///
/// D3 and D4 put different things in the same header slot: D3 stores a small
/// struct version that increments per layout change (`data/d3_group_versions.json`
/// records the full shipped range -- the largest is in the low hundreds), while
/// D4 stores a hash.  The smallest D4 format hash across both the compiled
/// registry and the tables recovered from the client is 419536, so 0xFFFF sits
/// two orders of magnitude clear of D3 and six times below the D4 floor.
/// Used to keep the D4-only fallbacks in parse() from firing on D3 input.
static bool isD4FormatHash(u32 formatHash) {
    return formatHash > 0xFFFFu;
}

/// Check whether a field carries external payload data.  D4 only -- see below.
///
/// D3: these bits do NOT mean "payload".  In the D3 engine (2.6.2 Switch,
/// sub_7100612560) the upper field flags are conditional-serialization
/// gate/polarity *pairs*: bit 20 gates a runtime condition whose expected value
/// is bit 21, bit 22 gates a second condition whose expected value is bit 23,
/// bit 24 gates a third.  A field is included only when the conditions hold.
/// That is why 0x700000 appears on 300 D3 fields across 81 types, including
/// plain GBID fields such as ItemType+0.  Applying the D4 test to D3 made 76
/// variable-array fields -- among them Appearance+136, a SubObject array --
/// report an external marker instead of their contents.
///
/// D3 also has no external payload file: its payload bytes live in the same
/// buffer immediately after the struct image, and the engine reads them with the
/// same Read(stream, offset, size, dst) it uses for the struct itself.  So the D3
/// path must fall through to the normal in-buffer read.
///
/// D4: only 0x200000 marks an external payload array.  0x400000 is a different
/// flag entirely -- it marks a *runtime-only pointer* that is never serialized.
/// See docs/D4 Specs/TEX_ENGINE_NOTES.md section 6.
static bool isExternalField(const SnoFieldDef* field, SnoFormat format) {
    if (format != SnoFormat::D4)
        return false;
    return field && (field->flags & 0x200000);
}

/// Read a trivially-copyable value, advance readLength, return SnoValue.
template <typename T>
static SnoValue readSimple(ReadCtx& ctx, size_t offset) {
    ctx.readLength += sizeof(T);
    return SnoValue(readAt<T>(ctx.reader, offset));
}

/// Read an integer value with optional bool promotion, advance readLength.
template <typename T>
static SnoValue readInt(ReadCtx& ctx, size_t offset, const SnoFieldDef* field) {
    ctx.readLength += sizeof(T);
    return readIntValue<T>(ctx.reader, offset, field);
}

static size_t getBasicTypeAlignment(const SnoTypeDef* typeDef, const u32 typeHashes[3],
                                    const SnoTypeRegistry& reg, bool inTagMap) {
    switch (typeDef->hash) {
    case TypeHash::DT_POLYMORPHIC_VARIABLEARRAY:
    case TypeHash::DT_STRING_FORMULA:
    case TypeHash::DT_VARIABLEARRAY:
    case TypeHash::DT_TAGMAP:
    case TypeHash::DT_CSTRING:
        return inTagMap ? 4 : 8;
    case TypeHash::DT_CHARARRAY:
        return 1;
    case TypeHash::DT_FIXEDARRAY:
    case TypeHash::DT_OPTIONAL:
    case TypeHash::DT_RANGE: {
        u32 sub[3];
        subTypeHashes(typeHashes, sub);
        return getTypeAlignment(reg, sub, inTagMap);
    }
    case TypeHash::DT_SNO_NAME:
        return 4;
    case TypeHash::DT_BINDABLEPROPERTY:
        return 8;
    default:
        return typeDef->size > 0 ? typeDef->size : 4;
    }
}

static size_t getTypeAlignment(const SnoTypeRegistry& reg, const u32 typeHashes[3], bool inTagMap) {
    auto* typeDef = reg.findType(typeHashes[0]);
    if (!typeDef)
        return 4;

    if (typeDef->isBasic) {
        return getBasicTypeAlignment(typeDef, typeHashes, reg, inTagMap);
    }

    // Complex type: max alignment of all fields
    auto fields = reg.fields(*typeDef);
    if (fields.empty())
        return 4;

    size_t maxAlign = 1;
    for (auto& f : fields) {
        maxAlign = std::max(maxAlign, getTypeAlignment(reg, f.typeHashes, inTagMap));
    }
    return maxAlign;
}

// ============================================================================
// Typed-array helpers — bulk-read homogeneous basic-type arrays
// ============================================================================

/// Returns the fixed byte size for a basic type eligible for SnoArray,
/// or 0 if the type is not eligible (complex type, has sentinel null values,
/// or the field might promote elements to bool).
static size_t typedArrayElemSize(u32 elemTypeHash, const SnoFieldDef* field) {
    const bool maybeBool = field && field->serializedBitCount == 1;
    switch (elemTypeHash) {
    case TypeHash::DT_BYTE:
        return maybeBool ? 0 : 1;
    case TypeHash::DT_WORD:
        return maybeBool ? 0 : 2;
    case TypeHash::DT_INT:
        return maybeBool ? 0 : 4;
    case TypeHash::DT_ENUM:
        return 4;
    case TypeHash::DT_UINT:
        return maybeBool ? 0 : 4;
    case TypeHash::DT_FLOAT:
        return 4;
    case TypeHash::DT_VECTOR2D:
        return 8;
    case TypeHash::DT_VECTOR3D:
        return 12;
    case TypeHash::DT_VECTOR4D:
        return 16;
    case TypeHash::DT_BCVEC2I:
        return 8;
    case TypeHash::DT_RGBACOLOR:
        return 4;
    case TypeHash::DT_RGBACOLORVALUE:
        return 16;
    default:
        return 0;
    }
}

/// Bulk-read `count` elements of a homogeneous basic type into a SnoArray.
static SnoArray readTypedArrayFromBuf(BinaryReader& reader, size_t off, size_t count,
                                      u32 elemTypeHash) {
    reader.setPosition(static_cast<u32>(off));
    switch (elemTypeHash) {
    case TypeHash::DT_BYTE:
        return SnoArray(reader.read<std::vector<u8>>(count));
    case TypeHash::DT_WORD:
        return SnoArray(reader.read<std::vector<u16>>(count));
    case TypeHash::DT_INT:
    case TypeHash::DT_ENUM:
        return SnoArray(reader.read<std::vector<i32>>(count));
    case TypeHash::DT_UINT:
        return SnoArray(reader.read<std::vector<u32>>(count));
    case TypeHash::DT_FLOAT:
        return SnoArray(reader.read<std::vector<f32>>(count));
    case TypeHash::DT_VECTOR2D:
        return SnoArray(reader.read<std::vector<SnoVec2>>(count));
    case TypeHash::DT_VECTOR3D:
        return SnoArray(reader.read<std::vector<SnoVec3>>(count));
    case TypeHash::DT_VECTOR4D:
        return SnoArray(reader.read<std::vector<SnoVec4>>(count));
    case TypeHash::DT_BCVEC2I:
        return SnoArray(reader.read<std::vector<SnoIVec2>>(count));
    case TypeHash::DT_RGBACOLOR:
        return SnoArray(reader.read<std::vector<SnoColor>>(count));
    case TypeHash::DT_RGBACOLORVALUE:
        return SnoArray(reader.read<std::vector<SnoColorF>>(count));
    default:
        return SnoArray();
    }
}

// ============================================================================
// Helpers for common reader patterns
// ============================================================================

static SnoValue emptyArray() {
    return SnoValue(SnoArray(std::vector<SnoValue>{}));
}

/// Translate a `SnoGroup` (whose values are Diablo IV's, per sno_types.h) into
/// the Diablo III group id used to key the D3 registry.
///
/// The two games agree on almost every id, which is why casting has worked so
/// far, but they do diverge.  PhysMesh is group 30 in D4 and group 61 in D3
/// (confirmed against the D3 2.6.2 group-handler registry, where the descriptor
/// registered as "PhysMesh" carries groupId 61; nothing is registered at 30).
/// Casting straight through therefore looked up a group D3 does not have, so
/// every .phm file failed to resolve a root type and parsed as nullopt.
///
/// Applied on the D3 path only -- D4 lookups keep using the raw enum value.
static u32 toD3GroupId(SnoGroup group) {
    switch (group) {
    case SnoGroup::PhysMesh:
        return 61; // D4 = 30, D3 = 61
    default:
        return static_cast<u32>(group);
    }
}

static SnoValue makeExternalMarker(i32 dataOffset, i32 dataSize, i32 dataCount = -1) {
    SnoObject ext;
    ext["__external__"] = SnoValue(true);
    ext["dataOffset"] = SnoValue(dataOffset);
    ext["dataSize"] = SnoValue(dataSize);
    if (dataCount >= 0)
        ext["dataCount"] = SnoValue(dataCount);
    return SnoValue(std::move(ext));
}

template <typename T>
static SnoValue readIntValue(BinaryReader& reader, size_t offset, const SnoFieldDef* field) {
    T v = readAt<T>(reader, offset);
    if (field && field->serializedBitCount == 1)
        return SnoValue(static_cast<bool>(v));
    return SnoValue(v);
}

static SnoArray readElementLoop(const ReadCtx& parentCtx, const u32 sub[3], size_t off,
                                i32 remaining, const SnoFieldDef* field) {
    std::vector<SnoValue> arr;
    while (remaining > 0) {
        ReadCtx subCtx = parentCtx.sub();
        arr.push_back(readStructure(subCtx, sub, off, field));
        if (subCtx.readLength < 1)
            break;
        off += subCtx.readLength;
        remaining -= static_cast<i32>(subCtx.readLength);
    }
    return SnoArray(std::move(arr));
}

static SnoArray readPolyElements(const ReadCtx& parentCtx, u32 baseTypeHash, size_t off,
                                 i32 remaining, i32 count, const SnoFieldDef* field) {
    constexpr u32 kPolyBaseHash = 0x5d4bac71;
    std::vector<SnoValue> arr(static_cast<size_t>(count));
    for (i32 i = 0; i < count && remaining > 0; ++i) {
        u32 polyTypeHash = baseTypeHash;
        if (parentCtx.reg.findType(kPolyBaseHash) && off + 4 <= parentCtx.payloadSize) {
            u32 const dwType = readAt<u32>(parentCtx.reader, off);
            if (dwType != 0)
                polyTypeHash = dwType;
        }
        u32 polySub[3] = {polyTypeHash, baseTypeHash, TypeHash::DT_NULL};
        ReadCtx subCtx = parentCtx.sub();
        arr[i] = readStructure(subCtx, polySub, off, field);
        if (subCtx.readLength < 1)
            break;
        off += subCtx.readLength;
        remaining -= static_cast<i32>(subCtx.readLength);
    }
    return SnoArray(std::move(arr));
}

/// Read variable array elements, trying a bulk typed read first.
static SnoValue readVarArray(const ReadCtx& arrCtx, const u32 sub[3], size_t off, i32 dataSize,
                             const SnoFieldDef* field) {
    size_t const elemSz = typedArrayElemSize(sub[0], field);
    if (elemSz > 0) {
        size_t const count = static_cast<size_t>(dataSize) / elemSz;
        return SnoValue(readTypedArrayFromBuf(arrCtx.reader, off, count, sub[0]));
    }
    return SnoValue(readElementLoop(arrCtx, sub, off, dataSize, field));
}

// ============================================================================
// Basic type readers
// ============================================================================

static SnoValue readBasicType(ReadCtx& ctx, u32 typeHash, const u32 typeHashes[3], size_t offset,
                              const SnoFieldDef* field) {
    auto& reader = ctx.reader;

    switch (typeHash) {

    // -- Scalar integers (with optional bool promotion) --
    case TypeHash::DT_BYTE:
        return readInt<u8>(ctx, offset, field);
    case TypeHash::DT_WORD:
        return readInt<u16>(ctx, offset, field);
    case TypeHash::DT_INT:
        return readInt<i32>(ctx, offset, field);
    case TypeHash::DT_UINT:
        return readInt<u32>(ctx, offset, field);
    case TypeHash::DT_INT64:
        return readInt<i64>(ctx, offset, field);

    // -- Simple fixed-size scalars --
    case TypeHash::DT_ENUM:
        return readSimple<i32>(ctx, offset);
    case TypeHash::DT_FLOAT:
        return readSimple<f32>(ctx, offset);
    case TypeHash::DT_STARTLOC_NAME:
        return readSimple<u32>(ctx, offset);
    case TypeHash::DT_ACD_NETWORK_NAME:
    case TypeHash::DT_SHARED_SERVER_DATA_ID:
        return readSimple<u64>(ctx, offset);

    // -- DT_SNO --
    case TypeHash::DT_SNO: {
        ctx.readLength += 4;
        i32 const raw = readAt<i32>(reader, offset);
        if (raw == -1 || raw == 0 || raw == static_cast<i32>(0xFFFFFFFF))
            return SnoValue();
        return SnoValue(SnoRef{field ? field->group : -1, raw});
    }

    // -- DT_SNO_NAME --
    case TypeHash::DT_SNO_NAME: {
        ctx.readLength += 8;
        reader.setPosition(static_cast<u32>(offset));
        i32 group = reader.read<i32>();
        i32 const raw = reader.read<i32>();
        if (raw == -1 || raw == 0 || raw == static_cast<i32>(0xFFFFFFFF))
            return SnoValue();
        if (group == 0 && field)
            group = field->group;
        return SnoValue(SnoRef{group, raw});
    }

    // -- DT_GBID --
    case TypeHash::DT_GBID: {
        ctx.readLength += 4;
        u32 const raw = readAt<u32>(reader, offset);
        if (raw == 0xFFFFFFFF)
            return SnoValue();
        return SnoValue(SnoGbid{field ? field->group : -1, raw});
    }

    // -- DT_CSTRING --
    case TypeHash::DT_CSTRING: {
        ctx.readLength += 16;
        reader.setPosition(static_cast<u32>(offset + 8));
        i32 const stringOffset = reader.read<i32>();
        i32 const stringSize = reader.read<i32>();
        if (stringSize < 1 || stringOffset < 1 ||
            static_cast<size_t>(stringOffset + stringSize) > ctx.payloadSize)
            return SnoValue(std::string{});
        reader.setPosition(static_cast<u32>(stringOffset));
        return SnoValue(reader.readString(static_cast<size_t>(stringSize), true));
    }

    // -- DT_CHARARRAY --
    case TypeHash::DT_CHARARRAY: {
        i32 const arrLen = field ? field->arrayLength : 0;
        if (arrLen <= 0)
            return SnoValue(std::string{});
        ctx.readLength += static_cast<size_t>(arrLen);
        reader.setPosition(static_cast<u32>(offset));
        return SnoValue(reader.readString(static_cast<size_t>(arrLen), true));
    }

    // -- DT_STRING_FORMULA --
    case TypeHash::DT_STRING_FORMULA: {
        ctx.readLength += 32;
        reader.setPosition(static_cast<u32>(offset + 8));
        i32 const formulaOffset = reader.read<i32>();
        i32 const formulaSize = reader.read<i32>();
        if (formulaSize <= 0 || formulaOffset <= 0 ||
            static_cast<size_t>(formulaOffset + formulaSize) > ctx.payloadSize)
            return SnoValue(std::string{});
        reader.setPosition(static_cast<u32>(formulaOffset));
        return SnoValue(reader.readString(static_cast<size_t>(formulaSize), true));
    }

    // -- Geometric / color types --
    case TypeHash::DT_RGBACOLOR:
        return readSimple<SnoColor>(ctx, offset);
    case TypeHash::DT_RGBACOLORVALUE:
        return readSimple<SnoColorF>(ctx, offset);
    case TypeHash::DT_BCVEC2I:
        return readSimple<SnoIVec2>(ctx, offset);
    case TypeHash::DT_VECTOR2D:
        return readSimple<SnoVec2>(ctx, offset);
    case TypeHash::DT_VECTOR3D:
        return readSimple<SnoVec3>(ctx, offset);
    case TypeHash::DT_VECTOR4D:
        return readSimple<SnoVec4>(ctx, offset);

    // -- DT_OPTIONAL --
    case TypeHash::DT_OPTIONAL: {
        u32 sub[3];
        subTypeHashes(typeHashes, sub);
        ReadCtx subCtx = ctx.sub();
        SnoValue value = readStructure(subCtx, sub, offset, field);
        i32 const present = readAt<i32>(reader, offset);
        ctx.readLength += 4;
        return present ? std::move(value) : SnoValue();
    }

    // -- DT_RANGE --
    case TypeHash::DT_RANGE: {
        u32 sub[3];
        subTypeHashes(typeHashes, sub);
        ReadCtx subCtx = ctx.sub();
        SnoValue v1 = readStructure(subCtx, sub, offset, field);
        SnoValue v2 = readStructure(subCtx, sub, offset + subCtx.readLength, field);
        ctx.readLength += subCtx.readLength;
        SnoObject range;
        range["rangeValue1"] = std::move(v1);
        range["rangeValue2"] = std::move(v2);
        return SnoValue(std::move(range));
    }

    // -- DT_FIXEDARRAY --
    case TypeHash::DT_FIXEDARRAY: {
        i32 const arrLen = field ? field->arrayLength : 0;
        if (arrLen <= 0)
            return emptyArray();
        u32 sub[3];
        subTypeHashes(typeHashes, sub);

        // D3: fixed byte arrays are null-terminated strings
        if (ctx.format == SnoFormat::D3 && sub[0] == TypeHash::DT_BYTE) {
            size_t const len = static_cast<size_t>(arrLen);
            ctx.readLength += len;
            reader.setPosition(static_cast<u32>(offset));
            return SnoValue(reader.readString(len, true));
        }

        // Try typed array for homogeneous basic types
        size_t const elemSz = typedArrayElemSize(sub[0], field);
        if (elemSz > 0) {
            size_t const n = static_cast<size_t>(arrLen);
            if (offset + n * elemSz <= ctx.payloadSize) {
                ctx.readLength += n * elemSz;
                return SnoValue(readTypedArrayFromBuf(reader, offset, n, sub[0]));
            }
        }

        std::vector<SnoValue> arr;
        arr.reserve(static_cast<size_t>(arrLen));
        ReadCtx subCtx = ctx.sub();
        for (i32 i = 0; i < arrLen; ++i) {
            arr.push_back(readStructure(subCtx, sub, offset + subCtx.readLength, field));
        }
        ctx.readLength += subCtx.readLength;
        return SnoValue(SnoArray(std::move(arr)));
    }

    // -- DT_VARIABLEARRAY --
    case TypeHash::DT_VARIABLEARRAY: {
        ctx.readLength += 16;
        // D3 layout: {dataOffset(4), dataSize(4), 0(4), 0(4)}
        // D4 layout: {0(4), 0(4), dataOffset(4), dataSize(4)}
        size_t const descOff = (ctx.format == SnoFormat::D3) ? offset : offset + 8;
        reader.setPosition(static_cast<u32>(descOff));
        i32 const dataOffset = reader.read<i32>();
        i32 const dataSize = reader.read<i32>();

        u32 sub[3];
        subTypeHashes(typeHashes, sub);

        if (isExternalField(field, ctx.format)) {
            if (ctx.payloadDataReader && dataSize > 0 && dataOffset >= 0 &&
                static_cast<size_t>(dataOffset + dataSize) <= ctx.payloadDataSize)
                return readVarArray(ctx.forExternalPayload(), sub, static_cast<size_t>(dataOffset),
                                    dataSize, field);
            return makeExternalMarker(dataOffset, dataSize);
        }

        if (dataSize < 1 || dataOffset < 1 ||
            static_cast<size_t>(dataOffset + dataSize) > ctx.payloadSize)
            return emptyArray();

        return readVarArray(ctx, sub, static_cast<size_t>(dataOffset), dataSize, field);
    }

    // -- DT_POLYMORPHIC_VARIABLEARRAY --
    case TypeHash::DT_POLYMORPHIC_VARIABLEARRAY: {
        ctx.readLength += 24;
        reader.setPosition(static_cast<u32>(offset + 8));
        i32 const dataOffset = reader.read<i32>();
        i32 const dataSize = reader.read<i32>();
        i32 const dataCount = reader.read<i32>();

        if (isExternalField(field, ctx.format)) {
            if (ctx.payloadDataReader && dataSize > 0 && dataCount > 0 && dataOffset >= 0 &&
                static_cast<size_t>(dataOffset + dataSize) <= ctx.payloadDataSize) {
                size_t const off =
                    static_cast<size_t>(dataOffset) + static_cast<size_t>(dataCount) * 8;
                i32 const remaining = dataSize - dataCount * 8;
                return SnoValue(readPolyElements(ctx.forExternalPayload(), typeHashes[1], off,
                                                 remaining, dataCount, field));
            }
            return makeExternalMarker(dataOffset, dataSize, dataCount);
        }

        if (dataSize < 1 || dataCount < 1 || dataOffset < 1 ||
            static_cast<size_t>(dataOffset + dataSize) > ctx.payloadSize)
            return emptyArray();

        size_t const off = static_cast<size_t>(dataOffset) + static_cast<size_t>(dataCount) * 8;
        i32 const remaining = dataSize - dataCount * 8;
        return SnoValue(readPolyElements(ctx, typeHashes[1], off, remaining, dataCount, field));
    }

    // -- DT_TAGMAP --
    case TypeHash::DT_TAGMAP: {
        ctx.readLength += 16;
        // D3 layout: {dataOffset(4), dataSize(4), 0(4), 0(4)}
        // D4 layout: {0(4), 0(4), dataOffset(4), dataSize(4)}
        size_t const tmDescOff = (ctx.format == SnoFormat::D3) ? offset : offset + 8;
        reader.setPosition(static_cast<u32>(tmDescOff));
        i32 const dataOffset = reader.read<i32>();
        i32 const dataSize = reader.read<i32>();

        if (dataSize < 1 || dataOffset < 1)
            return SnoValue(SnoObject{});
        if (static_cast<size_t>(dataOffset + dataSize) > ctx.payloadSize)
            return SnoValue(SnoObject{});

        size_t doff = static_cast<size_t>(dataOffset);
        [[maybe_unused]] i32 drem = dataSize;

        i32 const dataCount = readAt<i32>(reader, doff);
        doff += 4;
        drem -= 4;

        // Step 1: read field info (names and types)
        struct TagField {
            u32 nameHash;
            u32 typeHashes[3];
            std::string name;
        };
        std::vector<TagField> tagFields(static_cast<size_t>(dataCount));
        for (i32 i = 0; i < dataCount; ++i) {
            reader.setPosition(static_cast<u32>(doff));
            tagFields[i].nameHash = reader.read<u32>();
            tagFields[i].typeHashes[0] = reader.read<u32>();
            tagFields[i].typeHashes[1] = TypeHash::DT_NULL;
            tagFields[i].typeHashes[2] = TypeHash::DT_NULL;
            doff += 8;
            drem -= 8;

            // Read additional type hashes if flagged
            for (int ti = 0; ti < 2; ++ti) {
                auto* ft = ctx.reg.findType(tagFields[i].typeHashes[ti]);
                if (!ft || !(ft->flags & 0x8000))
                    break;
                if (ti >= 1 && tagFields[i].typeHashes[0] == TypeHash::DT_BINDABLEPROPERTY &&
                    tagFields[i].typeHashes[1] == TypeHash::DT_CSTRING)
                    break;
                tagFields[i].typeHashes[ti + 1] = reader.read<u32>();
                doff += 4;
                drem -= 4;
            }

            // Resolve field name by hash — we just store the hash as a hex string
            // (the full name table would require attributes.json)
            char nameBuf[32];
            std::snprintf(nameBuf, sizeof(nameBuf), "field_%08x", tagFields[i].nameHash);
            tagFields[i].name = nameBuf;
        }

        // Step 2: read values
        SnoObject obj;
        for (i32 i = 0; i < dataCount; ++i) {
            size_t const reqAlign = getTypeAlignment(ctx.reg, tagFields[i].typeHashes, true);
            size_t const curAlign = doff % reqAlign;
            if (curAlign) {
                size_t const padding = reqAlign - curAlign;
                doff += padding;
                drem -= static_cast<i32>(padding);
            }

            SnoFieldDef tmField{};
            std::memcpy(tmField.typeHashes, tagFields[i].typeHashes, sizeof(tmField.typeHashes));
            tmField.nameIndex = 0; // not used directly
            tmField.flags = 0;
            tmField.offset = 0;
            tmField.arrayLength = -1;
            tmField.group = -1;
            tmField.serializedBitCount = 0;

            ReadCtx subCtx = ctx.sub();
            SnoValue val = readStructure(subCtx, tagFields[i].typeHashes, doff, &tmField);
            if (subCtx.readLength < 1)
                break;
            obj[tagFields[i].name] = std::move(val);
            doff += subCtx.readLength;
            drem -= static_cast<i32>(subCtx.readLength);
        }
        return SnoValue(std::move(obj));
    }

    // -- DT_BINDABLEPROPERTY --
    case TypeHash::DT_BINDABLEPROPERTY: {
        // DT_CSTRING DataStore @0, DT_CSTRING DataPath @16, DT_UINT FormatterId @32...
        SnoObject obj;

        u32 cstringHash[3] = {TypeHash::DT_CSTRING, TypeHash::DT_NULL, TypeHash::DT_NULL};
        ReadCtx sub1 = ctx.sub();
        obj["DataStore"] = readStructure(sub1, cstringHash, offset, field);

        ReadCtx sub2 = ctx.sub();
        obj["DataPath"] = readStructure(sub2, cstringHash, offset + 16, field);

        obj["FormatterId"] = SnoValue(readAt<u32>(reader, offset + 32));

        // padding + PropertyFlags + padding2 = 12 bytes
        // Then the sub-value
        size_t const subOffset = offset + 48;
        u32 sub[3] = {typeHashes[1], typeHashes[2], TypeHash::DT_NULL};
        ReadCtx sub3 = ctx.sub();
        obj["value"] = readStructure(sub3, sub, subOffset, field);

        ctx.readLength += 48 + sub3.readLength;
        if ((subOffset + sub3.readLength) % 8) {
            ctx.readLength += 4; // padding
        }
        return SnoValue(std::move(obj));
    }

    // -- DT_NULL --
    case TypeHash::DT_NULL:
    default:
        return SnoValue();
    }
}

// ============================================================================
// readStructure — the recursive dispatcher
// ============================================================================

static SnoValue readStructure(ReadCtx& ctx, const u32 typeHashes[3], size_t offset,
                              const SnoFieldDef* field) {
    if (typeHashes[0] == TypeHash::DT_NULL)
        return SnoValue();

    auto* typeDef = ctx.reg.findType(typeHashes[0]);

    // If the registry doesn't know this type (e.g. basic types in D3 registry),
    // try dispatching as a basic type directly by hash.
    if (!typeDef)
        return readBasicType(ctx, typeHashes[0], typeHashes, offset, field);

    // Basic type
    if (typeDef->isBasic) {
        return readBasicType(ctx, typeDef->hash, typeHashes, offset, field);
    }

    // Complex type — read as an object with named fields
    SnoObject obj;
    auto fields = ctx.reg.fields(*typeDef);
    for (auto& f : fields) {
        size_t fieldOff = offset + static_cast<size_t>(f.offset);

        // D3: pointer-to-struct indirection (flags & 1)
        if (ctx.format == SnoFormat::D3 && (f.flags & 1)) {
            // Bit 0 selects pointer indirection only for a *struct* field: the
            // slot then holds an i32 offset to the struct body.  It cannot mean
            // that for a basic type -- PhysicsDefinition is a flat 68-byte POD
            // whose DT_FLOAT at +20 carries bit 0 and is plainly stored inline.
            //
            // This used to `continue` for basic (and unknown) types, which did
            // not merely skip the indirection, it dropped the field outright.
            // That silently deleted every scalar of Physics (14 of 14 fields, so
            // .phy parsed to an empty object) and most of Material/AnimSet/
            // ShaderMap.  Basic and unresolved types now fall through and are
            // read inline; struct indirection is unchanged.
            auto* subType = ctx.reg.findType(f.typeHashes[0]);
            if (subType && !subType->isBasic && subType->size != 0) {
                if (fieldOff + 4 > ctx.payloadSize)
                    continue;
                i32 const ptrOff = readAt<i32>(ctx.reader, fieldOff);
                if (ptrOff <= 0 || static_cast<size_t>(ptrOff) + subType->size > ctx.payloadSize)
                    continue;
                fieldOff = static_cast<size_t>(ptrOff);
            }
        }

        ReadCtx subCtx = ctx.sub();
        SnoValue val = readStructure(subCtx, f.typeHashes, fieldOff, &f);
        const char* name = ctx.reg.fieldName(f);
        obj[name] = std::move(val);
    }
    ctx.readLength += typeDef->size;
    return SnoValue(std::move(obj));
}

// ============================================================================
// SnoReader
// ============================================================================

SnoReader::SnoReader()
    : m_registry(d4::SnoTypeRegistry::instance()), m_d3Registry(d3::SnoTypeRegistry::instance()) {}

void SnoReader::setFormatHashes(const std::unordered_map<i32, u32>& hashes) {
    m_groupFormatHashes = hashes;
}

std::optional<SnoFile> SnoReader::parse(std::span<const u8> data) const {
    return parse(data, SnoGroup::None);
}

std::optional<SnoFile> SnoReader::parse(std::span<const u8> data, SnoGroup group) const {
    return parse(data, group, {});
}

std::optional<SnoFile> SnoReader::parse(std::span<const u8> data, SnoGroup group,
                                        std::span<const u8> payloadData) const {
    if (data.size() < 20)
        return std::nullopt;

    span_streambuf dataBuf(data);
    std::istream dataStream(&dataBuf);
    BinaryReader dataReader(dataStream);

    u32 const magic = dataReader.read<u32>();
    if (magic != kSnoMagic)
        return std::nullopt;

    u32 formatHash = dataReader.read<u32>();

    if (formatHash == 0 && group != SnoGroup::None) {
        auto it = m_groupFormatHashes.find(static_cast<i32>(group));
        if (it != m_groupFormatHashes.end())
            formatHash = it->second;
    }

    // Look up the root type via D4 registry
    u32 rootTypeHash = 0;
    if (formatHash != 0)
        rootTypeHash = m_registry.typeHashFromKey(formatHash);

    // The compiled registry comes from d4data and lags behind live builds:
    // Blizzard rehashes a root type's format hash on every layout change.
    // d4::rootTypeHashForFormatHash() carries the extra hashes read straight
    // out of the shipped client's SNO group containers, so a file stamped with
    // a revision d4data has not caught up to still finds its root type.
    if (rootTypeHash == 0 && isD4FormatHash(formatHash))
        rootTypeHash = d4::rootTypeHashForFormatHash(formatHash);

    // If D4 format-hash lookup failed and the D3 registry has an explicit
    // group mapping, prefer D3 over the D4 name-based fallback.  D3 SNO
    // files store a version number (small integer) in the same field that
    // D4 uses for the format hash, so the D4 key lookup correctly fails.
    // Without this check, the name-based fallback below would find the D4
    // type (e.g. "AnimationDefinition") and parse the D3 file incorrectly.
    if (rootTypeHash == 0 && group != SnoGroup::None) {
        u32 const d3TypeHash = m_d3Registry.typeHashFromKey(toD3GroupId(group));
        if (d3TypeHash != 0)
            return parseD3(data, group);
    }

    // Format hash still unresolved: fall back to the group's root type as the
    // shipped client itself binds it (one SnoContainer per group, each built
    // with its root type).  This is exact where the name-based guess below is
    // not — the guess cannot reach nine of the 135 groups, either because the
    // type is spelled differently (NPCComponentSet, Subzone, Storyboard,
    // ABTest) or named outright differently (UI -> UIDialogDefinition,
    // Modal -> UIModalDefinition), or because snoGroupName() has no case for
    // the group at all (CrowdTemplates, CrowdPlacements, Pip).
    //
    // Gated on isD4FormatHash: this table covers 135 of the 181 group ids and
    // D3 reuses that same id space, so without the gate a D3 file whose group
    // the D3 registry happens not to cover would resolve a D4 root here and be
    // parsed as D4 instead of falling through to parseD3 below.
    if (rootTypeHash == 0 && isD4FormatHash(formatHash) && group != SnoGroup::None)
        rootTypeHash = d4::rootTypeHashForGroup(static_cast<i32>(group));

    // Last D4 resort: construct the expected type name from the SNO group
    // name.  Only reachable for a group with no container in the client.
    if (rootTypeHash == 0 && formatHash != 0 && group != SnoGroup::None) {
        const char* gn = snoGroupName(group);
        if (gn) {
            std::string const typeName = std::string(gn) + "Definition";
            rootTypeHash = m_registry.typeHashFromName(typeName.c_str());
        }
    }

    // If D4 lookup also failed, try D3 as last resort
    if (rootTypeHash == 0 && group != SnoGroup::None) {
        return parseD3(data, group);
    }
    if (rootTypeHash == 0)
        return std::nullopt;

    return parseD4(data, magic, formatHash, rootTypeHash, payloadData);
}

std::optional<SnoFile> SnoReader::parseD4(std::span<const u8> data, u32 magic, u32 formatHash,
                                          u32 rootTypeHash, std::span<const u8> payloadData) const {
    auto* rootType = m_registry.findType(rootTypeHash);
    if (!rootType)
        return std::nullopt;

    auto payload = data.subspan(16);
    span_streambuf payloadBuf(payload);
    std::istream payloadStream(&payloadBuf);
    BinaryReader reader(payloadStream);

    i32 const snoId = reader.read<i32>();

    // Set up external payload data reader if provided
    span_streambuf pdBuf(payloadData);
    std::istream pdStream(&pdBuf);
    BinaryReader pdReader(pdStream);
    BinaryReader* pdPtr = payloadData.empty() ? nullptr : &pdReader;

    ReadCtx ctx{reader, pdPtr, m_registry, SnoFormat::D4, payload.size(), payloadData.size(), 0};
    SnoValue root = readStructure(ctx, &rootTypeHash, 0, nullptr);

    SnoFile result;
    result.dwSignature = magic;
    result.formatHash = formatHash;
    result.snoId = snoId;
    result.typeName = m_registry.typeName(*rootType);
    result.root = std::move(root);
    return result;
}

// ============================================================================
// D3 SNO parsing
// ============================================================================

std::optional<SnoFile> SnoReader::parseD3(std::span<const u8> data, SnoGroup group) const {
    if (data.size() < 32)
        return std::nullopt;

    span_streambuf dataBuf(data);
    std::istream dataStream(&dataBuf);
    BinaryReader dataReader(dataStream);

    u32 const magic = dataReader.read<u32>();
    if (magic != kSnoMagic)
        return std::nullopt;

    u32 const version = dataReader.read<u32>();

    u32 const groupId = toD3GroupId(group);
    u32 const rootTypeHash = m_d3Registry.typeHashFromKey(groupId);
    if (rootTypeHash == 0)
        return std::nullopt;

    auto* rootType = m_d3Registry.findType(rootTypeHash);
    if (!rootType)
        return std::nullopt;

    auto payload = data.subspan(16);
    span_streambuf payloadBuf(payload);
    std::istream payloadStream(&payloadBuf);
    BinaryReader reader(payloadStream);

    i32 const snoId = reader.read<i32>();

    ReadCtx ctx{reader, nullptr, m_d3Registry, SnoFormat::D3, payload.size(), 0, 0};
    SnoValue root = readStructure(ctx, &rootTypeHash, 0, nullptr);

    SnoFile result;
    result.dwSignature = magic;
    result.formatHash = version;
    result.snoId = snoId;
    result.typeName = m_d3Registry.typeName(*rootType);
    result.root = std::move(root);
    return result;
}

} // namespace sno
} // namespace whiteout