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
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 Fernando Sahmkow
#include <whiteout/models/wem/profile.h>
namespace whiteout {
namespace models {
namespace wem {
namespace {
// ---------------------------------------------------------------------------
// Blend-mode sets, one per format family. These are the modes the *format*
// encodes, not the modes a renderer can produce: an exporter that meets a mode
// outside its profile's set records `DiagCode::LossyBlendMode` and writes the
// nearest neighbour.
// ---------------------------------------------------------------------------
/// MDX filterMode 0..6: None, Transparent, Blend, Additive, AddAlpha, Modulate, Modulate2x.
constexpr BlendMode kMdxBlendModes[] = {
BlendMode::Opaque, BlendMode::Transparent, BlendMode::AlphaBlend, BlendMode::Additive,
BlendMode::AdditiveAlpha, BlendMode::Modulate, BlendMode::Modulate2x,
};
/// M2 blendingMode 0..7.
constexpr BlendMode kM2BlendModes[] = {
BlendMode::Opaque, BlendMode::AlphaKey, BlendMode::AlphaBlend, BlendMode::AdditiveAlpha,
BlendMode::Additive, BlendMode::Modulate, BlendMode::Modulate2x, BlendMode::BlendAdd,
};
/// M3 BlendMode 0..5 — plus `AlphaKey`, which M3 spells as an Opaque blend
/// with a non-zero `alphaTestThreshold` (the test is a render state beside the
/// blend there, not a blend mode).
constexpr BlendMode kM3BlendModes[] = {
BlendMode::Opaque, BlendMode::AlphaKey, BlendMode::AlphaBlend, BlendMode::Additive,
BlendMode::AdditiveAlpha, BlendMode::Modulate, BlendMode::Modulate2x,
};
/// Diablo III. Provisional until P6 pins the RenderPass blend enum — which is
/// *not* D3DBLEND — against the reconstructed shader atlas.
constexpr BlendMode kD3BlendModes[] = {
BlendMode::Opaque, BlendMode::AlphaKey, BlendMode::AlphaBlend,
BlendMode::Additive, BlendMode::AdditiveAlpha, BlendMode::Modulate,
BlendMode::Modulate2x, BlendMode::PremultipliedAlpha,
};
/// Everything WEM can name. `Generic` has no exporter, so nothing it allows can
/// be written wrong.
constexpr BlendMode kAllBlendModes[] = {
BlendMode::Opaque, BlendMode::AlphaKey,
BlendMode::AlphaBlend, BlendMode::Additive,
BlendMode::AdditiveAlpha, BlendMode::Modulate,
BlendMode::Modulate2x, BlendMode::BlendAdd,
BlendMode::Transparent, BlendMode::PremultipliedAlpha,
};
constexpr MaterialKindMask kComposite = MaterialKindBit(MaterialKind::Composite);
constexpr MaterialKindMask kCombiners = MaterialKindBit(MaterialKind::Combiners);
constexpr MaterialKindMask kLegacy = MaterialKindBit(MaterialKind::LegacyDeferred);
constexpr MaterialKindMask kPbr = MaterialKindBit(MaterialKind::PBRDeferred);
template <std::size_t N>
constexpr std::span<const BlendMode> modes(const BlendMode (&a)[N]) {
return std::span<const BlendMode>(a, N);
}
// ---------------------------------------------------------------------------
// The registry. Indexed by ProfileId; `kDescs[i].id == ProfileId(i)` is asserted
// by wem_profile_test.
//
// `winding` and `handedness` agree across all seven today — every Blizzard format
// is right-handed with CCW front faces, which is why no importer reverses indices
// and the SC2 rebase (a rotation, §6.4) costs nothing. The fields exist so a
// format that disagrees is describable, not so a converter has to guess.
// ---------------------------------------------------------------------------
ProfileDesc makeDesc(ProfileId id, const char* name, const char* displayName, const char* formatId,
CoordSpace space, f32 sceneScale, u32 maxInfluences, u32 maxUvSets,
u32 maxPalette, IndexWidth width, bool ngons, bool vertexColor,
std::span<const BlendMode> blends, MaterialKindMask kinds, NativeKind native,
bool looks, bool actors, RigConvention rig = RigConvention::PivotRelative,
MaterialKindMask containerKinds = 0) {
ProfileDesc d;
d.id = id;
d.name = name;
d.displayName = displayName;
d.formatId = formatId;
d.sourceSpace = space;
d.rig = rig;
d.handedness = Handedness::Right;
d.winding = WindingOrder::CounterClockwise;
d.sceneScale = sceneScale;
d.maxBoneInfluences = maxInfluences;
d.maxUvSets = maxUvSets;
d.maxBonesPerPalette = maxPalette;
d.indexWidth = width;
d.allowsNgons = ngons;
d.allowsVertexColor = vertexColor;
d.blendModes = blends;
d.commonKinds = kinds;
d.containerKinds = containerKinds;
d.nativeMaterialKind = native;
d.supportsLooks = looks;
d.supportsActors = actors;
return d;
}
const std::array<ProfileDesc, static_cast<std::size_t>(ProfileId::Count)>& descs() {
static const std::array<ProfileDesc, static_cast<std::size_t>(ProfileId::Count)> table = {{
// Generic is the permissive profile: no exporter, so its limits exist only
// to be wider than every source it can be derived from. n-gons and U32
// indices are allowed here and nowhere else, because an authored or
// retargeted document is allowed to be richer than any single game.
makeDesc(ProfileId::Generic, "generic", "Generic", nullptr, CoordSpace::Blizzard, 1.0f, 4,
8, 0, IndexWidth::U32, /*ngons*/ true, /*vcolor*/ true, modes(kAllBlendModes),
kAllMaterialKinds, NativeKind::None,
/*looks*/ true, /*actors*/ true,
// An explicit bind can state a pivot rig -- IREF is then
// `T(-pivot)` -- and a pivot rig cannot state an explicit one,
// so the permissive profile takes the wider of the two.
RigConvention::ExplicitBind),
// Warcraft III, SD. One UV set; the layer stack is Composite, and collapses
// to Combiners when the whole stack folds into one draw (§7.2.2).
makeDesc(ProfileId::Wc3Classic, "wc3_classic", "Warcraft III (Classic)", "mdx",
CoordSpace::Blizzard, 1.0f, 4, 1, 0, IndexWidth::U16, false, false,
modes(kMdxBlendModes), kComposite | kCombiners, NativeKind::Mdx, false, false),
// Warcraft III, HD. Same container, PBR shading: `SlotType` renames onto
// `PbrSlot` and layer fresnel becomes a feature.
// The HD half of an `.mdx`. `containerKinds` is the SD half of the same
// file: a material says which it is by name, so a chain the slot map
// cannot fold is written as a layer stack instead. `Combiners` only --
// a `Composite` is an ordered stack over NAMED channels and an MDX SD
// stack has none, so writing one there drops the normal, the specular
// and the ambient occlusion to keep an ordering nothing reads.
makeDesc(ProfileId::Wc3Reforged, "wc3_reforged", "Warcraft III (Reforged)", "mdx",
CoordSpace::Blizzard, 1.0f, 4, 2, 0, IndexWidth::U16, false, false,
modes(kMdxBlendModes), kPbr, NativeKind::Mdx, false, false,
RigConvention::PivotRelative, kCombiners),
// World of Warcraft. Stage order and combine ops *are* the material, so
// Combiners is the only kind; looks carry texture variations (§8).
makeDesc(ProfileId::Wow, "wow", "World of Warcraft", "m2", CoordSpace::Blizzard, 100.0f, 4,
2, 0, IndexWidth::U16, false, false, modes(kM2BlendModes), kCombiners,
NativeKind::M2, /*looks*/ true, false),
// StarCraft II. Five UV sets; the 256-entry bone palette is the M3 ceiling.
// Combiners is accepted so a WoW or Diablo III derive keeps its chain
// intact for the M3 exporter's own crossing (WOW_TO_SC2_DESIGN.md §3)
// -- squeezed through `toComposite` first, every stage landed on the
// Color channel and the mod-family seconds had no slot to go into.
// LegacyDeferred is accepted for the same reason from the other side
// (D3_TO_SC2_DESIGN.md §2): the kind IS this generation's slot
// vocabulary, and the Composite squeeze had no channel for the
// lightmap, gloss or height slots the StandardMaterial has names for.
// 63 bones a region, not 256: that is the ceiling across all 67,320 v5
// regions in the corpus -- 436 sit exactly on it and none goes past --
// and a draw whose palette overruns the vertex shader's matrix
// registers fails with D3DERR_INVALIDCALL, which the Galaxy editor
// answers by resetting the device every frame onto a black viewport.
makeDesc(ProfileId::Sc2, "sc2", "StarCraft II", "m3", CoordSpace::Sc2, 100.0f, 4, 5, 63,
IndexWidth::U16, false, true, modes(kM3BlendModes),
kComposite | kCombiners | kLegacy, NativeKind::M3, false, false,
RigConvention::ExplicitBind),
// Heroes of the Storm. Same container and space; the difference is the
// version range and therefore the available material kinds (MADD at v30).
makeDesc(ProfileId::Heroes, "heroes", "Heroes of the Storm", "m3", CoordSpace::Sc2, 100.0f,
4, 5, 63, IndexWidth::U16, false, true, modes(kM3BlendModes),
kComposite | kCombiners | kLegacy, NativeKind::M3, false, false,
RigConvention::ExplicitBind),
// Diablo III. Three influences on disk, a packed vertex colour pair, and the
// only profile with actors today. A Legacy stage block makes a material
// Combiners; without one the shader-named texture types project onto
// legacy slots.
makeDesc(ProfileId::Diablo3, "diablo3", "Diablo III", "d3", CoordSpace::Blizzard, 17.0f, 3,
2, 0, IndexWidth::U16, false, /*vcolor*/ true, modes(kD3BlendModes),
kCombiners | kLegacy, NativeKind::D3, /*looks*/ true,
/*actors*/ true, RigConvention::ExplicitBind),
}};
return table;
}
} // namespace
// ============================================================================
const char* ToString(ProfileId id) {
if (static_cast<u32>(id) >= static_cast<u32>(ProfileId::Count)) {
return "invalid";
}
return descs()[static_cast<std::size_t>(id)].name;
}
ProfileId ProfileFromName(const std::string& name) {
const auto& table = descs();
for (std::size_t i = 0; i < table.size(); ++i) {
if (name == table[i].name) {
return static_cast<ProfileId>(i);
}
}
return ProfileId::Count;
}
const char* ToString(RigConvention rig) {
switch (rig) {
case RigConvention::PivotRelative:
return "pivot_relative";
case RigConvention::ExplicitBind:
return "explicit_bind";
}
return "invalid";
}
const char* ToString(CoordSpace space) {
switch (space) {
case CoordSpace::Blizzard:
return "blizzard";
case CoordSpace::Sc2:
return "sc2";
}
return "invalid";
}
const char* ToString(BlendMode mode) {
switch (mode) {
case BlendMode::Opaque:
return "opaque";
case BlendMode::AlphaKey:
return "alpha_key";
case BlendMode::AlphaBlend:
return "alpha_blend";
case BlendMode::Additive:
return "additive";
case BlendMode::AdditiveAlpha:
return "additive_alpha";
case BlendMode::Modulate:
return "modulate";
case BlendMode::Modulate2x:
return "modulate_2x";
case BlendMode::BlendAdd:
return "blend_add";
case BlendMode::Transparent:
return "transparent";
case BlendMode::PremultipliedAlpha:
return "premultiplied_alpha";
}
return "invalid";
}
const char* ToString(MaterialKind kind) {
switch (kind) {
case MaterialKind::Composite:
return "composite";
case MaterialKind::Combiners:
return "combiners";
case MaterialKind::LegacyDeferred:
return "legacy_deferred";
case MaterialKind::PBRDeferred:
return "pbr_deferred";
case MaterialKind::Count:
break;
}
return "invalid";
}
const char* ToString(NativeKind kind) {
switch (kind) {
case NativeKind::None:
return "none";
case NativeKind::Mdx:
return "mdx";
case NativeKind::M2:
return "m2";
case NativeKind::M3:
return "m3";
case NativeKind::D3:
return "d3";
}
return "invalid";
}
bool ProfileDesc::acceptsBlendMode(BlendMode mode) const {
for (BlendMode m : blendModes) {
if (m == mode) {
return true;
}
}
return false;
}
const ProfileDesc& Profile(ProfileId id) {
const auto& table = descs();
const auto index = static_cast<std::size_t>(id);
return table[index < table.size() ? index : 0];
}
std::span<const ProfileDesc> AllProfileDescs() {
const auto& table = descs();
return std::span<const ProfileDesc>(table.data(), table.size());
}
} // namespace wem
} // namespace models
} // namespace whiteout