/** * scripts/pack-canonical-drop-data.ts * * Offline extraction & packaging script for Diablo II v1.13c canonical drop tables: * - MonStats.txt: Extracts 19 essential drop & level columns across 3 difficulties * - SuperUniques.txt: Extracts 21 columns including multi-difficulty TC columns (TC, TC(N), TC(H)) * * Aligns offline extraction with runtime contracts in `src/data/canonical-drop-data.ts` * and `src/game/embedded-drop-tables.ts` per Diablo II 1.13c ground truth parity rules. */ import { existsSync } from 'node:fs' import { join } from 'node:path' import { MpqArchive } from '../src/mpq/archive.ts' import { fileSource } from '../src/mpq/file-source.ts' import { MountedArchives } from '../src/mpq/mount.ts' import { parseTable, cell } from '../src/game/acts.ts' import { readMonsterKinds, readSuperUniques } from '../src/game/monsters.ts' import { RAW_MONSTATS, RAW_SUPERUNIQUES } from '../src/data/canonical-drop-data.ts' export const MONSTATS_DROP_COLUMNS = [ 'Id', 'BaseId', 'NameStr', 'boss', 'noRatio', 'Level', 'Level(N)', 'Level(H)', 'TreasureClass1', 'TreasureClass2', 'TreasureClass3', 'TreasureClass4', 'TreasureClass1(N)', 'TreasureClass2(N)', 'TreasureClass3(N)', 'TreasureClass4(N)', 'TreasureClass1(H)', 'TreasureClass2(H)', 'TreasureClass3(H)', 'TreasureClass4(H)', ] as const export const SUPERUNIQUES_DROP_COLUMNS = [ 'Superunique', 'Name', 'Class', 'hcIdx', 'MonSound', 'Mod1', 'Mod2', 'Mod3', 'MinGrp', 'MaxGrp', 'EClass', 'AutoPos', 'Stacks', 'Replaceable', 'Utrans', 'Utrans(N)', 'Utrans(H)', 'TC', 'TC(N)', 'TC(H)', '*eol', ] as const /** * Mounts standard 1.13c MPQ archives for table extraction. */ export async function openDropDataArchives(baseDir = 'samples/d2'): Promise { const archives = new MountedArchives() for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { const fullPath = join(baseDir, name) if (!existsSync(fullPath)) { throw new Error(`Required MPQ archive not found: ${fullPath}`) } archives.add(name, await MpqArchive.open(await fileSource(fullPath))) } return archives } /** * Extracts canonical MonStats drop columns into TSV format matching RAW_MONSTATS. */ export async function extractCanonicalMonStats(archives: MountedArchives): Promise { const bytes = await archives.read('data\\global\\excel\\monstats.txt') const table = parseTable(bytes) let tsv = MONSTATS_DROP_COLUMNS.join('\t') + '\r\n' for (const row of table.rows) { const values = MONSTATS_DROP_COLUMNS.map(col => cell(table, row, col)) tsv += values.join('\t') + '\r\n' } return tsv } /** * Extracts canonical SuperUniques columns into TSV format matching RAW_SUPERUNIQUES. */ export async function extractCanonicalSuperUniques(archives: MountedArchives): Promise { const bytes = await archives.read('data\\global\\excel\\SuperUniques.txt') const table = parseTable(bytes) let tsv = SUPERUNIQUES_DROP_COLUMNS.join('\t') + '\r\n' for (const row of table.rows) { const values = SUPERUNIQUES_DROP_COLUMNS.map(col => cell(table, row, col)) tsv += values.join('\t') + '\r\n' } return tsv } /** * Packs extracted canonical drop data into src/data/canonical-drop-data.ts */ export async function packCanonicalDropData(options: { baseDir?: string targetFile?: string } = {}): Promise<{ monstatsBytes: number; superUniquesBytes: number }> { const { readFileSync, writeFileSync } = await import('node:fs') const baseDir = options.baseDir ?? 'samples/d2' const targetFile = options.targetFile ?? join(process.cwd(), 'src/data/canonical-drop-data.ts') const archives = await openDropDataArchives(baseDir) const monstatsTsv = await extractCanonicalMonStats(archives) const superUniquesTsv = await extractCanonicalSuperUniques(archives) let content = readFileSync(targetFile, 'utf-8') // Update RAW_MONSTATS export const monstatsPattern = /export const RAW_MONSTATS: string = "[\s\S]*?"\r?\n/ const newMonstatsExport = `export const RAW_MONSTATS: string = ${JSON.stringify(monstatsTsv)}\r\n` if (monstatsPattern.test(content)) { content = content.replace(monstatsPattern, newMonstatsExport) } else { content += `\r\n${newMonstatsExport}` } // Update RAW_SUPERUNIQUES export const suPattern = /export const RAW_SUPERUNIQUES: string = "[\s\S]*?"\r?\n/ const newSuExport = `export const RAW_SUPERUNIQUES: string = ${JSON.stringify(superUniquesTsv)}\r\n` if (suPattern.test(content)) { content = content.replace(suPattern, newSuExport) } else { content += `\r\n${newSuExport}` } writeFileSync(targetFile, content, 'utf-8') return { monstatsBytes: Buffer.byteLength(monstatsTsv, 'utf-8'), superUniquesBytes: Buffer.byteLength(superUniquesTsv, 'utf-8'), } } /** * Audits 1.13c ground truth parity between MPQ archives and embedded constants. */ export async function auditDropParity( baseDir = 'samples/d2', targetFile = join(process.cwd(), 'src/data/canonical-drop-data.ts'), ): Promise<{ monstatsMatch: boolean superUniquesMatch: boolean monsterKindsCount: number superUniquesCount: number }> { const archives = await openDropDataArchives(baseDir) const extractedMon = await extractCanonicalMonStats(archives) const extractedSu = await extractCanonicalSuperUniques(archives) const { readFileSync } = await import('node:fs') let currentMon = RAW_MONSTATS let currentSu = RAW_SUPERUNIQUES try { const content = readFileSync(targetFile, 'utf-8') const monMatch = content.match(/export const RAW_MONSTATS: string = ("[\s\S]*?")\r?\n/) if (monMatch) currentMon = JSON.parse(monMatch[1]!) const suMatch = content.match(/export const RAW_SUPERUNIQUES: string = ("[\s\S]*?")\r?\n/) if (suMatch) currentSu = JSON.parse(suMatch[1]!) } catch { // fallback to statically imported constants } const monstatsMatch = extractedMon === currentMon const superUniquesMatch = extractedSu === currentSu const enc = new TextEncoder() const monKinds = readMonsterKinds(parseTable(enc.encode(currentMon))) const suList = readSuperUniques(parseTable(enc.encode(currentSu))) return { monstatsMatch, superUniquesMatch, monsterKindsCount: monKinds.size, superUniquesCount: suList.length, } } if (process.argv[1] && process.argv[1].endsWith('pack-canonical-drop-data.ts')) { const shouldPack = process.argv.includes('--pack') || process.argv.includes('--write') const run = async () => { if (shouldPack) { console.log('Packing canonical drop data into src/data/canonical-drop-data.ts...') const res = await packCanonicalDropData() console.log(`Packed MonStats (${res.monstatsBytes} bytes) and SuperUniques (${res.superUniquesBytes} bytes).`) } const result = await auditDropParity() console.log('=== Diablo II v1.13c Drop Data Parity Audit ===') console.log(`MonStats extraction matches RAW_MONSTATS: ${result.monstatsMatch ? 'PASS' : 'FAIL'}`) console.log(`SuperUniques extraction matches RAW_SUPERUNIQUES: ${result.superUniquesMatch ? 'PASS' : 'FAIL'}`) console.log(`Monster kinds hydrated: ${result.monsterKindsCount} (expected: 734)`) console.log(`SuperUniques hydrated: ${result.superUniquesCount} (expected: 66)`) if (!result.monstatsMatch || !result.superUniquesMatch || result.monsterKindsCount !== 734 || result.superUniquesCount !== 66) { process.exit(1) } console.log('All drop data contracts verified with 100% 1.13c parity.') } run().catch(err => { console.error('Operation failed:', err) process.exit(1) }) }