Files
healer-man/scripts/runewaker-pipeline/export-player-catalog.mjs
T
2026-08-14 15:56:39 -04:00

612 lines
31 KiB
JavaScript

#!/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)}`);