Files
healer-man/scripts/runewaker-pipeline/export-leveling-gear-packages.mjs
T

155 lines
7.2 KiB
JavaScript

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