612 lines
31 KiB
JavaScript
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)}`);
|