diablo2-web/tests/assets/m0-assets-zero-mpq.test.ts

553 lines
21 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import zlib from 'node:zlib'
import { execSync } from 'node:child_process'
import { MISSILE_METAS } from '../../src/render/missiles-meta.ts'
import { OVERLAY_METAS } from '../../src/render/overlays-meta.ts'
import { SpriteRenderer } from '../../src/render/renderer.ts'
// Helper function to decode 32-bit RGBA PNG
function decodeRgbaPng(buf: Buffer) {
const width = buf.readUInt32BE(16)
const height = buf.readUInt32BE(20)
const bitDepth = buf[24]!
const colorType = buf[25]!
const idatParts: Buffer[] = []
let offset = 8
while (offset < buf.length) {
const len = buf.readUInt32BE(offset)
const type = buf.subarray(offset + 4, offset + 8).toString('ascii')
if (type === 'IDAT') {
idatParts.push(buf.subarray(offset + 8, offset + 8 + len))
}
offset += 12 + len
}
const idat = Buffer.concat(idatParts)
const raw = zlib.inflateSync(idat)
const bpp = 4
const rowBytes = width * bpp
const pixels = new Uint8Array(width * height * 4)
let srcAt = 0
let dstAt = 0
for (let y = 0; y < height; y++) {
const filter = raw[srcAt++]
const row = raw.subarray(srcAt, srcAt + rowBytes)
srcAt += rowBytes
const prevRow = y > 0 ? pixels.subarray((y - 1) * rowBytes, y * rowBytes) : null
const curRow = pixels.subarray(dstAt, dstAt + rowBytes)
for (let x = 0; x < rowBytes; x++) {
const left = x >= bpp ? curRow[x - bpp]! : 0
const up = prevRow ? prevRow[x]! : 0
const upLeft = prevRow && x >= bpp ? prevRow[x - bpp]! : 0
let val = row[x]!
if (filter === 0) {
// None
} else if (filter === 1) {
// Sub
val = (val + left) & 0xff
} else if (filter === 2) {
// Up
val = (val + up) & 0xff
} else if (filter === 3) {
// Average
val = (val + Math.floor((left + up) / 2)) & 0xff
} else if (filter === 4) {
// Paeth
const p = left + up - upLeft
const pa = Math.abs(p - left)
const pb = Math.abs(p - up)
const pc = Math.abs(p - upLeft)
let pr = upLeft
if (pa <= pb && pa <= pc) pr = left
else if (pb <= pc) pr = up
val = (val + pr) & 0xff
}
curRow[x] = val
}
dstAt += rowBytes
}
return { width, height, bitDepth, colorType, pixels }
}
describe('Milestone M0 — Asset Thresholding & Zero-MPQ Invariants', () => {
const publicMissilesDir = path.resolve(__dirname, '../../public/missiles')
const packMissilesDir = path.resolve(__dirname, '../../samples/d2-packs/missiles')
const publicOverlaysDir = path.resolve(__dirname, '../../public/overlays')
const packOverlaysDir = path.resolve(__dirname, '../../samples/d2-packs/overlays')
const MISSILE_NAMES = [
// 35 Base Missiles
'firebolt', 'fireball', 'fireexplode', 'icebolt', 'iceblast', 'glacialspike',
'iceexplode', 'freezeexplode', 'frozenorb', 'frozenorbexplode',
'blizzard1', 'blizzard2', 'blizzard3', 'blizzardexplode1', 'blizzardexplode2',
'chargedbolt', 'teeth', 'teethexplode', 'bonespear', 'bonespirit', 'bonespiritexplode',
'poisonnova', 'holybolt', 'blessedhammer', 'magicarrow', 'firearrow', 'icearrow',
'arrow', 'lightningjavelin', 'meteor', 'meteortail', 'meteorcenter', 'meteorexplode',
'meteorfire', 'firesmall',
// 9 New M0 Missiles
'nova', 'lightningstrike', 'thunderstorm1', 'sparkle', 'freezingarrowexp2',
'firewall', 'infernoflame1', 'infernoflame2', 'frostnova',
// Aliases
'blizzard4', 'blizzardexplode3', 'meteortargetindicator', 'firemedium',
'electricnova', 'chainlightning', 'lightninghit', 'thunderstorm', 'lightningboltbig',
'gleam', 'freezeexplodeejecta', 'glacialspikeejecta', 'blaze', 'firewallmaker',
'groundfirebig', 'flamethrower', 'flamethrower2', 'coldunique', 'mephfrostnova',
]
const OVERLAY_NAMES = [
// 8 Cast Overlays
'fire_cast_1', 'fire_cast_2', 'ice_cast_1', 'ice_cast_2', 'ice_cast_3',
'light_cast_1', 'light_cast_2', 'teleport',
// 9 New M0 Overlays
'lightning', 'frozenarmor', 'frozenarmor_hit', 'energyshield', 'energyshieldhit0',
'thunderstormcast', 'thunderstormback', 'enchant', 'ice_explode',
]
describe('Suite 1: Asset File Existence & Mirroring Invariants', () => {
it('1.1 Verifies all missile PNG and JSON files exist in public/ and samples/d2-packs/', () => {
for (const name of MISSILE_NAMES) {
for (const dir of [publicMissilesDir, packMissilesDir]) {
const pngPath = path.join(dir, `${name}.png`)
const jsonPath = path.join(dir, `${name}.json`)
expect(fs.existsSync(pngPath), `Missing missile PNG: ${pngPath}`).toBe(true)
expect(fs.existsSync(jsonPath), `Missing missile JSON: ${jsonPath}`).toBe(true)
const stat = fs.statSync(pngPath)
expect(stat.size).toBeGreaterThan(1000)
const buf = fs.readFileSync(pngPath)
expect(buf[0]).toBe(0x89)
expect(buf[1]).toBe(0x50) // 'P'
expect(buf[2]).toBe(0x4e) // 'N'
expect(buf[3]).toBe(0x47) // 'G'
}
}
})
it('1.2 Verifies all overlay PNG and JSON files exist in public/ and samples/d2-packs/', () => {
for (const name of OVERLAY_NAMES) {
for (const dir of [publicOverlaysDir, packOverlaysDir]) {
const pngPath = path.join(dir, `${name}.png`)
const jsonPath = path.join(dir, `${name}.json`)
expect(fs.existsSync(pngPath), `Missing overlay PNG: ${pngPath}`).toBe(true)
expect(fs.existsSync(jsonPath), `Missing overlay JSON: ${jsonPath}`).toBe(true)
const stat = fs.statSync(pngPath)
expect(stat.size).toBeGreaterThan(1000)
const buf = fs.readFileSync(pngPath)
expect(buf[0]).toBe(0x89)
expect(buf[1]).toBe(0x50)
expect(buf[2]).toBe(0x4e)
expect(buf[3]).toBe(0x47)
}
}
})
})
describe('Suite 2: Metadata Registry & Schema Typing Invariants', () => {
it('2.1 Verifies MISSILE_METAS contains complete, valid entries for all missiles', () => {
for (const name of MISSILE_NAMES) {
const meta = MISSILE_METAS[name]
expect(meta, `MISSILE_METAS missing entry: ${name}`).toBeDefined()
expect(meta!.name).toBe(name)
expect(meta!.celFile.length).toBeGreaterThan(0)
expect(meta!.width).toBeGreaterThan(0)
expect(meta!.height).toBeGreaterThan(0)
expect(meta!.directions).toBeGreaterThan(0)
expect(meta!.framesPerDirection).toBeGreaterThan(0)
expect(meta!.animSpeed).toBeGreaterThan(0)
expect(meta!.groups.length).toBe(meta!.directions)
for (const group of meta!.groups) {
expect(group.length).toBe(meta!.framesPerDirection)
for (const [x, y, w, h, ax, ay] of group) {
expect(x).toBeGreaterThanOrEqual(0)
expect(y).toBeGreaterThanOrEqual(0)
expect(x + w).toBeLessThanOrEqual(meta!.width)
expect(y + h).toBeLessThanOrEqual(meta!.height)
expect(w).toBeGreaterThan(0)
expect(h).toBeGreaterThan(0)
expect(typeof ax).toBe('number')
expect(typeof ay).toBe('number')
}
}
}
})
it('2.2 Verifies OVERLAY_METAS contains complete, valid entries for all overlays', () => {
for (const name of OVERLAY_NAMES) {
const meta = OVERLAY_METAS[name]
expect(meta, `OVERLAY_METAS missing entry: ${name}`).toBeDefined()
expect(meta!.name).toBe(name)
expect(meta!.celFile.length).toBeGreaterThan(0)
expect(meta!.width).toBeGreaterThan(0)
expect(meta!.height).toBeGreaterThan(0)
expect(meta!.directions).toBeGreaterThan(0)
expect(meta!.frames).toBeGreaterThan(0)
expect(meta!.framesPerDirection).toBeGreaterThan(0)
expect(meta!.animRate).toBeGreaterThan(0)
expect(meta!.groups.length).toBe(meta!.directions)
expect(meta!.groups[0]?.length).toBe(meta!.frames)
}
})
it('2.3 Verifies CelFile casing lookups for MISSILE_METAS (e.g. ElectricNova !== undefined)', () => {
const CEL_CASING_CHECKS = [
// 9 New M0 Missiles & Aliases
'ElectricNova',
'LightningStrike',
'LightningboltBig',
'Gleam',
'FreezeExplodeEjecta',
'groundFireBig',
'Flamethrower',
'Flamethrower2',
'FrostNova',
// Primary Class Missiles
'ChargedBolt',
'Firebolt',
'Fireball',
'FireArrowExplode2',
'Icebolt',
'IceBlast',
'GlacialSpike',
'IceOrb',
'Blizzard',
'BoneSpear',
'BoneSpirit',
'HolyBoltMissile',
'SafeArrow',
]
for (const celFile of CEL_CASING_CHECKS) {
const meta = MISSILE_METAS[celFile]
expect(meta, `MISSILE_METAS['${celFile}'] must not be undefined`).toBeDefined()
expect(meta!.width, `${celFile} width > 0`).toBeGreaterThan(0)
expect(meta!.height, `${celFile} height > 0`).toBeGreaterThan(0)
expect(meta!.groups.length, `${celFile} groups > 0`).toBeGreaterThan(0)
// Also assert lowercased variant resolves
const lowerMeta = MISSILE_METAS[celFile.toLowerCase()]
expect(lowerMeta, `MISSILE_METAS['${celFile.toLowerCase()}'] must not be undefined`).toBeDefined()
}
})
it('2.4 Verifies CelFile casing lookups for OVERLAY_METAS', () => {
const OVERLAY_CEL_CHECKS = [
'ShockHitSm',
'FrozenArmor',
'AuraResistColdCast',
'ManaShieldBall',
'ManaShieldDir00',
'ThunderstormCast',
'ThunderstormCast_operate',
'FireEnchant',
'IceExplode',
'FireCast_for_Sorceress',
'FireCast2',
'IceCastNew01',
'LightningCast',
'Teleport',
]
for (const celFile of OVERLAY_CEL_CHECKS) {
const meta = OVERLAY_METAS[celFile]
expect(meta, `OVERLAY_METAS['${celFile}'] must not be undefined`).toBeDefined()
expect(meta!.width, `${celFile} width > 0`).toBeGreaterThan(0)
expect(meta!.height, `${celFile} height > 0`).toBeGreaterThan(0)
expect(meta!.frames, `${celFile} frames > 0`).toBeGreaterThan(0)
const lowerMeta = OVERLAY_METAS[celFile.toLowerCase()]
expect(lowerMeta, `OVERLAY_METAS['${celFile.toLowerCase()}'] must not be undefined`).toBeDefined()
}
})
})
describe('Suite 3: 32-bit RGBA Decoding, Boundary & Zero Black Border Thresholding Invariants', () => {
const MAGICAL_MISSILES = [
'nova', 'lightningstrike', 'thunderstorm1', 'sparkle', 'freezingarrowexp2',
'firewall', 'infernoflame1', 'infernoflame2', 'frostnova',
'firebolt', 'fireball', 'icebolt', 'chargedbolt',
]
it('3.1 Verifies magical missiles have 32-bit RGBA color type and zero opaque black pixels', () => {
for (const name of MAGICAL_MISSILES) {
const pngPath = path.join(publicMissilesDir, `${name}.png`)
const buf = fs.readFileSync(pngPath)
const decoded = decodeRgbaPng(buf)
expect(decoded.colorType).toBe(6) // 32-bit RGBA
expect(decoded.bitDepth).toBe(8)
let blackOpaqueCount = 0
let visibleSpellCount = 0
let edgeGradientCount = 0
for (let i = 0; i < decoded.pixels.length; i += 4) {
const r = decoded.pixels[i]!
const g = decoded.pixels[i + 1]!
const b = decoded.pixels[i + 2]!
const a = decoded.pixels[i + 3]!
const m = Math.max(r, g, b)
if (m <= 4 && a > 0) {
blackOpaqueCount++
}
if (m >= 30 && a > 0) {
visibleSpellCount++
}
if (m > 4 && m < 60 && a > 0 && a < 255) {
edgeGradientCount++
}
}
expect(blackOpaqueCount, `${name}.png contains ${blackOpaqueCount} opaque black pixels!`).toBe(0)
expect(visibleSpellCount, `${name}.png must contain visible core spell pixels`).toBeGreaterThan(0)
expect(edgeGradientCount, `${name}.png must contain smoothed edge gradient pixels`).toBeGreaterThan(0)
}
})
it('3.2 Verifies physical arrow preserves opaque black outline pixels (trans: 0)', () => {
const arrowPath = path.join(publicMissilesDir, 'arrow.png')
const buf = fs.readFileSync(arrowPath)
const decoded = decodeRgbaPng(buf)
expect(decoded.colorType).toBe(6)
expect(decoded.bitDepth).toBe(8)
let blackOpaqueCount = 0
for (let i = 0; i < decoded.pixels.length; i += 4) {
const r = decoded.pixels[i]!
const g = decoded.pixels[i + 1]!
const b = decoded.pixels[i + 2]!
const a = decoded.pixels[i + 3]!
const m = Math.max(r, g, b)
if (m <= 4 && a === 255) {
blackOpaqueCount++
}
}
// arrow has physical black outline pixels per trans: 0
expect(blackOpaqueCount, 'arrow.png must retain physical black outline pixels').toBeGreaterThan(0)
})
})
describe('Suite 4: Zero Runtime MPQ Invariant (Static Code Scan)', () => {
it('4.1 Verifies metadata modules contain zero MPQ, DLL or fs imports', () => {
const missilesMetaPath = path.resolve(__dirname, '../../src/render/missiles-meta.ts')
const overlaysMetaPath = path.resolve(__dirname, '../../src/render/overlays-meta.ts')
for (const p of [missilesMetaPath, overlaysMetaPath]) {
const content = fs.readFileSync(p, 'utf-8')
expect(content).not.toContain('node:fs')
expect(content).not.toContain('src/mpq')
expect(content).not.toContain('.mpq')
expect(content).not.toContain('.dll')
}
})
it('4.2 Verifies core missile engine contains zero MPQ imports', () => {
const enginePath = path.resolve(__dirname, '../../src/game/engine/missile-engine.ts')
if (fs.existsSync(enginePath)) {
const content = fs.readFileSync(enginePath, 'utf-8')
expect(content).not.toContain('src/mpq')
expect(content).not.toContain('.mpq')
}
})
it('4.3 Verifies public/ assets are self-contained JSON and PNG with no external MPQ dependencies', () => {
for (const name of ['nova', 'lightning', 'firewall', 'energyshield']) {
const jsonPath = fs.existsSync(path.join(publicMissilesDir, `${name}.json`))
? path.join(publicMissilesDir, `${name}.json`)
: path.join(publicOverlaysDir, `${name}.json`)
const content = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
expect(content.name).toBe(name)
expect(content.groups.length).toBeGreaterThan(0)
}
})
it('4.4 Verifies package.json scripts contain pack:missiles and pack:overlays', () => {
const pkgPath = path.resolve(__dirname, '../../package.json')
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
expect(pkg.scripts, 'package.json missing scripts').toBeDefined()
expect(pkg.scripts['pack:missiles'], 'package.json missing pack:missiles').toBe('tsx scripts/pack-missiles.ts')
expect(pkg.scripts['pack:overlays'], 'package.json missing pack:overlays').toBe('tsx scripts/pack-overlays.ts')
// Verify physical script files exist on disk
const missilesScript = path.resolve(__dirname, '../../scripts/pack-missiles.ts')
const overlaysScript = path.resolve(__dirname, '../../scripts/pack-overlays.ts')
expect(fs.existsSync(missilesScript), 'scripts/pack-missiles.ts missing').toBe(true)
expect(fs.existsSync(overlaysScript), 'scripts/pack-overlays.ts missing').toBe(true)
})
it('4.5 Verifies npm run pack:overlays executes cleanly with zero exit code', () => {
const projectRoot = path.resolve(__dirname, '../..')
const output = execSync('npm run pack:overlays', {
cwd: projectRoot,
encoding: 'utf-8',
timeout: 30000,
})
expect(output).toContain('[Overlay Packer] Baked')
expect(output).toContain('overlays-meta.ts')
})
})
describe('Suite 5: WebGL Additive Blending & Renderer State Isolation', () => {
function createMockRenderer() {
let currentBlendMode = 'normal'
let flushCount = 0
let blendFuncCalls: [number, number][] = []
const gl: any = {
FLOAT: 5126,
UNSIGNED_SHORT: 5123,
TRIANGLES: 4,
ARRAY_BUFFER: 34962,
ELEMENT_ARRAY_BUFFER: 34963,
STATIC_DRAW: 35044,
DYNAMIC_DRAW: 35048,
RGBA: 6408,
UNSIGNED_BYTE: 5121,
TEXTURE_2D: 3553,
LINEAR: 9729,
TEXTURE_MIN_FILTER: 10241,
TEXTURE_MAG_FILTER: 10240,
TEXTURE_WRAP_S: 10242,
TEXTURE_WRAP_T: 10243,
CLAMP_TO_EDGE: 33071,
NEAREST: 9728,
UNPACK_ALIGNMENT: 3317,
BLEND: 3042,
SRC_ALPHA: 770,
ONE: 1,
ONE_MINUS_SRC_ALPHA: 771,
DEPTH_TEST: 2929,
COLOR_BUFFER_BIT: 16384,
TEXTURE0: 33984,
drawingBufferWidth: 800,
drawingBufferHeight: 600,
isContextLost: () => false,
createProgram: () => ({ id: 1, type: 'program' }),
deleteProgram: () => {},
attachShader: () => {},
detachShader: () => {},
linkProgram: () => {},
getProgramParameter: () => true,
getProgramInfoLog: () => '',
useProgram: () => {},
createShader: (type: number) => ({ id: 1, type }),
shaderSource: () => {},
compileShader: () => {},
getShaderParameter: () => true,
getShaderInfoLog: () => '',
deleteShader: () => {},
createVertexArray: () => ({ id: 1, type: 'vao' }),
deleteVertexArray: () => {},
bindVertexArray: () => {},
createBuffer: () => ({ id: 1, type: 'buffer' }),
deleteBuffer: () => {},
bindBuffer: () => {},
bufferData: () => {},
bufferSubData: () => {},
getAttribLocation: () => 0,
enableVertexAttribArray: () => {},
vertexAttribPointer: () => {},
createTexture: () => ({ id: 1, type: 'texture' }),
deleteTexture: () => {},
bindTexture: () => {},
texParameteri: () => {},
pixelStorei: () => {},
texImage2D: () => {},
getParameter: (param: number) => (param === 3379 ? 4096 : param === 34930 ? 16 : 0),
getUniformLocation: (_p: any, name: string) => ({ name }),
uniform2f: () => {},
uniform1f: () => {},
uniform1i: () => {},
uniform1iv: () => {},
enable: () => {},
disable: () => {},
blendFunc: (s: number, d: number) => {
blendFuncCalls.push([s, d])
},
viewport: () => {},
clearColor: () => {},
clear: () => {},
activeTexture: () => {},
drawArrays: () => {},
drawElements: () => {},
}
const canvas: any = {
width: 800,
height: 600,
clientWidth: 800,
clientHeight: 600,
getContext: (type: string) => (type === 'webgl2' ? gl : null),
addEventListener: () => {},
removeEventListener: () => {},
}
const renderer = new SpriteRenderer(canvas as HTMLCanvasElement)
const origFlush = renderer.flush.bind(renderer)
renderer.flush = () => {
flushCount++
origFlush()
}
return { renderer, gl, getBlendCalls: () => blendFuncCalls, getFlushCount: () => flushCount }
}
it('5.1 Verifies setBlendMode switches blendFunc between additive and normal with batch flush', () => {
const { renderer, gl, getBlendCalls, getFlushCount } = createMockRenderer()
// Default is 'normal'
expect(renderer.blendMode).toBe('normal')
// Switch to 'additive'
renderer.setBlendMode('additive')
expect(renderer.blendMode).toBe('additive')
const callsAfterAdd = getBlendCalls()
expect(callsAfterAdd.length).toBeGreaterThan(0)
const lastAddCall = callsAfterAdd[callsAfterAdd.length - 1]!
expect(lastAddCall[0]).toBe(gl.SRC_ALPHA)
expect(lastAddCall[1]).toBe(gl.ONE)
expect(getFlushCount()).toBeGreaterThan(0)
// Redundant setBlendMode('additive') must not flush again or re-invoke blendFunc
const flushesBeforeNoop = getFlushCount()
const callsBeforeNoop = callsAfterAdd.length
renderer.setBlendMode('additive')
expect(getFlushCount()).toBe(flushesBeforeNoop)
expect(getBlendCalls().length).toBe(callsBeforeNoop)
// Switch back to 'normal'
renderer.setBlendMode('normal')
expect(renderer.blendMode).toBe('normal')
const callsAfterNorm = getBlendCalls()
const lastNormCall = callsAfterNorm[callsAfterNorm.length - 1]!
expect(lastNormCall[0]).toBe(gl.SRC_ALPHA)
expect(lastNormCall[1]).toBe(gl.ONE_MINUS_SRC_ALPHA)
expect(getFlushCount()).toBeGreaterThan(flushesBeforeNoop)
})
it('5.2 Verifies renderer disposal safely inhibits blendMode state mutations', () => {
const { renderer, getBlendCalls } = createMockRenderer()
renderer.setBlendMode('additive')
const callsCount = getBlendCalls().length
renderer.dispose()
expect(renderer.isDisposed).toBe(true)
// Calls after disposal must safely no-op
renderer.setBlendMode('normal')
expect(getBlendCalls().length).toBe(callsCount)
})
})
})