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
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 Fernando Sahmkow
#include "m2_shader_table.h"
namespace whiteout {
namespace models {
namespace wem {
namespace m2_core {
namespace {
using PS = M2PixelShader;
constexpr CombinerOp kSeed = CombinerOp::Opaque;
constexpr CombinerOp kMod = CombinerOp::Mod;
constexpr CombinerOp kMod2x = CombinerOp::Mod2x;
constexpr CombinerOp kAdd = CombinerOp::Add;
constexpr CombinerOp kAddA = CombinerOp::AddAlpha;
constexpr CombinerOp kFade = CombinerOp::Fade;
constexpr CombinerOp kPass = CombinerOp::Pass;
constexpr CombinerOp kMaskMod = CombinerOp::MaskedMod;
constexpr CombinerOp kMaskMod2x = CombinerOp::MaskedMod2x;
/// The pixel column of `s_modelShaderEffect`. WEM wants ops, not shader ids, so
/// the vertex column — which only says which UV source feeds each unit — is not
/// transcribed here; `TextureInput::uvSet` carries that per unit instead.
constexpr PS kExplicitEffects[kNumShaderEffects] = {
PS::Combiners_Opaque_Mod2xNA_Alpha,
PS::Combiners_Opaque_AddAlpha,
PS::Combiners_Opaque_AddAlpha_Alpha,
PS::Combiners_Opaque_Mod2xNA_Alpha_Add,
PS::Combiners_Mod_AddAlpha,
PS::Combiners_Opaque_AddAlpha,
PS::Combiners_Mod_AddAlpha,
PS::Combiners_Mod_AddAlpha_Alpha,
PS::Combiners_Opaque_Alpha_Alpha,
PS::Combiners_Opaque_Mod2xNA_Alpha_3s,
PS::Combiners_Opaque_AddAlpha_Wgt,
PS::Combiners_Mod_Add_Alpha,
PS::Combiners_Opaque_ModNA_Alpha,
PS::Combiners_Mod_AddAlpha_Wgt,
PS::Combiners_Mod_AddAlpha_Wgt,
PS::Combiners_Opaque_AddAlpha_Wgt,
PS::Combiners_Opaque_Mod_Add_Wgt,
PS::Combiners_Opaque_Mod2xNA_Alpha_UnshAlpha,
PS::Combiners_Mod_Dual_Crossfade,
PS::Combiners_Mod_Depth,
PS::Combiners_Opaque_Mod2xNA_Alpha_Alpha,
PS::Combiners_Mod_Mod,
PS::Combiners_Mod_Masked_Dual_Crossfade,
PS::Combiners_Opaque_Alpha,
PS::Combiners_Opaque_Mod2xNA_Alpha_UnshAlpha,
PS::Combiners_Mod_Depth,
PS::Guild,
PS::Guild_NoBorder,
PS::Guild_Opaque,
PS::Illum,
// 30-35, absent from 6.0.1. The corpus selects 33, 34 and 35; a 30-row
// table sent all three down the bit-field path, which gets the edge fade
// wrong every time.
PS::Combiners_Unnamed_35,
PS::Combiners_Unnamed_35,
PS::Combiners_Opaque,
PS::Combiners_Mod_Mod2x,
PS::Combiners_Mod,
PS::Combiners_Mod_Mod_Depth,
};
/// The client's two 8-entry tables, indexed by `shaderId & 7`.
constexpr PS kTwoTexture[8] = {
PS::Combiners_Opaque_Opaque, PS::Combiners_Opaque_Mod, PS::Combiners_Opaque_Mod,
PS::Combiners_Opaque_AddAlpha, PS::Combiners_Opaque_Mod2x, PS::Combiners_Opaque_Mod,
PS::Combiners_Opaque_Mod2xNA, PS::Combiners_Opaque_AddAlpha,
};
constexpr PS kTwoTextureEnv[8] = {
PS::Combiners_Mod_Opaque, PS::Combiners_Mod_Mod, PS::Combiners_Mod_Mod,
PS::Combiners_Mod_Add, PS::Combiners_Mod_Mod2x, PS::Combiners_Mod_Mod,
PS::Combiners_Mod_Mod2xNA, PS::Combiners_Mod_AddNA,
};
constexpr const char* kNames[static_cast<std::size_t>(PS::Count)] = {
"Combiners_Opaque",
"Combiners_Mod",
"Combiners_Opaque_Mod",
"Combiners_Opaque_Mod2x",
"Combiners_Opaque_Mod2xNA",
"Combiners_Opaque_Opaque",
"Combiners_Mod_Mod",
"Combiners_Mod_Mod2x",
"Combiners_Mod_Add",
"Combiners_Mod_Mod2xNA",
"Combiners_Mod_AddNA",
"Combiners_Mod_Opaque",
"Combiners_Opaque_Mod2xNA_Alpha",
"Combiners_Opaque_AddAlpha",
"Combiners_Opaque_AddAlpha_Alpha",
"Combiners_Opaque_Mod2xNA_Alpha_Add",
"Combiners_Mod_AddAlpha",
"Combiners_Mod_AddAlpha_Alpha",
"Combiners_Opaque_Alpha_Alpha",
"Combiners_Opaque_Mod2xNA_Alpha_3s",
"Combiners_Opaque_AddAlpha_Wgt",
"Combiners_Mod_Add_Alpha",
"Combiners_Opaque_ModNA_Alpha",
"Combiners_Mod_AddAlpha_Wgt",
"Combiners_Opaque_Mod_Add_Wgt",
"Combiners_Opaque_Mod2xNA_Alpha_UnshAlpha",
"Combiners_Mod_Dual_Crossfade",
"Combiners_Opaque_Mod2xNA_Alpha_Alpha",
"Combiners_Mod_Masked_Dual_Crossfade",
"Combiners_Opaque_Alpha",
"Guild",
"Guild_NoBorder",
"Guild_Opaque",
"Combiners_Mod_Depth",
"Illum",
"Combiners_Unnamed_35",
"Combiners_Mod_Mod_Depth",
};
/// One row per pixel shader, read off the shader **bodies** rather than off the
/// names — `renderer/shaders/m2_combiners.slang`, which transcribes the fifteen
/// wowdev.wiki publishes and derives the rest from the naming grammar.
///
/// Reading the names instead is what the first version of this table did, and a
/// body settles two things the names get wrong:
///
/// - **A unit never replaces.** `Combiners_Opaque_Opaque` is
/// `c.rgb * t0.rgb * t1.rgb`, not "t1 wins": in World of Warcraft the word
/// `Opaque` says the unit contributes **no alpha**, and its colour multiplies
/// in either way. So `rgb[0]` is the seed on every row and `rgb[i>0]` is only
/// ever a fold. A mid-chain `Opaque` reached MDX as `FilterMode::None`, which
/// is a fresh opaque draw, and the units before it stopped existing.
/// - **The `_Alpha` family's mask is unit 0's alpha, not unit 1's.**
/// `lerp(t1.rgb * 2, 1, t0.a)`: wherever the base map is opaque — which is
/// most of a shipped base map — the second unit contributes **nothing**. MDX
/// has no pass that reads another pass's alpha, so those stages are `Pass`:
/// the identity is the value they carry over most of the surface, and drawing
/// the environment map unmasked over all of it turned Kil'jaeden's red skin
/// tan and washed out the Lich's gem.
///
/// The modifiers stay in `unexpressed`, which the converter reports once:
///
/// `NA` the unit does not touch alpha, so its alpha op is `Pass`
/// `_Alpha` unit 0's alpha masks the unit before it
/// `_Wgt` the contribution is scaled by a batch weight
/// `_3s` a third sampler replaces the `1` the mask lerps toward
/// `Dual_Crossfade`, `Masked_...` an interpolation by batch weights
/// `Depth` a depth-driven edge fade, which needs the EDGF chunk
constexpr Chain kChains[static_cast<std::size_t>(PS::Count)] = {
// 0 Combiners_Opaque c*t0 | c.a
{1, {kSeed}, {kPass}, nullptr},
// 1 Combiners_Mod c*t0 | c.a*t0.a
{1, {kSeed}, {kMod}, nullptr},
// 2 Combiners_Opaque_Mod c*t0*t1 | c.a*t1.a
{2, {kSeed, kMod}, {kPass, kMod}, nullptr},
// 3 Combiners_Opaque_Mod2x c*t0*t1*2 | c.a*t1.a*2
{2, {kSeed, kMod2x}, {kPass, kMod2x}, nullptr},
// 4 Combiners_Opaque_Mod2xNA c*t0*t1*2 | c.a
{2, {kSeed, kMod2x}, {kPass, kPass}, nullptr},
// 5 Combiners_Opaque_Opaque c*t0*t1 | c.a
{2, {kSeed, kMod}, {kPass, kPass}, nullptr},
// 6 Combiners_Mod_Mod c*t0*t1 | c.a*t0.a*t1.a
{2, {kSeed, kMod}, {kMod, kMod}, nullptr},
// 7 Combiners_Mod_Mod2x c*t0*t1*2 | c.a*t0.a*t1.a*2
{2, {kSeed, kMod2x}, {kMod, kMod2x}, nullptr},
// 8 Combiners_Mod_Add c*t0 + t1 | c.a*t0.a + t1.a
{2, {kSeed, kAdd}, {kMod, kAdd}, nullptr},
// 9 Combiners_Mod_Mod2xNA c*t0*t1*2 | c.a*t0.a
{2, {kSeed, kMod2x}, {kMod, kPass}, nullptr},
// 10 Combiners_Mod_AddNA c*t0 + t1 | c.a*t0.a
{2, {kSeed, kAdd}, {kMod, kPass}, nullptr},
// 11 Combiners_Mod_Opaque c*t0*t1 | c.a*t0.a
{2, {kSeed, kMod}, {kMod, kPass}, nullptr},
// 12 Combiners_Opaque_Mod2xNA_Alpha c*t0*lerp(t1*2,1,t0.a) | c.a
{2, {kSeed, kMaskMod2x}, {kPass, kPass}, nullptr},
// 13 Combiners_Opaque_AddAlpha c*t0 + t1*t1.a | c.a
{2, {kSeed, kAddA}, {kPass, kPass}, nullptr},
// 14 Combiners_Opaque_AddAlpha_Alpha c*t0 + t1*t1.a*t0.a | c.a
{2, {kSeed, kAddA}, {kPass, kPass}, "the add is scaled by unit 0's alpha as well"},
// 15 Combiners_Opaque_Mod2xNA_Alpha_Add ... + t2*t2.a | c.a
{3, {kSeed, kMaskMod2x, kAddA}, {kPass, kPass, kPass}, nullptr},
// 16 Combiners_Mod_AddAlpha c*t0 + t1*t1.a | c.a*t0.a
{2, {kSeed, kAddA}, {kMod, kPass}, nullptr},
// 17 Combiners_Mod_AddAlpha_Alpha c*t0 + t1*t1.a*t0.a | c.a*(t0.a + t1.a*t1.a)
{2, {kSeed, kAddA}, {kMod, kAdd}, "the add is scaled by unit 0's alpha as well"},
// 18 Combiners_Opaque_Alpha_Alpha lerp(lerp(c*t0,t1,t1.a),c*t0,t0.a) | c.a
{2, {kSeed, kFade}, {kPass, kPass}, "unit 0's alpha fades the blend back out"},
// 19 Combiners_Opaque_Mod2xNA_Alpha_3s c*t0*lerp(t1*2,t2,t0.a) | c.a
{3,
{kSeed, kPass, kMod},
{kPass, kPass, kPass},
"unit 1 is a mod2x masked by unit 0's alpha, and unit 2 is what the mask reaches instead"},
// 20 Combiners_Opaque_AddAlpha_Wgt c*t0 + t1*t1.a*w | c.a
{2, {kSeed, kAddA}, {kPass, kPass}, "the add is also scaled by a batch weight"},
// 21 Combiners_Mod_Add_Alpha c*t0 + t1 | c.a*t0.a + t1.a
{2, {kSeed, kAdd}, {kMod, kAdd}, nullptr},
// 22 Combiners_Opaque_ModNA_Alpha c*t0*lerp(t1,1,t0.a) | c.a
{2, {kSeed, kMaskMod}, {kPass, kPass}, nullptr},
// 23 Combiners_Mod_AddAlpha_Wgt c*t0 + t1*t1.a*w | c.a*t0.a
{2, {kSeed, kAddA}, {kMod, kPass}, "the add is also scaled by a batch weight"},
// 24 Combiners_Opaque_Mod_Add_Wgt c*lerp(t0,t1,t1.a) + t0*w | c.a
//
// The one row where the body and the name disagree about how many units
// there are — the body reads two, the name spells three — and the body is
// one of the DERIVED ones. The name wins on the count, because reading a
// unit the batch declared is recoverable and dropping one is not.
{3, {kSeed, kMod, kAdd}, {kPass, kPass, kPass}, "the add is batch-weighted"},
// 25 Combiners_Opaque_Mod2xNA_Alpha_UnshAlpha
// lerp(c*t0*lerp(t1*2,1,t0.a), t0, glow*t2.a) | c.a
{3,
{kSeed, kMaskMod2x, kPass},
{kPass, kPass, kPass},
"unit 2's alpha fades the whole fold back to the unshaded base"},
// 26 Combiners_Mod_Dual_Crossfade c*lerp(lerp(t0,t1,a),t2,b) | c.a*(the same)
{3,
{kSeed, kFade, kFade},
{kMod, kFade, kFade},
"a crossfade by two batch weights, not a fold; MDX blends by the sampled alpha instead"},
// 27 Combiners_Opaque_Mod2xNA_Alpha_Alpha lerp(c*t0*mask, t2, t2.a) | c.a
{3, {kSeed, kMaskMod2x, kFade}, {kPass, kPass, kPass}, nullptr},
// 28 Combiners_Mod_Masked_Dual_Crossfade the crossfade, masked by t3.a
{4,
{kSeed, kFade, kFade, kPass},
{kMod, kFade, kFade, kMod},
"a crossfade by two batch weights, masked by unit 3's alpha"},
// 29 Combiners_Opaque_Alpha lerp(c*t0, t1, t1.a) | c.a
{2, {kSeed, kFade}, {kPass, kPass}, nullptr},
// 30 Guild c*lerp(t0, lerp(t1,t2,t2.a), t1.a) | c.a*t0.a
{3, {kSeed, kFade, kFade}, {kMod, kPass, kPass}, "guild tabard recolour"},
// 31 Guild_NoBorder c*lerp(t0,t1,t1.a) | c.a*t0.a
{2, {kSeed, kFade}, {kMod, kPass}, "guild tabard recolour"},
// 32 Guild_Opaque the same colour | c.a
{3, {kSeed, kFade, kFade}, {kPass, kPass, kPass}, "guild tabard recolour, opaque"},
// 33 Combiners_Mod_Depth c*t0 | c.a*t0.a
{1, {kSeed}, {kMod}, "a depth-driven edge fade"},
// 34 Illum c*t0 + t0*t1*w | c.a*t0.a
{2, {kSeed, kAdd}, {kMod, kPass}, "the emissive unit multiplies unit 0 and is batch-weighted"},
// 35 Combiners_Unnamed_35 like the _3s member above
{3,
{kSeed, kPass, kMod},
{kPass, kPass, kPass},
"unit 1 is a mod2x masked by unit 0's alpha, and unit 2 is what the mask reaches instead"},
// 36 Combiners_Mod_Mod_Depth c*t0*t1 | c.a*t0.a*t1.a
{2, {kSeed, kMod}, {kMod, kMod}, "a depth-driven edge fade"},
};
/// The vertex column of `s_modelShaderEffect` (12.1.0.69404 @ 0x14443E9F0),
/// row-aligned with `kExplicitEffects`, each shader name reduced to its
/// per-unit sources: `Diffuse_T1_Env_T1` is {T1, Env, T1}. Rows 30/31 pair
/// with the unnamed vertex shaders 14/15; three explicit-UV units is the
/// best available guess for a pixel shader that reads three samplers.
constexpr M2UvSource kT1 = M2UvSource::T1;
constexpr M2UvSource kT2 = M2UvSource::T2;
constexpr M2UvSource kEnv = M2UvSource::Env;
constexpr UvSources kVertexSources[kNumShaderEffects] = {
{2, {kT1, kEnv}, false}, // 0 Diffuse_T1_Env
{2, {kT1, kEnv}, false}, // 1 Diffuse_T1_Env
{2, {kT1, kEnv}, false}, // 2 Diffuse_T1_Env
{3, {kT1, kEnv, kT1}, false}, // 3 Diffuse_T1_Env_T1
{2, {kT1, kEnv}, false}, // 4 Diffuse_T1_Env
{2, {kT1, kT1}, false}, // 5 Diffuse_T1_T1
{2, {kT1, kT1}, false}, // 6 Diffuse_T1_T1
{2, {kT1, kEnv}, false}, // 7 Diffuse_T1_Env
{2, {kT1, kEnv}, false}, // 8 Diffuse_T1_Env
{3, {kT1, kEnv, kT1}, false}, // 9 Diffuse_T1_Env_T1
{2, {kT1, kT1}, false}, // 10 Diffuse_T1_T1
{2, {kT1, kEnv}, false}, // 11 Diffuse_T1_Env
{2, {kT1, kEnv}, false}, // 12 Diffuse_T1_Env
{2, {kT1, kEnv}, false}, // 13 Diffuse_T1_Env
{2, {kT1, kT1}, false}, // 14 Diffuse_T1_T1
{2, {kT1, kT2}, false}, // 15 Diffuse_T1_T2
{2, {kT1, kEnv}, false}, // 16 Diffuse_T1_Env
{3, {kT1, kEnv, kT1}, false}, // 17 Diffuse_T1_Env_T1
{3, {kT1, kT1, kT1}, false}, // 18 Diffuse_T1_T1_T1
{1, {kT1}, true}, // 19 Diffuse_EdgeFade_T1
{3, {kT1, kEnv, kT2}, false}, // 20 Diffuse_T1_Env_T2
{2, {kT1, kT2}, true}, // 21 Diffuse_EdgeFade_T1_T2
{4, {kT1, kT1, kT1, kT2}, false}, // 22 Diffuse_T1_T1_T1_T2
{2, {kT1, kT1}, false}, // 23 Diffuse_T1_T1
{3, {kT1, kEnv, kT2}, false}, // 24 Diffuse_T1_Env_T2
{1, {kEnv}, true}, // 25 Diffuse_EdgeFade_Env
{3, {kT1, kT2, kT1}, false}, // 26 Diffuse_T1_T2_T1
{2, {kT1, kT2}, false}, // 27 Diffuse_T1_T2
{3, {kT1, kT2, kT1}, false}, // 28 Diffuse_T1_T2_T1
{2, {kT1, kT1}, false}, // 29 Diffuse_T1_T1
{3, {kT1, kT1, kT1}, false}, // 30 Unnamed_14
{3, {kT1, kT1, kT1}, false}, // 31 Unnamed_15
{1, {kT1}, false}, // 32 Diffuse_T1
{2, {kT1, kT2}, true}, // 33 Diffuse_EdgeFade_T1_T2
{1, {kT1}, true}, // 34 Diffuse_EdgeFade_T1
{2, {kT1, kT2}, true}, // 35 Diffuse_EdgeFade_T1_T2
};
} // namespace
const char* ToString(M2PixelShader shader) {
const auto index = static_cast<std::size_t>(shader);
return index < static_cast<std::size_t>(PS::Count) ? kNames[index] : "?";
}
M2PixelShader PixelShaderFor(u32 textureCount, u16 shaderId, bool& outOfTable) {
outOfTable = false;
if (IsExplicitCombo(shaderId)) {
const u32 row = shaderId & 0x7FFFu;
if (row < kNumShaderEffects) {
return kExplicitEffects[row];
}
// Fall THROUGH to the bit-field path rather than onto row 0. Row 0 is an
// opaque two-texture environment combiner — the worst available guess —
// and choosing it silently is the recorded black-box artifact.
outOfTable = true;
}
if (textureCount <= 1) {
return (shaderId & 0x70u) != 0 ? PS::Combiners_Mod : PS::Combiners_Opaque;
}
return ((shaderId & 0x70u) != 0 ? kTwoTextureEnv : kTwoTexture)[shaderId & 7u];
}
Chain ChainOf(M2PixelShader shader) {
const auto index = static_cast<std::size_t>(shader);
return index < static_cast<std::size_t>(PS::Count) ? kChains[index] : Chain{};
}
UvSources UvSourcesFor(u32 textureCount, u16 shaderId) {
if (IsExplicitCombo(shaderId)) {
const u32 row = shaderId & 0x7FFFu;
if (row < kNumShaderEffects) {
return kVertexSources[row];
}
}
// The bit-field path -- the client's legacy vertex selection, mirrored
// from the 12.1 binary: bit 0x80 makes unit 0 env, bit 0x8 makes unit 1
// env, bit 0x4000 reads the second UV set instead of the first.
UvSources out;
if (textureCount <= 1) {
out.count = 1;
out.unit[0] = (shaderId & 0x80u) != 0
? M2UvSource::Env
: ((shaderId & 0x4000u) != 0 ? M2UvSource::T2 : M2UvSource::T1);
return out;
}
out.count = 2;
if ((shaderId & 0x80u) != 0) {
out.unit[0] = M2UvSource::Env;
out.unit[1] = (shaderId & 0x8u) != 0 ? M2UvSource::Env : M2UvSource::T1;
} else {
out.unit[0] = M2UvSource::T1;
out.unit[1] = (shaderId & 0x8u) != 0
? M2UvSource::Env
: ((shaderId & 0x4000u) != 0 ? M2UvSource::T2 : M2UvSource::T1);
}
return out;
}
} // namespace m2_core
} // namespace wem
} // namespace models
} // namespace whiteout