#!/usr/bin/env node import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; import { mkdir, open, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { inflateSync } from "node:zlib"; import sharp from "sharp"; const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(scriptDirectory, "../.."); const sourceRoot = path.resolve(process.env.RUNEWAKER_ROOT ?? path.join(projectRoot, "..", "Runewaker")); const resourceRoot = path.join(sourceRoot, "Resource"); const dataRoot = path.join(resourceRoot, "data"); const files = { learnMagic: path.join(dataRoot, "learnmagic.db"), magicCollect: path.join(dataRoot, "magiccollectobject.db"), magicObject: path.join(dataRoot, "magicobject.db"), imageObject: path.join(dataRoot, "imageobject.db"), vocationTable: path.join(dataRoot, "voctable.db"), strings: path.join(dataRoot, "string_enus.db"), eliteLua: path.join(resourceRoot, "luascript", "02248.lua"), roleAttr: path.join(sourceRoot, "SourceCodeVS2019", "Libraries", "Union", "RoleData", "RoleAttr.cpp"), objectStruct: path.join(sourceRoot, "SourceCodeVS2019", "Libraries", "Union", "RoleData", "ObjectStruct.h"), }; const outputFile = path.join(projectRoot, "src", "game", "romPlayerCatalog.generated.json"); const auditFile = path.join(scriptDirectory, "snapshots", "runewaker-player-skill-audit.json"); const interfaceFdb = path.join(resourceRoot, "fdb", "interface.fdb"); const iconOutputDirectory = path.join(projectRoot, "public", "assets", "ui", "rom-spells"); const CLASS_IDS = ["rom-warrior", "rom-scout", "rom-rogue", "rom-mage", "rom-priest", "rom-knight", "rom-warden", "rom-druid", "rom-warlock", "rom-champion"]; const CLASS_NAMES = ["Warrior", "Scout", "Rogue", "Mage", "Priest", "Knight", "Warden", "Druid", "Warlock", "Champion"]; const RESOURCES = ["rage", "focus", "energy", "mana", "mana", "mana", "mana", "mana", "focus", "rage"]; const ELITE_LEVELS = [15, 20, 25, 30, 35, 40, 45, 50, 60, 70]; const RACE_MATRIX = { "rom-human": [1, 2, 3, 4, 5, 6], "rom-elf": [1, 2, 3, 4, 7, 8], "rom-dwarf": [1, 3, 4, 5, 9, 10], }; function vocationGrowth(classIndex) { const recordSize = 816; const offset = DATA_START + classIndex * recordSize + 8; const values = Array.from({ length: 21 }, (_, index) => cleanFloat(float32(buffers.vocationTable, offset + index * 4))); const keys = ["strength", "stamina", "intellect", "wisdom", "agility", "health", "mana"]; return Object.fromEntries(keys.map((key, index) => [key, { levelOne: values[index], flatGrowth: values[index + 7], percentGrowth: values[index + 14], }])); } const DATA_START = 140; const LEARN_RECORD_SIZE = 12184; const LEARN_ENTRY_SIZE = 24; const SP_CAPACITY = 200; const MAGIC_COLLECT_RECORD_SIZE = 1032; const MAGIC_OBJECT_RECORD_SIZE = 992; const COUNT_OFFSET = 132; const buffers = Object.fromEntries(await Promise.all(Object.entries(files).map(async ([key, filename]) => [key, await readFile(filename)]))); const sha256 = (value) => createHash("sha256").update(value).digest("hex"); async function sha256File(filename) { const hash = createHash("sha256"); for await (const chunk of createReadStream(filename)) hash.update(chunk); return hash.digest("hex"); } const int32 = (buffer, offset) => buffer.readInt32LE(offset); const float32 = (buffer, offset) => buffer.readFloatLE(offset); const cleanFloat = (value) => Number.isFinite(value) ? Number(value.toFixed(5)) : 0; function validateDatabase(buffer, recordSize, expectedType, label) { const count = int32(buffer, COUNT_OFFSET); if (count <= 0 || DATA_START + count * recordSize > buffer.length) throw new Error(`${label} record-size sentinel drifted.`); for (const index of [0, Math.floor(count / 2), count - 1]) { if (int32(buffer, DATA_START + index * recordSize + 4) !== expectedType) throw new Error(`${label} type sentinel drifted.`); } return count; } const stringCache = new Map(); function stringValue(key) { if (stringCache.has(key)) return stringCache.get(key); const marker = Buffer.from(`${key}\0`, "ascii"); const keyPosition = buffers.strings.indexOf(marker); if (keyPosition < 0) return null; const start = keyPosition + marker.length; const end = Math.min(buffers.strings.length - 4, start + 600); for (let offset = start; offset < end; offset += 1) { const length = buffers.strings.readInt32LE(offset); if (length < 1 || length > 4001 || offset + 4 + length > buffers.strings.length) continue; const value = buffers.strings.subarray(offset + 4, offset + 3 + length); if (buffers.strings[offset + 3 + length] !== 0 || value.includes(0)) continue; try { const result = new TextDecoder("utf-8", { fatal: true }).decode(value).trim() || null; stringCache.set(key, result); return result; } catch { /* scan through metadata bytes */ } } stringCache.set(key, null); return null; } const magicCollectCount = validateDatabase(buffers.magicCollect, MAGIC_COLLECT_RECORD_SIZE, 21, "magiccollectobject.db"); const magicObjectCount = validateDatabase(buffers.magicObject, MAGIC_OBJECT_RECORD_SIZE, 3, "magicobject.db"); const imageObjectCount = validateDatabase(buffers.imageObject, 3712, 13, "imageobject.db"); const magicCollectOffsets = new Map(); for (let index = 0; index < magicCollectCount; index += 1) { const offset = DATA_START + index * MAGIC_COLLECT_RECORD_SIZE; magicCollectOffsets.set(int32(buffers.magicCollect, offset), offset); } const magicObjectOffsets = new Map(); for (let index = 0; index < magicObjectCount; index += 1) { const offset = DATA_START + index * MAGIC_OBJECT_RECORD_SIZE; magicObjectOffsets.set(int32(buffers.magicObject, offset), offset); } const imagePaths = new Map(); for (let index = 0; index < imageObjectCount; index += 1) { const offset = DATA_START + index * 3712; const imageId = int32(buffers.imageObject, offset); let end = offset + 0xb4; while (end < offset + 0xb4 + 520 && buffers.imageObject[end] !== 0) end += 1; let assetPath = buffers.imageObject.subarray(offset + 0xb4, end).toString("utf8").replaceAll("/", "\\").toLowerCase(); if (!assetPath) continue; assetPath = assetPath.replace(/\.[^.\\]+$/, "") + ".dds"; imagePaths.set(imageId, assetPath); } function componentFor(id) { const offset = magicObjectOffsets.get(id); if (offset === undefined) return null; const abilityModifiers = Array.from({ length: 10 }, (_, index) => ({ type: int32(buffers.magicObject, offset + 0xf8 + index * 4), value: int32(buffers.magicObject, offset + 0x120 + index * 4), })).filter((modifier) => modifier.type !== 0 || modifier.value !== 0); const magicFunc = int32(buffers.magicObject, offset + 0xb0); const settingFlags = int32(buffers.magicObject, offset + 0xd8); const specialActionFlags = int32(buffers.magicObject, offset + 0xdc); const effectFlagWords = [ int32(buffers.magicObject, offset + 0xec), int32(buffers.magicObject, offset + 0xf0), ]; const clearFlags = int32(buffers.magicObject, offset + 0xf4); const onAttackTrigger = { rate: int32(buffers.magicObject, offset + 0x148), rank: int32(buffers.magicObject, offset + 0x14c), id: int32(buffers.magicObject, offset + 0x150), }; const triggers = { onTimeMagicId: int32(buffers.magicObject, offset + 0x158), onTimeSeconds: int32(buffers.magicObject, offset + 0x15c), onHitMagicId: int32(buffers.magicObject, offset + 0x168), onBuffTimeoutMagicId: int32(buffers.magicObject, offset + 0x16c), onAttackReboundMagicId: int32(buffers.magicObject, offset + 0x170), onMagicAttackReboundMagicId: int32(buffers.magicObject, offset + 0x174), onDeathMagicId: int32(buffers.magicObject, offset + 0x178), }; const dot = { timeSeconds: int32(buffers.magicObject, offset + 0x184), type: int32(buffers.magicObject, offset + 0x188), base: int32(buffers.magicObject, offset + 0x18c), skillLevelArg: cleanFloat(float32(buffers.magicObject, offset + 0x190)), }; const attack = { type: int32(buffers.magicObject, offset + 0x194), damagePower: cleanFloat(float32(buffers.magicObject, offset + 0x198)), damagePowerSkillLevelArg: cleanFloat(float32(buffers.magicObject, offset + 0x19c)), fixedValue: cleanFloat(float32(buffers.magicObject, offset + 0x1a0)), fixedType: int32(buffers.magicObject, offset + 0x1a4), fixedDamageSkillLevelArg: cleanFloat(float32(buffers.magicObject, offset + 0x1a8)), randomRange: cleanFloat(float32(buffers.magicObject, offset + 0x1ac)), criticalRate: int32(buffers.magicObject, offset + 0x1b0), hateRate: cleanFloat(float32(buffers.magicObject, offset + 0x1b4)), }; const summon = { creatureId: int32(buffers.magicObject, offset + 0x1d0), level: int32(buffers.magicObject, offset + 0x1d4), rangeLevel: int32(buffers.magicObject, offset + 0x1d8), lifetimeSeconds: int32(buffers.magicObject, offset + 0x1dc), skillLevelArg: cleanFloat(float32(buffers.magicObject, offset + 0x1e0)), type: int32(buffers.magicObject, offset + 0x1e4), groupId: int32(buffers.magicObject, offset + 0x1e8), ownerPowerRate: cleanFloat(float32(buffers.magicObject, offset + 0x1ec)), }; const magicShield = { rawBaseWord: int32(buffers.magicObject, offset + 0x2c0), type: int32(buffers.magicObject, offset + 0x2c4), effect: int32(buffers.magicObject, offset + 0x2c8), point: int32(buffers.magicObject, offset + 0x2cc), skillLevelArg: cleanFloat(float32(buffers.magicObject, offset + 0x2d0)), }; return { id, magicFunc, magicType: int32(buffers.magicObject, offset + 0xb4), effectType: int32(buffers.magicObject, offset + 0xb8), effectTime: cleanFloat(float32(buffers.magicObject, offset + 0xbc)), hateCost: int32(buffers.magicObject, offset + 0xc8), settingFlags, specialActionFlags, assistType: int32(buffers.magicObject, offset + 0xe0), ...(effectFlagWords.some(Boolean) ? { effectFlagWords } : {}), ...(clearFlags ? { clearFlags } : {}), ...(abilityModifiers.length ? { abilityModifiers } : {}), ...(onAttackTrigger.id > 0 ? { onAttackTrigger } : {}), abilitySkillLevelArg: cleanFloat(float32(buffers.magicObject, offset + 0x154)), ...(Object.values(triggers).some(Boolean) ? { triggers } : {}), dotTime: int32(buffers.magicObject, offset + 0x184), dotType: int32(buffers.magicObject, offset + 0x188), dotBase: int32(buffers.magicObject, offset + 0x18c), ...(dot.timeSeconds > 0 || dot.base !== 0 ? { dot } : {}), attackType: int32(buffers.magicObject, offset + 0x194), damagePower: cleanFloat(float32(buffers.magicObject, offset + 0x198)), fixedValue: cleanFloat(float32(buffers.magicObject, offset + 0x1a0)), ...(magicFunc === 0 && (attack.damagePower !== 0 || attack.fixedValue !== 0) ? { attack } : {}), ...(magicFunc === 8 ? { raise: { experiencePercent: int32(buffers.magicObject, offset + 0x1cc) } } : {}), summonCreatureId: int32(buffers.magicObject, offset + 0x1d0), ...(summon.creatureId > 0 ? { summon } : {}), // The serialized record has a reserved word at +0x2c0; the four // MagicShieldStruct fields immediately follow it at +0x2c4. ...(settingFlags & (1 << 8) ? { magicShield } : {}), }; } function targetFor(sourceTarget, effectType) { if (sourceTarget === 0) return "self"; if ([1, 2, 3, 4, 5, 9, 11, 13, 14, 15, 17].includes(sourceTarget)) return "friendly"; if ([6, 7, 8, 10, 12].includes(sourceTarget)) return "hostile"; return effectType === 0 || effectType === 4 ? "hostile" : "self"; } function effectFor(components, target, passive) { if (passive) return { kind: "passive", coefficient: 0 }; const raise = components.find((component) => component.magicFunc === 8); if (raise) return { kind: "resurrection", coefficient: 0.35 }; const summon = components.find((component) => component.magicFunc === 3 || component.summonCreatureId > 0); if (summon) return { kind: "summon", coefficient: 0.75, creatureId: summon.summonCreatureId }; const teleport = components.find((component) => component.magicFunc === 2); if (teleport) return { kind: "movement", coefficient: 0, distance: 12 }; const absorb = components.find((component) => component.magicShield); if (absorb) return { kind: "absorb", coefficient: 0, durationMs: Math.max(0, absorb.effectTime * 1000) }; const hp = components.find((component) => component.magicFunc === 0 && component.attackType === 0); if (hp) { const magnitude = Math.max(Math.abs(hp.damagePower), Math.abs(hp.fixedValue), Math.abs(hp.dotBase), 1); const coefficient = Math.max(0.15, Math.min(3.5, Number((magnitude / 100).toFixed(3)))); const periodic = hp.dotTime > 0; const healing = target !== "hostile" && hp.damagePower >= 0 && hp.fixedValue >= 0 && hp.dotBase >= 0; if (periodic) return { kind: healing ? "hot" : "dot", coefficient, ticks: Math.max(2, Math.min(12, Math.round(hp.dotTime / 2))), intervalMs: 2000 }; return { kind: healing ? "heal" : "damage", coefficient }; } // EffectType identifies the engine channel (magic/physical/equipment/pet), // not heal/cleanse/shield/status semantics. Preserve an honest source hint; // the runtime translator consumes the full components and tooltip instead. return { kind: "source-components", coefficient: 0 }; } function resourceForCostType(type, classIndex) { if (type === 1 || type === 3) return "health"; if (type === 2 || type === 4) return "mana"; if (type === 5) return "rage"; if (type === 6) return "focus"; if (type === 7) return "energy"; return RESOURCES[classIndex - 1]; } function sourceSpell(spellId, classIndex) { const offset = magicCollectOffsets.get(spellId); if (offset === undefined) return null; const imageId = int32(buffers.magicCollect, offset + 0x20); const sourceEffectType = int32(buffers.magicCollect, offset + 0xb4); const sourceTarget = int32(buffers.magicCollect, offset + 0xb8); const target = targetFor(sourceTarget, sourceEffectType); const passive = sourceEffectType === 2; const description = stringValue(`Sys${spellId}_shortnote`) ?? stringValue(`Sys${spellId}_desc`) ?? "RuneWaker source tooltip unavailable."; const components = []; const componentIds = new Set(); for (let componentOffset = 0x180; componentOffset <= 0x2c0; componentOffset += 4) { const componentId = int32(buffers.magicCollect, offset + componentOffset); if (magicObjectOffsets.has(componentId)) componentIds.add(componentId); } // Elite and scripted skills frequently point at their actual buff, shield, // or triggered-damage MagicObject through a tooltip token such as // `(Buff-Shield-620686)` instead of the primary MagicCollect component list. // Those are authoritative references, so retain them as source components. for (const match of description.matchAll(/\b\d{6}\b/g)) { const referencedComponentId = Number(match[0]); if (magicObjectOffsets.has(referencedComponentId)) componentIds.add(referencedComponentId); } for (const componentId of componentIds) { const component = componentFor(componentId); if (component) components.push(component); } const costRows = [0, 1].map((index) => ({ type: int32(buffers.magicCollect, offset + 0xe0 + index * 8), value: int32(buffers.magicCollect, offset + 0xe4 + index * 8), })).filter((cost) => cost.type > 0 && cost.value !== 0 && ![9, 10, 11, 12, 13, 14, 15].includes(cost.type)); const costs = costRows.map((cost) => ({ resource: resourceForCostType(cost.type, classIndex), amount: Math.max(0, Math.min(100, Math.abs(cost.value))), percentage: cost.type === 3 || cost.type === 4, })); const attackDistance = int32(buffers.magicCollect, offset + 0xbc); const sourceCooldownSeconds = int32(buffers.magicCollect, offset + 0x108); const spellTimeSeconds = cleanFloat(float32(buffers.magicCollect, offset + 0x114)); return { sourceSkillId: spellId, name: stringValue(`Sys${spellId}_name`) ?? `RuneWaker Skill ${spellId}`, // RuneWaker's player-facing skill tooltip is stored under `shortnote`. // `_desc` is not present in the shipped English string database, which // previously left every skill with a generic placeholder and made faithful // semantic classification impossible downstream. description, target, range: Math.max(target === "self" ? 0 : 3, Math.min(40, attackDistance > 0 ? attackDistance * 0.1 : target === "hostile" ? 6 : 30)), castTimeMs: Math.max(0, Math.round(spellTimeSeconds * 1000)), cooldownMs: Math.max(0, Math.min(180000, sourceCooldownSeconds * 1000)), costs, passive, effect: effectFor(components, target, passive), source: { imageId, sourceEffectType, sourceTarget, componentIds: [...componentIds], components, }, }; } // The parent skill is the canonical rank-one source record. Avoid duplicating // its large component payload inside the nested rank-one row; higher ranks keep // their own components because their coefficients and flags may differ. function nestedRankSpell(spell, parentSourceSkillId) { if (spell.sourceSkillId !== parentSourceSkillId) return spell; const { components: _components, ...source } = spell.source; return { ...spell, source }; } async function loadFdbIndex() { const handle = await open(interfaceFdb, "r"); const prefix = Buffer.alloc(8); await handle.read(prefix, 0, prefix.length, 0); if (prefix.readUInt32LE(0) !== 0x46444201) throw new Error("interface.fdb magic sentinel drifted."); const count = prefix.readInt32LE(4); if (count <= 0 || count > 1_000_000) throw new Error("interface.fdb entry-count sentinel drifted."); const headers = Buffer.alloc(count * 16); const lengths = Buffer.alloc(count * 4); await handle.read(headers, 0, headers.length, 8); await handle.read(lengths, 0, lengths.length, 8 + headers.length); const textSizeBuffer = Buffer.alloc(4); await handle.read(textSizeBuffer, 0, 4, 8 + headers.length + lengths.length); const textSize = textSizeBuffer.readInt32LE(0); if (textSize <= 0 || textSize > 64 * 1024 * 1024) throw new Error("interface.fdb path-table sentinel drifted."); const textBuffer = Buffer.alloc(textSize); await handle.read(textBuffer, 0, textSize, 12 + headers.length + lengths.length); const entries = new Map(); let textOffset = 0; for (let index = 0; index < count; index += 1) { const length = lengths.readInt32LE(index * 4); const filename = textBuffer.subarray(textOffset, textOffset + length).toString("utf8").toLowerCase(); entries.set(filename, { fileType: headers.readInt32LE(index * 16), offset: headers.readUInt32LE(index * 16 + 12), }); textOffset += length + 1; } return { handle, count, entries }; } async function extractNativeIcons(skills) { const requested = new Map(); for (const skill of skills) { const imageId = skill.source.imageId; const assetPath = imagePaths.get(imageId); if (!assetPath) throw new Error(`ImageObject ${imageId} has no ACTField path.`); requested.set(imageId, assetPath); } const { handle, count, entries } = await loadFdbIndex(); await mkdir(iconOutputDirectory, { recursive: true }); try { const jobs = [...requested].sort(([left], [right]) => left - right); for (let cursor = 0; cursor < jobs.length; cursor += 16) { await Promise.all(jobs.slice(cursor, cursor + 16).map(async ([imageId, assetPath]) => { const entry = entries.get(assetPath); if (!entry || entry.fileType !== 2) throw new Error(`interface.fdb is missing image ${imageId}: ${assetPath}`); const sizeBuffer = Buffer.alloc(4); await handle.read(sizeBuffer, 0, 4, entry.offset); const storedSize = sizeBuffer.readInt32LE(0); if (storedSize <= 0 || storedSize > 0x10000000) throw new Error(`Invalid FDB image payload size for ${imageId}.`); const payload = Buffer.alloc(storedSize); await handle.read(payload, 0, storedSize, entry.offset + 4); const compression = payload.readInt32LE(4); const dataSize = payload.readInt32LE(8); const compressedSize = payload.readInt32LE(12); const filenameLength = payload.readInt32LE(24); const imageHeaderOffset = 28 + filenameLength; const imageFormat = payload.readInt32LE(imageHeaderOffset); const width = payload.readInt32LE(imageHeaderOffset + 4); const height = payload.readInt32LE(imageHeaderOffset + 8); const pixelOffset = imageHeaderOffset + 16; const storedPixels = payload.subarray(pixelOffset, pixelOffset + (compression === 0 ? dataSize : compressedSize)); const pixels = compression === 0 ? storedPixels : compression === 3 ? inflateSync(storedPixels) : null; if (!pixels || pixels.length !== dataSize) throw new Error(`Unsupported or corrupt FDB compression ${compression} for ${imageId}.`); if (imageFormat !== 4 || width <= 0 || height <= 0 || pixels.length < width * height * 4) { throw new Error(`Unsupported FDB image format ${imageFormat} for ${imageId}.`); } const rgba = Buffer.alloc(width * height * 4); for (let offset = 0; offset < rgba.length; offset += 4) { rgba[offset] = pixels[offset + 2]; rgba[offset + 1] = pixels[offset + 1]; rgba[offset + 2] = pixels[offset]; rgba[offset + 3] = pixels[offset + 3]; } await sharp(rgba, { raw: { width, height, channels: 4 } }).png({ compressionLevel: 9 }).toFile(path.join(iconOutputDirectory, `${imageId}.png`)); })); } return { requested: requested.size, resolved: requested.size, fdbEntries: count }; } finally { await handle.close(); } } function parseLearnEntries() { if (int32(buffers.learnMagic, COUNT_OFFSET) !== 21 || int32(buffers.learnMagic, DATA_START) !== 590000) throw new Error("learnmagic.db sentinel drifted."); const entries = []; for (let classIndex = 1; classIndex <= 10; classIndex += 1) { const recordOffset = DATA_START + classIndex * LEARN_RECORD_SIZE; if (int32(buffers.learnMagic, recordOffset) !== 590000 + classIndex) throw new Error(`learnmagic class ${classIndex} sentinel drifted.`); const spCount = int32(buffers.learnMagic, recordOffset + 0xb0); const normalCountOffset = recordOffset + 0xb4 + SP_CAPACITY * LEARN_ENTRY_SIZE; const normalCount = int32(buffers.learnMagic, normalCountOffset); for (const [kind, count, start] of [["primary", spCount, recordOffset + 0xb4], ["general", normalCount, normalCountOffset + 4]]) { for (let index = 0; index < count; index += 1) { const offset = start + index * LEARN_ENTRY_SIZE; entries.push({ classIndex, kind, sourceSkillId: int32(buffers.learnMagic, offset), learnLevel: Math.max(1, int32(buffers.learnMagic, offset + 4)), keyItemId: int32(buffers.learnMagic, offset + 8), prerequisites: [int32(buffers.learnMagic, offset + 12), int32(buffers.learnMagic, offset + 16)].filter((id) => id > 0), saveLevelPosition: buffers.learnMagic.readUInt8(offset + 22), }); } } } return entries; } function parseEliteTable(tableName, terminator, levels) { const text = buffers.eliteLua.toString("latin1"); const start = text.indexOf(`local ${tableName}`); const end = text.indexOf(terminator, start); if (start < 0 || end < 0) throw new Error(`${tableName} mapping was not found.`); let mainClass = null; const mappings = []; for (const line of text.slice(start, end).split(/\r?\n/)) { const main = line.match(/^\s*\[(\d+)\]\s*=\s*\{\s*$/); if (main) { mainClass = Number(main[1]); continue; } const pair = line.match(/^\s*\[(\d+)\]\s*=\s*\{([\d,\s]+)\}/); if (!pair || mainClass === null) continue; const keys = pair[2].split(",").map(Number); if (keys.length !== levels.length) throw new Error(`${tableName} ${mainClass}/${pair[1]} expected ${levels.length} milestones.`); mappings.push({ mainClass, secondaryClass: Number(pair[1]), milestones: keys.map((keyItemId, index) => ({ level: levels[index], keyItemId })) }); } return mappings; } const learnEntries = parseLearnEntries(); const eliteMappings = [ ...parseEliteTable("VocationSkill", "return VocationSkill", ELITE_LEVELS.slice(0, 8)), ...parseEliteTable("KeyItemList", "return KeyItemList", ELITE_LEVELS.slice(8)), ].reduce((map, entry) => { const key = `${entry.mainClass}:${entry.secondaryClass}`; const existing = map.get(key) ?? { mainClass: entry.mainClass, secondaryClass: entry.secondaryClass, milestones: [] }; existing.milestones.push(...entry.milestones); map.set(key, existing); return map; }, new Map()); const eligiblePairs = new Set(); for (const classes of Object.values(RACE_MATRIX)) for (const main of classes) for (const secondary of classes) if (main !== secondary) eligiblePairs.add(`${main}:${secondary}`); if (eligiblePairs.size !== 66) throw new Error(`Race matrix produced ${eligiblePairs.size} ordered pairs instead of 66.`); if (eliteMappings.size !== 66 || [...eliteMappings.values()].some((entry) => entry.milestones.length !== 10)) throw new Error("Elite Lua mapping does not resolve 66 ordered pairs with ten milestones."); const byKeyItem = new Map(); for (const entry of learnEntries) { if (!entry.keyItemId) continue; const candidates = byKeyItem.get(entry.keyItemId) ?? []; candidates.push(entry); byKeyItem.set(entry.keyItemId, candidates); } const adaptations = []; const skills = []; for (const classIndex of CLASS_IDS.keys()) { const sourceClass = classIndex + 1; const grouped = new Map(); for (const entry of learnEntries.filter((candidate) => candidate.classIndex === sourceClass && candidate.keyItemId === 0)) { const key = `${entry.kind}:${entry.saveLevelPosition}`; const family = grouped.get(key) ?? []; family.push(entry); grouped.set(key, family); } for (const [familyKey, family] of grouped) { family.sort((left, right) => left.learnLevel - right.learnLevel || left.sourceSkillId - right.sourceSkillId); const rankRows = family.map((entry, index) => ({ ...entry, rank: index + 1, spell: sourceSpell(entry.sourceSkillId, sourceClass) })).filter((entry) => entry.spell); if (!rankRows.length) continue; const base = rankRows[0]; const slug = `${CLASS_IDS[classIndex]}-${base.kind}-${base.saveLevelPosition}`; skills.push({ id: slug, classId: CLASS_IDS[classIndex], group: base.spell.passive ? "passive" : base.kind, unlockLevel: Math.min(...rankRows.map((rank) => rank.learnLevel)), ...base.spell, ranks: rankRows.map((rank) => ({ rank: rank.rank, level: rank.learnLevel, prerequisites: rank.prerequisites, ...nestedRankSpell(rank.spell, base.spell.sourceSkillId), })), sourceFamily: familyKey, icon: `/assets/ui/rom-spells/${base.spell.source.imageId}.png`, iconResolution: "native-interface-fdb", }); } } for (const key of [...eligiblePairs].sort()) { const mapping = eliteMappings.get(key); if (!mapping) throw new Error(`Missing elite mapping ${key}.`); for (const milestone of mapping.milestones) { let candidates = byKeyItem.get(milestone.keyItemId) ?? []; let candidate = candidates.find((entry) => entry.classIndex === mapping.mainClass && entry.learnLevel === milestone.level) ?? candidates.find((entry) => entry.classIndex === mapping.mainClass) ?? candidates[0]; if (!candidate && milestone.keyItemId === 545911) { candidate = learnEntries.find((entry) => entry.classIndex === 9 && entry.sourceSkillId === 498668); adaptations.push({ type: "source-key-typo", pair: key, level: milestone.level, keyItemId: milestone.keyItemId, sourceSkillId: 498668, reason: "Lua key 545911 is absent; the adjacent duplicated 545910 rank 498668 is the deterministic level-50 continuation." }); } if (!candidate) throw new Error(`Elite ${key} level ${milestone.level} key ${milestone.keyItemId} has no learnmagic entry.`); const spell = sourceSpell(candidate.sourceSkillId, mapping.mainClass); if (!spell) throw new Error(`Elite ${key} level ${milestone.level} spell ${candidate.sourceSkillId} has no magic record.`); skills.push({ id: `${CLASS_IDS[mapping.mainClass - 1]}-${CLASS_IDS[mapping.secondaryClass - 1]}-elite-${milestone.level}`, classId: CLASS_IDS[mapping.mainClass - 1], secondaryClassId: CLASS_IDS[mapping.secondaryClass - 1], group: spell.passive ? "passive" : "elite", unlockLevel: milestone.level, keyItemId: milestone.keyItemId, ...spell, ranks: [{ rank: 1, level: milestone.level, prerequisites: candidate.prerequisites, ...nestedRankSpell(spell, spell.sourceSkillId), }], icon: `/assets/ui/rom-spells/${spell.source.imageId}.png`, iconResolution: "native-interface-fdb", }); } } const eliteSkills = skills.filter((skill) => skill.secondaryClassId); const ordinarySkills = skills.filter((skill) => !skill.secondaryClassId); const unresolvedVisible = skills.filter((skill) => !skill.passive && !skill.effect?.kind); const iconCoverage = await extractNativeIcons(skills); const sourceHashes = { ...Object.fromEntries(Object.entries(buffers).map(([key, value]) => [key, sha256(value)])), interfaceFdb: await sha256File(interfaceFdb), }; const catalog = { schemaVersion: 1, source: { rootHint: "RuneWaker source snapshot", hashes: sourceHashes }, canonicalClasses: CLASS_IDS.map((id, index) => ({ id, sourceClassId: index + 1, name: CLASS_NAMES[index], resource: RESOURCES[index] })), baseStatGrowth: Object.fromEntries(CLASS_IDS.map((id, index) => [id, vocationGrowth(index + 1)])), raceMatrix: Object.fromEntries(Object.entries(RACE_MATRIX).map(([raceId, classes]) => [raceId, classes.map((index) => CLASS_IDS[index - 1])])), eliteLevels: ELITE_LEVELS, skills, }; const audit = { generatorVersion: 1, hashes: catalog.source.hashes, sentinels: { learnMagicRecords: int32(buffers.learnMagic, COUNT_OFFSET), magicCollectRecords: magicCollectCount, magicObjectRecords: magicObjectCount }, coverage: { releasedClasses: CLASS_IDS.length, duelistExcluded: !CLASS_NAMES.includes("Duelist"), races: Object.keys(RACE_MATRIX).length, orderedPairs: eligiblePairs.size, eliteMilestones: eliteSkills.length, ordinaryFamilies: ordinarySkills.length, sourceRankRows: ordinarySkills.reduce((sum, skill) => sum + skill.ranks.length, 0), iconResolved: skills.filter((skill) => skill.iconResolution === "native-interface-fdb").length, uniqueNativeIcons: iconCoverage.resolved, interfaceFdbEntries: iconCoverage.fdbEntries, executableOrNonCombat: skills.filter((skill) => skill.passive || Boolean(skill.effect?.kind)).length, unresolvedVisible: unresolvedVisible.length, }, adaptations, }; if (eliteSkills.length !== 660 || unresolvedVisible.length !== 0 || audit.coverage.iconResolved !== skills.length || iconCoverage.requested !== iconCoverage.resolved) throw new Error(`Coverage failed: ${JSON.stringify(audit.coverage)}`); await mkdir(path.dirname(outputFile), { recursive: true }); await mkdir(path.dirname(auditFile), { recursive: true }); await Promise.all([ writeFile(outputFile, `${JSON.stringify(catalog)}\n`), writeFile(auditFile, `${JSON.stringify(audit, null, 2)}\n`), ]); console.log(`Generated ${ordinarySkills.length} ordinary families and ${eliteSkills.length} ordered-pair elite skills.`); console.log(`Coverage audit: ${path.relative(projectRoot, auditFile)}`);