/** * @file tests/shadow-rendering.test.ts * * Tests for the Shadow Layer (DT1 Type 13) rendering and placement parity with * Diablo II v1.13c. * * Ground Truth Invariants: * 1. DT1 tiles with `type === 13` are shadow tiles. * 2. Shadows are ground-aligned: screen anchor uses (orthoX - 80, orthoY) identical * to floor tiles, without wall vertical block adjustment (`minBlockY + 80`). * 3. `IsoMapScene` exposes `shadows: readonly IsoDraw[]`, sorted by isometric depth. * 4. Sub-tile collision flags of shadow layers are unioned into `collisionMasks` and `blocked`. * 5. Real Act 1 presets (Ruin2.ds1) carry shadow tiles that resolve to DT1 type 13. */ import { describe, it, expect } from 'vitest' import { buildIsoMapScene, ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, } from '../src/game/d2map.ts' import type { Ds1 } from '../src/formats/ds1.ts' import { MpqArchive } from '../src/mpq/archive.ts' import { MountedArchives } from '../src/mpq/mount.ts' import { fileSource } from '../src/mpq/file-source.ts' import { decodeDs1 } from '../src/formats/ds1.ts' import { decodeDt1, subTileFlagsOf, type Dt1, type Dt1Tile } from '../src/formats/dt1.ts' import { loadActTables, resolveLevelLibraries } from '../src/game/acts.ts' import { existsSync } from 'node:fs' describe('Shadow Layer (DT1 Type 13) Rendering & Parity', () => { const makeFloorTile = (style = 1, sequence = 1): Dt1Tile => ({ direction: 1, roofHeight: 0, materialFlags: 0, animated: 0, height: -80, width: 160, type: 0, // floor style, sequence, rarityFrameIndex: 0, subTileFlags: Array.from({ length: 25 }, () => subTileFlagsOf(0)), blocks: [], minBlockY: 0, bitmapHeight: 80, }) const makeShadowTile = (style = 2, sequence = 1, animated = 0, blockWalk = false): Dt1Tile => ({ direction: 1, roofHeight: 0, materialFlags: 0, animated, height: -80, width: 160, type: 13, // shadow style, sequence, rarityFrameIndex: 0, subTileFlags: Array.from({ length: 25 }, (_, i) => subTileFlagsOf(blockWalk && i === 12 ? 0x0001 : 0), ), blocks: [], minBlockY: 0, bitmapHeight: 80, }) it('buildIsoMapScene parses and outputs shadows array with exact type 13', () => { const floorTile = makeFloorTile(1, 1) const shadowTile = makeShadowTile(2, 1) const dt1: Dt1 = { versionMajor: 7, versionMinor: 6, tiles: [floorTile, shadowTile], warnings: [], } const ds1: Ds1 = { version: 18, width: 2, height: 2, act: 1, substitutionType: 0, wallLayers: 1, floorLayers: 1, cells: [ [ { floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [{ style: 2, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], substitutions: [], }, { floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [], substitutions: [], }, ], [ { floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [], substitutions: [], }, { floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [{ style: 2, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], substitutions: [], }, ], ], objects: [], npcPathOffset: null, } const scene = buildIsoMapScene(ds1, [dt1]) expect(scene.shadows).toBeDefined() expect(scene.shadows.length).toBe(2) // Check first shadow draw at cell(0, 0) const s0 = scene.shadows[0]! expect(s0.cellX).toBe(0) expect(s0.cellY).toBe(0) expect(s0.x).toBe(scene.originX - 80) expect(s0.y).toBeCloseTo(scene.originY) // Check second shadow draw at cell(1, 1) const s1 = scene.shadows[1]! expect(s1.cellX).toBe(1) expect(s1.cellY).toBe(1) expect(s1.x).toBe(scene.originX - 80) expect(s1.y).toBeCloseTo(scene.originY + 2 * ORTHO_CELL_HEIGHT) // Both must point to the shadow tile (index 1) expect(s0.tile).toBe(1) expect(s1.tile).toBe(1) }) it('shadows are ground-anchored and do NOT inherit wall minBlockY offset', () => { // If a shadow tile has minBlockY = -40 (like some trees), it must STILL be drawn // on the floor ground plane (draw.y === originY + (cx + cy) * 40). const floorTile = makeFloorTile(1, 1) const shadowTile: Dt1Tile = { ...makeShadowTile(2, 1), minBlockY: -64, } const dt1: Dt1 = { versionMajor: 7, versionMinor: 6, tiles: [floorTile, shadowTile], warnings: [], } const ds1: Ds1 = { version: 18, width: 1, height: 1, act: 1, substitutionType: 0, wallLayers: 1, floorLayers: 1, cells: [ [ { floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [{ style: 2, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], substitutions: [], }, ], ], objects: [], npcPathOffset: null, } const scene = buildIsoMapScene(ds1, [dt1]) const floor = scene.floors[0]! const shadow = scene.shadows[0]! // Floor and shadow must have identical screen origins expect(shadow.x).toBe(floor.x) expect(shadow.y).toBe(floor.y) expect(shadow.y - scene.originY).toBe(0) }) it('shadows fold blocking flags into collisionMasks and blocked array', () => { const floorTile = makeFloorTile(1, 1) const shadowTile = makeShadowTile(2, 1, 0, true) // sub-tile 12 blocks walk const dt1: Dt1 = { versionMajor: 7, versionMinor: 6, tiles: [floorTile, shadowTile], warnings: [], } const ds1: Ds1 = { version: 18, width: 1, height: 1, act: 1, substitutionType: 0, wallLayers: 1, floorLayers: 1, cells: [ [ { floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [{ style: 2, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], substitutions: [], }, ], ], objects: [], npcPathOffset: null, } const scene = buildIsoMapScene(ds1, [dt1]) expect(scene.shadowBlockedSubtiles).toBe(1) expect(scene.blocked[12]).toBe(1) expect(scene.collisionMasks[12]! & 0x0001).toBe(0x0001) }) it('shadows sort by isometric depth order (cellX + cellY)', () => { const floorTile = makeFloorTile(1, 1) const shadowTile = makeShadowTile(2, 1) const dt1: Dt1 = { versionMajor: 7, versionMinor: 6, tiles: [floorTile, shadowTile], warnings: [], } const ds1: Ds1 = { version: 18, width: 3, height: 3, act: 1, substitutionType: 0, wallLayers: 1, floorLayers: 1, cells: [ [ { floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [{ style: 2, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], substitutions: [] }, { floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [], substitutions: [] }, { floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [{ style: 2, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], substitutions: [] }, ], [ { floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [], substitutions: [] }, { floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [{ style: 2, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], substitutions: [] }, { floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [], substitutions: [] }, ], [ { floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [{ style: 2, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], substitutions: [] }, { floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [], substitutions: [] }, { floors: [{ style: 1, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], walls: [], shadows: [{ style: 2, sequence: 1, prop1: 1, hidden: false, unknown1: 0, unknown2: 0 }], substitutions: [] }, ], ], objects: [], npcPathOffset: null, } const scene = buildIsoMapScene(ds1, [dt1]) expect(scene.shadows.length).toBe(5) for (let i = 0; i < scene.shadows.length - 1; i += 1) { const a = scene.shadows[i]! const b = scene.shadows[i + 1]! const depthA = a.cellX + a.cellY const depthB = b.cellX + b.cellY expect(depthA <= depthB).toBe(true) } }) const mpqDir = existsSync('/usr/local/google/home/taodao/d2-data/d2data.mpq') ? '/usr/local/google/home/taodao/d2-data' : existsSync('samples/d2/d2data.mpq') ? 'samples/d2' : null const hasMpq = mpqDir !== null it.skipIf(!hasMpq)('real MPQ: verifies shadow resolution and DT1 Type 13 in Act 1 Trees & Ruin', async () => { const archives = new MountedArchives() for (const name of ['d2exp.mpq', 'd2data.mpq', 'Patch_D2.mpq']) { try { archives.add(name, await MpqArchive.open(await fileSource(`${mpqDir}/${name}`))) } catch {} } const tables = await loadActTables(archives) const { dt1Names } = resolveLevelLibraries(tables, 2) const libraries: Dt1[] = [] for (const name of dt1Names) { try { libraries.push(decodeDt1(await archives.read(name))) } catch {} } expect(libraries.length).toBeGreaterThan(0) // Load Ruin2.ds1 (contains ruins and shadow layer) const ruinBuf = await archives.read('data/global/tiles/ACT1/OUTDOORS/Ruin2.ds1') const ds1 = decodeDs1(ruinBuf) const scene = buildIsoMapScene(ds1, libraries, 42) // Verify shadows were found and extracted expect(scene.shadows.length).toBeGreaterThan(0) for (const shadowDraw of scene.shadows) { const tile = libraries[shadowDraw.library]!.tiles[shadowDraw.tile]! // Must be DT1 Type 13 expect(tile.type).toBe(13) // Must be on the floor isometric plane const expectedY = scene.originY + (shadowDraw.cellX + shadowDraw.cellY) * ORTHO_CELL_HEIGHT expect(shadowDraw.y).toBe(expectedY) const expectedX = scene.originX + (shadowDraw.cellX - shadowDraw.cellY) * ORTHO_CELL_WIDTH - 80 expect(shadowDraw.x).toBe(expectedX) } }, 30000) })