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