#!/usr/bin/env node import { createHash } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { projectRoot, sourceRootFor } from "./lib/recipe.mjs"; const sourceRoot = sourceRootFor({ source: { rootEnvironmentVariable: "RUNEWAKER_ROOT", relativeDefault: "../rom-pvt-server-backups/Runewaker" }, }); const dataRoot = path.join(sourceRoot, "Resource", "data"); const outputPath = path.join(projectRoot, "src", "game", "generated", "romLevelingGearPackages.json"); const files = ["questdetailobject.db", "itemobject.db", "armorobject.db", "suitobject.db", "string_enus.db"]; const buffers = Object.fromEntries(await Promise.all(files.map(async (name) => ( [name, await readFile(path.join(dataRoot, name))] )))); const DATA_START = 140; const COUNT_OFFSET = 132; const layouts = { "questdetailobject.db": { size: 1656, type: 7 }, "itemobject.db": { size: 1776, type: 2 }, "armorobject.db": { size: 1776, type: 2 }, "suitobject.db": { size: 1284, type: 17 }, }; const indexes = Object.fromEntries(Object.entries(layouts).map(([name, layout]) => { const buffer = buffers[name]; const count = buffer.readInt32LE(COUNT_OFFSET); if (count < 1 || DATA_START + count * layout.size > buffer.length) { throw new Error(`${name} record count or layout drifted.`); } const index = new Map(); for (let row = 0; row < count; row += 1) { const offset = DATA_START + row * layout.size; if (buffer.readInt32LE(offset + 4) !== layout.type) { throw new Error(`${name} object type drifted at row ${row}.`); } index.set(buffer.readInt32LE(offset), offset); } return [name, index]; })); function record(name, id) { const offset = indexes[name].get(id); if (offset === undefined) throw new Error(`${name} is missing source ID ${id}.`); return buffers[name].subarray(offset, offset + layouts[name].size); } const names = new Map(); function sourceName(id) { if (names.has(id)) return names.get(id); const buffer = buffers["string_enus.db"]; const key = Buffer.from(`Sys${id}_name\0`, "ascii"); const position = buffer.indexOf(key); if (position < 0) throw new Error(`Missing English name for source ID ${id}.`); for (let offset = position + key.length; offset < Math.min(buffer.length - 4, position + key.length + 600); offset += 1) { const length = buffer.readInt32LE(offset); if (length < 2 || length > 300 || offset + 4 + length > buffer.length) continue; const value = buffer.subarray(offset + 4, offset + 3 + length); if (buffer[offset + 3 + length] !== 0 || value.includes(0)) continue; try { const name = new TextDecoder("utf-8", { fatal: true }).decode(value); names.set(id, name); return name; } catch { /* continue past metadata */ } } throw new Error(`Could not decode English name for source ID ${id}.`); } // The quest rewards are source-linked. Suit matches are curated from the // source's armor category, required levels, names, and suit membership. Package // expansion itself is not present in these DB records and remains unverified. const milestones = [ { level: 5, questId: 423783, packageStructure: "single-package-contents-unverified", choices: [ ["cloth", 209015], ["leather", 209024], ["chain", 209025], ] }, { level: 30, questId: 423771, packageStructure: "battlefield-package-contents-unverified", choices: [ ["armor", 208802], ["chain", 208803], ["leather", 208804], ["cloth", 208805], ] }, { level: 50, questId: 424596, packageStructure: "named-subpackages-links-unverified", choices: [ ["plate", 240384, 240385, 240386, 610363], ["chain", 240387, 240388, 240389, 610364], ["leather", 240390, 240391, 240392, 610365], ["cloth", 240393, 240394, 240395, 610366], ] }, { level: 55, questId: 424597, packageStructure: "named-subpackages-links-unverified", choices: [ ["plate", 240353, 240354, 240355, 610395], ["chain", 240356, 240357, 240358, 610396], ["leather", 240359, 240360, 240361, 610397], ["cloth", 240362, 240363, 240364, 610398], ] }, { level: 60, questId: 424598, packageStructure: "named-subpackages-links-unverified", choices: [ ["plate", 240341, 240342, 240343, 610391], ["chain", 240344, 240345, 240346, 610392], ["leather", 240347, 240348, 240349, 610393], ["cloth", 240350, 240351, 240352, 610394], ] }, { level: 65, questId: 424213, packageStructure: "named-subpackages-links-unverified", choices: [ ["plate", 240304, 240302, 240303, 610387], ["chain", 240307, 240305, 240306, 610388], ["leather", 240310, 240308, 240309, 610389], ["cloth", 240313, 240311, 240312, 610390], ] }, ]; const extracted = milestones.map(({ level, questId, packageStructure, choices }) => { const quest = record("questdetailobject.db", questId); if (quest.readInt32LE(0xc0) !== level) throw new Error(`Quest ${questId} level drifted.`); const rewardIds = Array.from({ length: 4 }, (_, index) => quest.readInt32LE(0x34c + index * 4)); if (rewardIds.slice(choices.length).some((id) => id !== 0)) { throw new Error(`Quest ${questId} has an unrecorded reward choice.`); } const packages = choices.map(([armorCategory, packageId, weaponPackageId, armorPackageId, matchingSuitId], index) => { if (rewardIds[index] !== packageId) throw new Error(`Quest ${questId} reward order drifted.`); for (const id of [packageId, weaponPackageId, armorPackageId].filter(Boolean)) record("itemobject.db", id); const suit = matchingSuitId ? record("suitobject.db", matchingSuitId) : null; const matchingArmorItems = suit ? Array.from({ length: 10 }, (_, member) => suit.readInt32LE(1224 + member * 4)) .filter((id) => id > 0) .map((itemId) => { const item = record("armorobject.db", itemId); if (item.readInt32LE(0x1a8) !== matchingSuitId) { throw new Error(`Armor ${itemId} lost suit ${matchingSuitId}.`); } const requiredLevel = item.readInt32LE(0x78); if (requiredLevel < 1 || requiredLevel > level) { throw new Error(`Armor ${itemId} has unexpected required level ${requiredLevel}.`); } return { itemId, name: sourceName(itemId), requiredLevel }; }) : []; if (suit && matchingArmorItems.length !== 7) throw new Error(`Suit ${matchingSuitId} should have seven armor pieces.`); return { armorCategory, packageId, packageName: sourceName(packageId), ...(weaponPackageId ? { weaponPackageId, weaponPackageName: sourceName(weaponPackageId) } : {}), ...(armorPackageId ? { armorPackageId, armorPackageName: sourceName(armorPackageId) } : {}), ...(suit ? { matchingSuitId, matchingSuitName: sourceName(matchingSuitId), matchingArmorItems } : {}), }; }); return { level, questId, questName: sourceName(questId), packageStructure, packages }; }); const output = { schemaVersion: 2, contentStatus: "quest-rewards-confirmed-package-expansion-unverified", sourceFiles: Object.fromEntries(files.map((name) => [name, createHash("sha256").update(buffers[name]).digest("hex")])), milestones: extracted, }; await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`); console.log(`Exported ${extracted.length} RoM leveling milestones and ${extracted.flatMap((entry) => entry.packages).length} package choices.`);