Files
healer-man/scripts/runewaker-pipeline/export-spell-catalog.mjs
T
2026-10-04 13:13:35 -04:00

385 lines
14 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import {
pipelineDirectory,
projectPath,
projectRoot,
readJson,
sourceRootFor,
} from "./lib/recipe.mjs";
const recipesDirectory = path.join(pipelineDirectory, "recipes");
const outputFile = path.join(pipelineDirectory, "snapshots", "runewaker-npc-spell-catalog.json");
const recipeNames = (await readdir(recipesDirectory))
.filter((name) => name.endsWith("-population.json"))
.sort();
const recipes = await Promise.all(recipeNames.map(async (name) => (
readJson(path.join(recipesDirectory, name))
)));
const sourceRoot = sourceRootFor(recipes[0]);
const dataRoot = path.join(sourceRoot, recipes[0].source.resourceRoot ?? "Resource", "data");
const magicCollectFile = path.join(dataRoot, "magiccollectobject.db");
const magicObjectFile = path.join(dataRoot, "magicobject.db");
const stringsFile = path.join(dataRoot, "string_enus.db");
const [magicCollect, magicObject, strings] = await Promise.all([
readFile(magicCollectFile),
readFile(magicObjectFile),
readFile(stringsFile),
]);
const DATA_START = 140;
const COUNT_OFFSET = 132;
const MAGIC_COLLECT_RECORD_SIZE = 1032;
const MAGIC_OBJECT_RECORD_SIZE = 992;
const MAGIC_COLLECT_TYPE = 21;
const MAGIC_OBJECT_TYPE = 3;
const PHYSICAL_CALCULATION_TYPES = new Set([0, 3, 9]);
const MAGICAL_CALCULATION_TYPES = new Set([1, 10]);
const SOURCE_CHANNELS = ["earth", "water", "fire", "wind", "light", "darkness"];
const RUNTIME_SCHOOLS_BY_SOURCE_CHANNEL = {
earth: "nature",
water: "frost",
fire: "fire",
wind: "nature",
light: "holy",
darkness: "shadow",
physical: "physical",
};
function sha256(buffer) {
return createHash("sha256").update(buffer).digest("hex");
}
function int32(buffer, offset) {
return buffer.readInt32LE(offset);
}
function float32(buffer, offset) {
return buffer.readFloatLE(offset);
}
function cleanFloat(value) {
return Number.isFinite(value) ? Number(value.toFixed(6)) : 0;
}
function validateCatalog(buffer, recordSize, type, label) {
const count = int32(buffer, COUNT_OFFSET);
if (count <= 0 || DATA_START + count * recordSize > buffer.length) {
throw new Error(label + " has an incompatible record count or size.");
}
for (const index of [0, Math.floor(count / 2), count - 1]) {
if (int32(buffer, DATA_START + index * recordSize + 4) !== type) {
throw new Error(label + " record layout/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 = strings.indexOf(marker);
if (keyPosition < 0) {
stringCache.set(key, null);
return null;
}
const start = keyPosition + marker.length;
const end = Math.min(strings.length - 4, start + 512);
for (let offset = start; offset < end; offset += 1) {
const length = strings.readInt32LE(offset);
if (length < 1 || length > 4001 || offset + 4 + length > strings.length) continue;
const value = strings.subarray(offset + 4, offset + 3 + length);
if (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 {
// Keep scanning; metadata bytes can resemble a length.
}
}
stringCache.set(key, null);
return null;
}
const magicCollectCount = validateCatalog(
magicCollect, MAGIC_COLLECT_RECORD_SIZE, MAGIC_COLLECT_TYPE, "MagicCollectObjectDB",
);
const magicObjectCount = validateCatalog(
magicObject, MAGIC_OBJECT_RECORD_SIZE, MAGIC_OBJECT_TYPE, "MagicObjectDB",
);
const magicObjectOffsets = new Map();
for (let index = 0; index < magicObjectCount; index += 1) {
const offset = DATA_START + index * MAGIC_OBJECT_RECORD_SIZE;
magicObjectOffsets.set(int32(magicObject, offset), offset);
}
const componentCache = new Map();
function magicComponent(id) {
if (componentCache.has(id)) return componentCache.get(id);
const offset = magicObjectOffsets.get(id);
if (offset === undefined) return null;
const magicFunc = int32(magicObject, offset + 0xb0);
const magicType = int32(magicObject, offset + 0xb4);
const effectType = int32(magicObject, offset + 0xb8);
const attackType = int32(magicObject, offset + 0x194);
const damagePower = cleanFloat(float32(magicObject, offset + 0x198));
const fixedValue = cleanFloat(float32(magicObject, offset + 0x1a0));
const dotTime = int32(magicObject, offset + 0x184);
const dotType = int32(magicObject, offset + 0x188);
const dotBase = int32(magicObject, offset + 0x18c);
const directHpDamage = magicFunc === 0 && attackType === 0
&& (damagePower < 0 || fixedValue < 0 || (dotType === 0 && dotTime > 0 && dotBase < 0));
const result = {
id,
name: stringValue("Sys" + int32(magicObject, offset + 0x20) + "_name")
?? stringValue("Sys" + id + "_name"),
magicFunc,
magicType,
effectType,
calculationType: int32(magicObject, offset + 0x2d4),
attackType,
damagePower,
fixedValue,
dotTime,
dotType,
dotBase,
summonCreatureId: int32(magicObject, offset + 0x1d0),
directHpDamage,
};
componentCache.set(id, result);
return result;
}
function sourceDamageClassForCalculationType(calculationType) {
if (PHYSICAL_CALCULATION_TYPES.has(calculationType)) return "physical";
if (MAGICAL_CALCULATION_TYPES.has(calculationType)) return "magical";
return undefined;
}
function sourceChannelForMagicType(magicType) {
if (magicType === -1) return "physical";
return SOURCE_CHANNELS[magicType];
}
function schoolFor(component) {
const sourceChannel = sourceChannelForMagicType(component.magicType);
return sourceChannel ? RUNTIME_SCHOOLS_BY_SOURCE_CHANNEL[sourceChannel] : undefined;
}
function attackKindForCalculationType(calculationType) {
if (calculationType === 0 || calculationType === 9) return "melee";
if (calculationType === 3) return "ranged";
if (calculationType === 1 || calculationType === 10) return "spell";
return undefined;
}
function formulaFor(spellId, component) {
const magnitude = Math.abs(component.damagePower);
const powerKind = component.damagePower < 0
? magnitude <= 4 ? "source-ratio" : "source-points"
: component.dotBase < 0
? "dot-only"
: "unresolved";
return {
schemaVersion: 1,
spellId,
componentId: component.id,
spellLevel: 0,
calculationPath: sourceDamageClassForCalculationType(component.calculationType) === "physical"
? "physical" : "magic",
powerKind,
damagePower: component.damagePower,
fixedValue: component.fixedValue,
dotBase: component.dotBase,
runtimeBaselineMultiplier: powerKind === "source-ratio"
? Math.max(0.2, Math.min(4, magnitude))
: powerKind === "unresolved" ? 0 : 0.85,
provenance: {
sourceFile: "SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp",
physicalFunctionLine: 3366,
magicFunctionLine: 3651,
magicObjectDatabase: "Resource/data/magicobject.db",
},
};
}
const assignmentsBySpell = new Map();
for (const recipe of recipes) {
const snapshot = await readJson(path.resolve(projectRoot, recipe.files.snapshot));
for (const row of snapshot.rows ?? []) {
for (const sourceSpell of row.source?.spells ?? []) {
if (!(Number(sourceSpell.id) > 0)) continue;
const assignments = assignmentsBySpell.get(Number(sourceSpell.id)) ?? new Map();
const key = recipe.dungeonId + ":" + row.templateId + ":" + Number(sourceSpell.level ?? 0);
assignments.set(key, {
dungeonId: recipe.dungeonId,
zoneId: recipe.zoneId,
templateId: row.templateId,
templateName: row.name,
sourceLevel: Number(sourceSpell.level ?? 0),
});
assignmentsBySpell.set(Number(sourceSpell.id), assignments);
}
}
}
const luaRoot = path.join(sourceRoot, recipes[0].source.luaRoot ?? "Resource/luascript");
const luaNames = (await readdir(luaRoot)).filter((name) => name.endsWith(".lua")).sort();
for (const fileName of luaNames) {
const source = await readFile(path.join(luaRoot, fileName), "utf8");
const executableSource = source
.replace(/--\[\[[\s\S]*?\]\]/g, "")
.split(/\r?\n/)
.map((line) => line.replace(/--.*$/, ""))
.join("\n");
for (const match of executableSource.matchAll(/\b49\d{4}\b/g)) {
const spellId = Number(match[0]);
const assignments = assignmentsBySpell.get(spellId) ?? new Map();
const key = "lua:" + fileName + ":" + spellId;
assignments.set(key, {
sourceKind: "lua-reference",
sourceFile: fileName,
});
assignmentsBySpell.set(spellId, assignments);
}
}
const spells = [];
for (const [spellId, assignmentMap] of [...assignmentsBySpell].sort((left, right) => left[0] - right[0])) {
let offset = null;
const expectedIndex = spellId - 490000;
if (expectedIndex >= 0 && expectedIndex < magicCollectCount) {
const candidate = DATA_START + expectedIndex * MAGIC_COLLECT_RECORD_SIZE;
if (int32(magicCollect, candidate) === spellId) offset = candidate;
}
if (offset === null) {
for (let index = 0; index < magicCollectCount; index += 1) {
const candidate = DATA_START + index * MAGIC_COLLECT_RECORD_SIZE;
if (int32(magicCollect, candidate) === spellId) {
offset = candidate;
break;
}
}
}
if (offset === null) {
spells.push({
spellId,
name: "RuneWaker Spell " + spellId,
mappingStatus: "evidence-only",
unsupportedReasons: ["MagicCollectObjectDB record is missing."],
assignments: [...assignmentMap.values()],
});
continue;
}
const componentIds = new Set();
for (let componentOffset = 0x180; componentOffset <= 0x2c0; componentOffset += 4) {
const candidate = int32(magicCollect, offset + componentOffset);
if (magicObjectOffsets.has(candidate)) componentIds.add(candidate);
}
const components = [...componentIds].map(magicComponent).filter(Boolean);
const damageComponents = components.filter((component) => component.directHpDamage);
const attackDistance = int32(magicCollect, offset + 0xbc);
const effectRange = int32(magicCollect, offset + 0xc0);
const rangeType = int32(magicCollect, offset + 0xc4);
const sourceCooldownSeconds = int32(magicCollect, offset + 0x108);
const runtimeDamageComponents = damageComponents.filter((component) => (
sourceDamageClassForCalculationType(component.calculationType)
&& attackKindForCalculationType(component.calculationType)
&& schoolFor(component)
));
const primaryDamage = runtimeDamageComponents[0];
const unsupportedReasons = [];
if (!components.length) unsupportedReasons.push("No referenced MagicObjectDB components were found.");
if (!damageComponents.length) {
unsupportedReasons.push("No component is positively classified as direct HP damage.");
}
if (damageComponents.length !== runtimeDamageComponents.length) {
unsupportedReasons.push(
(damageComponents.length - runtimeDamageComponents.length)
+ " direct-damage component(s) have an unsupported Attack.CalType or MagicType.",
);
}
if (components.length !== damageComponents.length) {
unsupportedReasons.push(
(components.length - damageComponents.length)
+ " non-damage component(s) remain unimplemented.",
);
}
const range = Math.max(4.5, Math.min(30, attackDistance > 0 ? attackDistance * 0.1 : 4.5));
spells.push({
spellId,
name: stringValue("Sys" + int32(magicCollect, offset + 0x20) + "_name")
?? stringValue("Sys" + spellId + "_name")
?? "RuneWaker Spell " + spellId,
mappingStatus: primaryDamage ? "damage-family" : "evidence-only",
fidelity: primaryDamage
? "Source identity, damage family, school, range, and cooldown only; coefficients are conservative HealerMan values."
: "Evidence only; no executable effect is inferred.",
source: {
effectType: int32(magicCollect, offset + 0xb4),
targetType: int32(magicCollect, offset + 0xb8),
attackDistance,
effectRange,
rangeType,
cooldownSeconds: sourceCooldownSeconds,
spellTimeSeconds: cleanFloat(float32(magicCollect, offset + 0x114)),
componentIds: [...componentIds],
},
...(primaryDamage ? {
runtime: {
delivery: range > 5 ? "projectile" : "melee",
target: "primary",
school: schoolFor(primaryDamage),
runewakerEffectType: sourceDamageClassForCalculationType(primaryDamage.calculationType) === "magical" ? 0 : 1,
attackKind: attackKindForCalculationType(primaryDamage.calculationType),
animation: range > 5 || attackKindForCalculationType(primaryDamage.calculationType) === "spell" ? "cast" : "attack",
range,
cooldownMs: Math.max(2500, Math.min(20000, sourceCooldownSeconds > 0
? sourceCooldownSeconds * 1000
: 6500)),
damageMultiplier: formulaFor(spellId, primaryDamage).runtimeBaselineMultiplier,
formula: formulaFor(spellId, primaryDamage),
},
} : {}),
components,
unsupportedReasons,
assignments: [...assignmentMap.values()],
});
}
const report = {
schemaVersion: 1,
status: "portable",
generatedFrom: {
magicCollectObject: projectPath(magicCollectFile),
magicCollectObjectSha256: sha256(magicCollect),
magicObject: projectPath(magicObjectFile),
magicObjectSha256: sha256(magicObject),
strings: projectPath(stringsFile),
stringsSha256: sha256(strings),
layout: "RuneWaker v0.20 fixed records; read-only export",
},
spellCount: spells.length,
damageFamilyCount: spells.filter((spell) => spell.mappingStatus === "damage-family").length,
evidenceOnlyCount: spells.filter((spell) => spell.mappingStatus === "evidence-only").length,
safety: {
runtimeSqlDependency: false,
unknownEffectsExecute: false,
coefficientsAreSourceExact: false,
unavailableNativeInputsDefaulted: false,
pointValuedDamageUsesLabeledScaledAdapter: true,
},
spells,
};
await writeFile(outputFile, JSON.stringify(report, null, 2) + "\n", "utf8");
console.log("[runewaker-spells] exported " + report.spellCount + " referenced spells: "
+ report.damageFamilyCount + " conservative damage families and "
+ report.evidenceOnlyCount + " evidence-only records.");
console.log("[runewaker-spells] " + projectPath(outputFile));