-- STADIUM battles: the generated fire and gas stand-ins. -- -- A port of model_extract/pipeline/effects.py, plus the bind-pose measurement -- build.py sizes them against. -- -- IMPORTANT: nothing here is extracted game data. The real tail flame, mane -- fire and gas are drawn by procedural callbacks that live in a different -- fragment -- geo command 0x08 records an attachment point and -- func_80014A60 calls node->unk_10, and the model file supplies only two -- empty display lists plus zeroed scratch buffers for it to fill. Those -- callbacks have not been ported, so the models genuinely contain no flame -- mesh and no flame texture: Charmander's texture set is eyes, claws, teeth -- and skin. -- -- What follows is an ORIGINAL, procedurally generated replacement -- looping -- flipbook noise on a pair of crossed quads, anchored to the exact bone the -- callback hangs off so it sits where the real effect would and follows the -- animation. Seeds derive from the species number, so a given Pokemon always -- generates the same flame. -- -- Which species get one is the game's own grouping: every species sharing a -- callback shares an effect. -- -- 0x810000D8 Charmander, Charmeleon, Charizard, Magmar, Moltres tail flame -- 0x81000108 Ponyta, Rapidash, Moltres's wings small flame -- 0x810000E0 Gastly (only) gas cloud -- the mod namespace (see main.lua): V.require loads a sibling module local V = ... local StadiumFx = {} local floor = math.floor local sqrt = math.sqrt local sin, cos = math.sin, math.cos local char = string.char local concat = table.concat local pi = math.pi local FIRE_TAIL = 0x810000D8 local FIRE_SMALL = 0x81000108 local AURA = 0x810000E0 -- Desired size as a fraction of the model's world-space height: length, width. StadiumFx.SIZES = { fire_tail = { 0.40, 0.22 }, fire_small = { 0.075, 0.042 }, gas = { 1.05, 1.05 }, } -- ------- 32-bit exclusive-or, in arithmetic -- -- The generator below is an xorshift, so it needs a real 32-bit xor and a -- real 32-bit wrap. Written out rather than taken from LuaJIT's `bit`, which -- works in SIGNED 32-bit and would need converting back on every step -- see -- the same note in StadiumFragment. local function bxor32(a, b) local r, p = 0, 1 for _ = 1, 32 do local x, y = a % 2, b % 2 if x ~= y then r = r + p end a, b, p = floor(a / 2), floor(b / 2), p * 2 end return r end -- ------- deterministic noise local Rng = {} Rng.__index = Rng local function newRng(seed) local s = seed % 0x100000000 if s == 0 then s = 0x9E3779B9 end return setmetatable({ s = s }, Rng) end function Rng:next() local x = self.s x = bxor32(x, (x % 0x80000) * 0x2000) -- x ^= (x << 13) x = bxor32(x, floor(x / 0x20000)) -- x ^= x >> 17 x = bxor32(x, (x % 0x8000000) * 0x20) -- x ^= (x << 5) self.s = x % 0x100000000 return self.s end function Rng:unit() return self:next() / 0x100000000 end -- A w-by-h lattice of unit noise, consumed row by row so the sequence -- and -- therefore the texture -- is reproducible. local function lattice(rng, w, h) local g = {} for y = 1, h do local row = {} for x = 1, w do row[x] = rng:unit() end g[y] = row end return g end local function smooth(t) return t * t * (3 - 2 * t) end -- Bilinear value noise on a torus, so the field tiles in both axes. local function sample(grid, x, y) local h = #grid local w = #grid[1] local fx0, fy0 = floor(x), floor(y) local x0, y0 = fx0 % w, fy0 % h local x1, y1 = (x0 + 1) % w, (y0 + 1) % h local fx, fy = smooth(x - fx0), smooth(y - fy0) local r0, r1 = grid[y0 + 1], grid[y1 + 1] local a = r0[x0 + 1] + (r0[x1 + 1] - r0[x0 + 1]) * fx local b = r1[x0 + 1] + (r1[x1 + 1] - r1[x0 + 1]) * fx return a + (b - a) * fy end -- Sum octaves of tileable noise. local function fbm(grids, x, y, scale) local total, amp, norm = 0.0, 1.0, 0.0 for i = 1, #grids do local f = scale * 2 ^ (i - 1) total = total + sample(grids[i], x * f, y * f) * amp norm = norm + amp amp = amp * 0.5 end return total / norm end -- Intensity -> RGBA, through a piecewise ramp. local function ramp(stops, t) if t < 0.0 then t = 0.0 elseif t > 1.0 then t = 1.0 end for i = 1, #stops - 1 do local a, b = stops[i], stops[i + 1] if t <= b[1] then local k = 0.0 if b[1] ~= a[1] then k = (t - a[1]) / (b[1] - a[1]) end return floor(a[2] + (b[2] - a[2]) * k), floor(a[3] + (b[3] - a[3]) * k), floor(a[4] + (b[4] - a[4]) * k), floor(a[5] + (b[5] - a[5]) * k) end end local last = stops[#stops] return last[2], last[3], last[4], last[5] end local FIRE_RAMP = { { 0.00, 0, 0, 0, 0 }, { 0.30, 120, 24, 8, 90 }, { 0.52, 226, 78, 16, 205 }, { 0.74, 252, 176, 44, 245 }, { 1.00, 255, 246, 214, 255 }, } local GAS_RAMP = { { 0.00, 0, 0, 0, 0 }, { 0.34, 52, 26, 78, 70 }, { 0.60, 96, 52, 140, 140 }, { 0.82, 148, 96, 196, 190 }, { 1.00, 208, 176, 236, 215 }, } local TRANSPARENT = char(0, 0, 0, 0) -- An upward-advected noise plume. Scrolling by an exact multiple of the -- lattice over the frame count is what makes the loop seamless. local function fireFrames(seed, w, h, frames, wisp) wisp = wisp or 1.0 local rng = newRng(seed) local grids = { lattice(rng, 8, 8), lattice(rng, 16, 16), lattice(rng, 32, 32) } local out = {} for f = 0, frames - 1 do local t = f / frames local buf = {} for i = 1, w * h do buf[i] = TRANSPARENT end for y = 0, h - 1 do local v = y / (h - 1) -- 0 at the base, 1 at the tip -- plume envelope: wide and hot at the base, pinched at the tip local taper = 1.0 - v if taper < 0.0 then taper = 0.0 end taper = taper ^ 0.42 for x = 0, w - 1 do local u = (x / (w - 1)) * 2 - 1 -- -1 .. 1 across the flame local denom = taper * 0.95 if denom < 0.10 then denom = 0.10 end local radial = 1.0 - (u < 0 and -u or u) / denom if radial > 0 then radial = radial ^ 0.7 local n = fbm(grids, x / w, (y / h) - t, 3.0) local lick = 0.55 + 0.75 * (n - 0.5) * wisp local inten = radial * (0.55 + 0.8 * taper) * lick inten = inten - 0.16 * v -- cool towards the tip if inten > 0.02 then local r, g, b, a = ramp(FIRE_RAMP, inten) -- +Y in texture space is up buf[(h - 1 - y) * w + x + 1] = char(r, g, b, a) end end end end out[f + 1] = concat(buf) end return w, h, out end -- Slow swirling haze that fades out towards the rim. local function gasFrames(seed, w, h, frames) local rng = newRng(seed) local grids = { lattice(rng, 8, 8), lattice(rng, 16, 16), lattice(rng, 32, 32) } local out = {} for f = 0, frames - 1 do local t = f / frames local buf = {} for i = 1, w * h do buf[i] = TRANSPARENT end local ang = t * 2 * pi local ca, sa = cos(ang), sin(ang) for y = 0, h - 1 do for x = 0, w - 1 do local dx = (x / (w - 1)) * 2 - 1 local dy = (y / (h - 1)) * 2 - 1 local d = sqrt(dx * dx + dy * dy) if d < 1.0 then local falloff = (1.0 - d) ^ 0.85 -- rotate the sample point so the haze churns without popping local sx = dx * ca - dy * sa local sy = dx * sa + dy * ca local n = fbm(grids, sx * 0.5 + 0.5, sy * 0.5 + 0.5 - t, 2.5) local inten = falloff * (0.78 + 1.30 * (n - 0.44)) if inten > 0.03 then local r, g, b, a = ramp(GAS_RAMP, inten) buf[y * w + x + 1] = char(r, g, b, a) end end end end out[f + 1] = concat(buf) end return w, h, out end -- Two quads at right angles, so the effect reads from any angle. `axis` picks -- which bone-local direction the quad grows along: bone-local +X runs down the -- limb, so a flame laid out along X comes out lying sideways, and 'y' is that -- same quad turned a quarter left about Z, which stands it up. `centred` -- straddles the origin instead of growing from it. local function crossedQuads(bone, length, width, axis, centred) local pos, uv, nrm, skin, idx = {}, {}, {}, {}, {} local ST = { { 0, 0 }, { 1, 0 }, { 1, 1 }, { 0, 1 } } local nv, ni = 0, 0 for q = 0, 1 do local base = nv for k = 1, 4 do local s, t = ST[k][1], ST[k][2] local a = (s - 0.5) * width local b = centred and (t - 0.5) * length or t * length local px, py, pz if axis == "x" then if q == 0 then px, py, pz = b, a, 0.0 else px, py, pz = b, 0.0, a end else -- (x, y) -> (-y, x) if q == 0 then px, py, pz = -a, b, 0.0 else px, py, pz = 0.0, b, a end end pos[nv * 3 + 1], pos[nv * 3 + 2], pos[nv * 3 + 3] = px, py, pz uv[nv * 2 + 1], uv[nv * 2 + 2] = s, 1.0 - t if q == 0 then nrm[nv * 3 + 1], nrm[nv * 3 + 2], nrm[nv * 3 + 3] = 0.0, 0.0, 1.0 else nrm[nv * 3 + 1], nrm[nv * 3 + 2], nrm[nv * 3 + 3] = 1.0, 0.0, 0.0 end skin[nv + 1] = bone nv = nv + 1 end idx[ni + 1], idx[ni + 2], idx[ni + 3] = base, base + 1, base + 2 idx[ni + 4], idx[ni + 5], idx[ni + 6] = base, base + 2, base + 3 ni = ni + 6 end return { pos = pos, uv = uv, nrm = nrm, skin = skin, nverts = nv, idx = idx, nidx = ni } end -- ------- the bind pose these are sized against -- -- build.py's bind_extent, kept in its own 4x4 column-major convention rather -- than folded into StadiumBuild's 3x4 walk. The two agree -- they are the -- same skeleton -- but the effect sizes come out of THIS one's per-bone scale -- measurement, and rewriting it into the other convention is exactly the kind -- of change that moves a byte without anyone noticing. local function trs(t, r, s) local function S(v) return sin(v / 32768 * pi) end local function C(v) return cos(v / 32768 * pi) end local sx, cx = S(r[1]), C(r[1]) local sy, cy = S(r[2]), C(r[2]) local sz, cz = S(r[3]), C(r[3]) return { cy * cz * s[1], cy * sz * s[1], -sy * s[1], 0, (sx * sy * cz - cx * sz) * s[2], (sx * sy * sz + cx * cz) * s[2], sx * cy * s[2], 0, (cx * sy * cz + sx * sz) * s[3], (cx * sy * sz - sx * cz) * s[3], cx * cy * s[3], 0, t[1], t[2], t[3], 1 } end local function mul(a, b) local r = {} for c = 0, 3 do for i = 1, 4 do r[c * 4 + i] = a[i] * b[c * 4 + 1] + a[4 + i] * b[c * 4 + 2] + a[8 + i] * b[c * 4 + 3] + a[12 + i] * b[c * 4 + 4] end end return r end -- (height of the bind pose, per-bone local scale). Height rather than the -- largest dimension: sizing off the max would scale Moltres's flames to its -- wingspan. function StadiumFx.bindExtent(data) local root = trs({ 0, 0, 0 }, { 0, 0, 0 }, data.rootScale) local acc, uns, mats = {}, {}, {} for i = 1, #data.bones do local b = data.bones[i] local p = b.parent local pa = (p >= 0) and acc[p + 1] or { 1.0, 1.0, 1.0 } local pu = (p >= 0) and uns[p + 1] or root local u = mul(pu, trs({ b.t[1] * pa[1], b.t[2] * pa[2], b.t[3] * pa[3] }, b.r, { 1, 1, 1 })) local a = { pa[1] * b.s[1], pa[2] * b.s[2], pa[3] * b.s[3] } local m = {} for k = 1, 16 do m[k] = u[k] end for k = 1, 4 do m[k] = m[k] * a[1] m[4 + k] = m[4 + k] * a[2] m[8 + k] = m[8 + k] * a[3] end acc[i], uns[i], mats[i] = a, u, m end local lo = { 1e9, 1e9, 1e9 } local hi = { -1e9, -1e9, -1e9 } for _, prim in ipairs(data.prims) do local pos, skin = prim.pos, prim.skin for i = 1, prim.nverts do local m = mats[skin[i] + 1] if m then local x, y, z = pos[i * 3 - 2], pos[i * 3 - 1], pos[i * 3] local wx = m[1] * x + m[5] * y + m[9] * z + m[13] local wy = m[2] * x + m[6] * y + m[10] * z + m[14] local wz = m[3] * x + m[7] * y + m[11] * z + m[15] if wx < lo[1] then lo[1] = wx end if wy < lo[2] then lo[2] = wy end if wz < lo[3] then lo[3] = wz end if wx > hi[1] then hi[1] = wx end if wy > hi[2] then hi[2] = wy end if wz > hi[3] then hi[3] = wz end end end end local extent = (lo[1] <= hi[1]) and (hi[2] - lo[2]) or 1.0 -- how much each bone scales its own local space, so an effect can divide it -- back out and come out the size it asked for wherever it hangs local scales = {} for i = 1, #mats do local m = mats[i] scales[i] = sqrt(m[1] * m[1] + m[2] * m[2] + m[3] * m[3]) end return extent, scales end -- ------- what a species gets -- Returns a list of { kind, bone, geo, w, h, frames }, or an empty list. function StadiumFx.buildFor(species, fx, extent, boneScale) local out = {} for _, node in ipairs(fx) do local cb, bone = node.callback, node.bone if bone >= 0 and bone < #boneScale then local k = boneScale[bone + 1] if k == 0 then k = 1.0 end if cb == FIRE_TAIL then local fl, fw = StadiumFx.SIZES.fire_tail[1], StadiumFx.SIZES.fire_tail[2] local w, h, fr = fireFrames(species * 7919 + 1, 32, 64, 8) out[#out + 1] = { kind = "fire", bone = bone, w = w, h = h, frames = fr, geo = crossedQuads(bone, extent * fl / k, extent * fw / k, "y", false) } elseif cb == FIRE_SMALL then local fl, fw = StadiumFx.SIZES.fire_small[1], StadiumFx.SIZES.fire_small[2] local w, h, fr = fireFrames(species * 6271 + bone, 24, 40, 8, 1.25) out[#out + 1] = { kind = "fire", bone = bone, w = w, h = h, frames = fr, geo = crossedQuads(bone, extent * fl / k, extent * fw / k, "y", false) } elseif cb == AURA and species == 92 then -- Gastly only local fl, fw = StadiumFx.SIZES.gas[1], StadiumFx.SIZES.gas[2] local w, h, fr = gasFrames(species * 5237 + 3, 48, 48, 10) out[#out + 1] = { kind = "gas", bone = bone, w = w, h = h, frames = fr, geo = crossedQuads(bone, extent * fl / k, extent * fw / k, "y", true) } end end end return out end -- Append the generated prims and their flipbook textures to a model, exactly -- as build.py's attach_effects does. Returns how many were made. function StadiumFx.attach(data, species) if not (data.fx and #data.fx > 0) then return 0 end local extent, boneScale = StadiumFx.bindExtent(data) local made = StadiumFx.buildFor(species, data.fx, extent, boneScale) for _, e in ipairs(made) do local first = #data.textures -- 0-based, as the file for i = 1, #e.frames do data.textures[first + i] = { index = -1, w = e.w, h = e.h, generated = true, rgba = e.frames[i] } end local g = e.geo local fxFrames = {} for i = 1, #e.frames do fxFrames[i] = first + i - 1 end data.prims[#data.prims + 1] = { tex = first, cull = 0, texAnim = -1, texMap = nil, generated = true, effect = e.kind, blend = (e.kind == "fire") and "add" or "alpha", fxFrames = fxFrames, pos = g.pos, uv = g.uv, nrm = g.nrm, skin = g.skin, nverts = g.nverts, idx = g.idx, nidx = g.nidx, } end return #made end return StadiumFx