287 lines
10 KiB
JavaScript
287 lines
10 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;
|
|
|
|
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,
|
|
attackType,
|
|
damagePower,
|
|
fixedValue,
|
|
dotTime,
|
|
dotType,
|
|
dotBase,
|
|
summonCreatureId: int32(magicObject, offset + 0x1d0),
|
|
directHpDamage,
|
|
};
|
|
componentCache.set(id, result);
|
|
return result;
|
|
}
|
|
|
|
function schoolFor(component) {
|
|
if (component.effectType === 1) return "physical";
|
|
return ["nature", "frost", "fire", "nature", "holy", "shadow"][component.magicType]
|
|
?? "arcane";
|
|
}
|
|
|
|
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 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 primaryDamage = damageComponents[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 (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),
|
|
animation: range > 5 || primaryDamage.effectType === 0 ? "cast" : "attack",
|
|
range,
|
|
cooldownMs: Math.max(2500, Math.min(20000, sourceCooldownSeconds > 0
|
|
? sourceCooldownSeconds * 1000
|
|
: 6500)),
|
|
damageMultiplier: 0.85,
|
|
},
|
|
} : {}),
|
|
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,
|
|
},
|
|
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));
|