Files
healer-man/scripts/runewaker-pipeline/export-loot-catalog.mjs
T
2026-09-08 10:40:26 -04:00

402 lines
15 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import {
pipelineDirectory,
projectPath,
projectRoot,
readJson,
sourceRootFor,
} from "./lib/recipe.mjs";
const DATA_START = 140;
const COUNT_OFFSET = 132;
const NPC_RECORD_SIZE = 2224;
const NPC_OBJECT_TYPE = 1;
const NPC_DROP_START = 0xf4;
const NPC_DROP_COUNT = 15;
const TREASURE_RECORD_SIZE = 1516;
const TREASURE_OBJECT_TYPE = 20;
const TREASURE_NEED_LEVEL_DELTA = 0xac;
const TREASURE_ITEM_COUNT = 0xb0;
const TREASURE_ITEMS_START = 0xb4;
const TREASURE_MAX_ITEMS = 100;
const TREASURE_LUA_START = 0x5ac;
const TREASURE_LUA_LENGTH = 64;
const RATE_DENOMINATOR = 100_000;
const MAX_TREASURE_DEPTH = 12;
const recipesDirectory = path.join(pipelineDirectory, "recipes");
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))
)));
if (!recipes.length) throw new Error("No RuneWaker population recipes were found.");
const sourceRoot = sourceRootFor(recipes[0]);
const dataRoot = path.join(sourceRoot, recipes[0].source.resourceRoot ?? "Resource", "data");
const databaseNames = [
"npcobject.db",
"treasureobject.db",
"itemobject.db",
"armorobject.db",
"weaponobject.db",
"cardobject.db",
"runeobject.db",
"recipeobject.db",
"keyitemobject.db",
"string_enus.db",
];
const buffers = new Map(await Promise.all(databaseNames.map(async (name) => (
[name, await readFile(path.join(dataRoot, name))]
))));
function sha256(buffer) {
return createHash("sha256").update(buffer).digest("hex");
}
function int32(buffer, offset) {
return buffer.readInt32LE(offset);
}
function validateFixedCatalog(buffer, recordSize, objectType, 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) !== objectType) {
throw new Error(label + " object-type sentinel drifted.");
}
}
return count;
}
function detectRecordSize(buffer, label) {
const count = int32(buffer, COUNT_OFFSET);
const objectType = int32(buffer, DATA_START + 4);
for (let recordSize = 100; recordSize <= 20_000; recordSize += 4) {
if (DATA_START + recordSize * count > buffer.length) continue;
const sample = [1, 2, 3, Math.floor(count / 2), count - 1];
if (sample.every((index) => (
int32(buffer, DATA_START + index * recordSize + 4) === objectType
))) return { count, objectType, recordSize };
}
throw new Error(label + " fixed-record layout could not be detected safely.");
}
function indexCatalog(buffer, count, recordSize) {
const offsets = new Map();
for (let index = 0; index < count; index += 1) {
const offset = DATA_START + index * recordSize;
const id = int32(buffer, offset);
if (id > 0) offsets.set(id, offset);
}
return offsets;
}
const npc = buffers.get("npcobject.db");
const treasure = buffers.get("treasureobject.db");
const strings = buffers.get("string_enus.db");
const npcCount = validateFixedCatalog(npc, NPC_RECORD_SIZE, NPC_OBJECT_TYPE, "NPCObjectDB");
const treasureCount = validateFixedCatalog(
treasure, TREASURE_RECORD_SIZE, TREASURE_OBJECT_TYPE, "TreasureObjectDB",
);
const npcOffsets = indexCatalog(npc, npcCount, NPC_RECORD_SIZE);
const treasureOffsets = indexCatalog(treasure, treasureCount, TREASURE_RECORD_SIZE);
const leafObjects = new Map();
const leafCatalogs = [];
for (const name of databaseNames.filter((candidate) => ![
"npcobject.db", "treasureobject.db", "string_enus.db",
].includes(candidate))) {
const buffer = buffers.get(name);
const layout = detectRecordSize(buffer, name);
const offsets = indexCatalog(buffer, layout.count, layout.recordSize);
leafCatalogs.push({ name, ...layout, count: offsets.size });
for (const id of offsets.keys()) {
if (!leafObjects.has(id)) leafObjects.set(id, { catalog: name, objectType: layout.objectType });
}
}
const stringCache = new Map();
function stringValue(key) {
if (stringCache.has(key)) return stringCache.get(key);
const marker = Buffer.concat([Buffer.from(key, "ascii"), Buffer.from([0])]);
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 {
// Metadata can resemble a string length; continue to the next byte.
}
}
stringCache.set(key, null);
return null;
}
function fixedString(buffer, offset, length) {
const end = buffer.indexOf(0, offset);
const boundedEnd = end < 0 || end > offset + length ? offset + length : end;
return buffer.subarray(offset, boundedEnd).toString("utf8").trim();
}
function cleanProbability(value) {
return Number(Math.max(0, Math.min(1, value)).toFixed(12));
}
function blockedOutcome(objectId, chance, quantity, sourcePath, reason, constraints = {}) {
return {
objectId,
name: stringValue("Sys" + objectId + "_name") ?? "RuneWaker Object " + objectId,
sourceCatalog: null,
runtimeStatus: "blocked",
blockedReason: reason,
conditionalChance: cleanProbability(chance),
quantity,
sourcePath,
...constraints,
};
}
function expandObject(objectId, chance, quantity, sourcePath, stack = [], constraints = {}) {
if (chance <= 0 || quantity <= 0) return [];
const leaf = leafObjects.get(objectId);
if (leaf) {
return [{
objectId,
name: stringValue("Sys" + objectId + "_name") ?? "RuneWaker Item " + objectId,
sourceCatalog: leaf.catalog,
sourceObjectType: leaf.objectType,
runtimeStatus: "executable-generic-item",
blockedReason: null,
conditionalChance: cleanProbability(chance),
quantity,
sourcePath,
...constraints,
}];
}
const offset = treasureOffsets.get(objectId);
if (offset === undefined) {
return [blockedOutcome(
objectId, chance, quantity, sourcePath,
"Object is absent from the bounded item and treasure source catalogs.", constraints,
)];
}
if (stack.includes(objectId) || stack.length >= MAX_TREASURE_DEPTH) {
return [blockedOutcome(
objectId, chance, quantity, sourcePath,
"Treasure recursion is cyclic or exceeds the audited depth limit.", constraints,
)];
}
const luaCheck = fixedString(treasure, offset + TREASURE_LUA_START, TREASURE_LUA_LENGTH);
if (luaCheck) {
return [blockedOutcome(
objectId, chance, quantity, sourcePath,
"Treasure table requires unavailable Lua predicate " + luaCheck + ".",
{ ...constraints, luaCheck },
)];
}
const needLevelDelta = int32(treasure, offset + TREASURE_NEED_LEVEL_DELTA);
const nextConstraints = {
...constraints,
minimumLevelDelta: Math.max(constraints.minimumLevelDelta ?? -2_147_483_648, needLevelDelta),
};
const itemCount = int32(treasure, offset + TREASURE_ITEM_COUNT);
if (itemCount < 0 || itemCount > TREASURE_MAX_ITEMS) {
return [blockedOutcome(
objectId, chance, quantity, sourcePath,
"Treasure item count is outside the preserved 0..100 layout.", nextConstraints,
)];
}
const outcomes = [];
for (let index = 0; index < itemCount; index += 1) {
const itemOffset = offset + TREASURE_ITEMS_START + index * 12;
const childId = int32(treasure, itemOffset);
const childWeight = int32(treasure, itemOffset + 4);
const childQuantity = int32(treasure, itemOffset + 8);
if (childId <= 0 || childWeight <= 0 || childQuantity <= 0) continue;
outcomes.push(...expandObject(
childId,
chance * Math.min(RATE_DENOMINATOR, childWeight) / RATE_DENOMINATOR,
quantity * childQuantity,
[...sourcePath, objectId],
[...stack, objectId],
nextConstraints,
));
}
if (outcomes.length) return outcomes;
return [blockedOutcome(
objectId, chance, quantity, sourcePath,
"Treasure table has no positive, resolvable outcomes.", nextConstraints,
)];
}
const bosses = [];
for (const recipe of recipes) {
const runtimePrefix = recipe.runtimeIdPrefix ?? "rw-" + recipe.dungeonId;
for (const template of recipe.templates.filter((candidate) => candidate.classification === "boss")) {
const bossKey = runtimePrefix + "-" + template.id;
const npcOffset = npcOffsets.get(template.id);
if (npcOffset === undefined) {
bosses.push({
bossKey,
dungeonId: recipe.dungeonId,
sourceNpcId: template.id,
bossName: template.name,
sourceLevel: Number(template.level ?? 1),
status: "blocked",
blockedReasons: ["NPCObjectDB record is missing."],
rolls: [],
});
continue;
}
const rolls = [];
for (let slot = 0; slot < NPC_DROP_COUNT; slot += 1) {
const dropOffset = npcOffset + NPC_DROP_START + slot * 8;
const sourceObjectId = int32(npc, dropOffset);
const sourceRate = int32(npc, dropOffset + 4);
if (sourceObjectId <= 0 || sourceRate <= 0) continue;
const rootChance = cleanProbability(Math.min(RATE_DENOMINATOR, sourceRate) / RATE_DENOMINATOR);
const outcomes = expandObject(sourceObjectId, 1, 1, []);
rolls.push({
sourceSlot: slot + 1,
sourceObjectId,
sourceRatePer100000: sourceRate,
chancePerKill: rootChance,
outcomes,
});
}
const executable = rolls.flatMap((roll) => roll.outcomes)
.filter((outcome) => outcome.runtimeStatus === "executable-generic-item");
const blocked = rolls.flatMap((roll) => roll.outcomes)
.filter((outcome) => outcome.runtimeStatus === "blocked");
bosses.push({
bossKey,
dungeonId: recipe.dungeonId,
sourceNpcId: template.id,
bossName: template.name,
sourceLevel: Number(template.level ?? 1),
status: !rolls.length ? "no-source-drops" : blocked.length ? "partial" : "portable",
blockedReasons: [...new Set(blocked.map((outcome) => outcome.blockedReason))],
rolls,
executableOutcomeCount: executable.length,
blockedOutcomeCount: blocked.length,
});
}
}
bosses.sort((left, right) => left.dungeonId.localeCompare(right.dungeonId)
|| left.sourceNpcId - right.sourceNpcId);
const sourceFiles = databaseNames.map((name) => ({
file: projectPath(path.join(dataRoot, name)),
sha256: sha256(buffers.get(name)),
}));
const catalog = {
schemaVersion: 1,
status: "portable-with-explicit-fallbacks",
generatedFrom: {
sourceFiles,
npcDropAlgorithm: {
file: "SourceCodeVS2019/Server/NetGameServer/MainProc/Global.cpp",
firstRelevantLine: 4707,
rateDenominator: RATE_DENOMINATOR,
},
treasureAlgorithm: {
file: "SourceCodeVS2019/Libraries/Union/RoleData/RD_ItemProc.cxx",
firstRelevantLine: 3033,
rateDenominator: RATE_DENOMINATOR,
},
layout: "RuneWaker v0.20 fixed records; read-only export",
},
runtimePolicy: {
rollsAreIndependent: true,
treasureOutcomesAreMutuallyExclusive: true,
unknownObjectsExecute: false,
luaPredicatesExecute: false,
sourceItemStatsInvented: false,
importedItemsUseGenericNonEquipableInventoryRepresentation: true,
},
leafCatalogs,
bossCount: bosses.length,
bossWithSourceRollsCount: bosses.filter((boss) => boss.rolls.length).length,
portableBossCount: bosses.filter((boss) => boss.status === "portable").length,
partialBossCount: bosses.filter((boss) => boss.status === "partial").length,
noSourceDropsBossCount: bosses.filter((boss) => boss.status === "no-source-drops").length,
blockedBossCount: bosses.filter((boss) => boss.status === "blocked").length,
executableOutcomeCount: bosses.reduce((total, boss) => total + (boss.executableOutcomeCount ?? 0), 0),
blockedOutcomeCount: bosses.reduce((total, boss) => total + (boss.blockedOutcomeCount ?? 0), 0),
bosses,
};
const runtimeFile = path.join(projectRoot, "src", "game", "generated", "runewakerLootCatalog.json");
const publicFile = path.join(projectRoot, "public", "data", "runewaker-loot-catalog.json");
const reportFile = path.join(pipelineDirectory, "RUNEWAKER_LOOT_IMPORT_REPORT.json");
const reportMarkdownFile = path.join(pipelineDirectory, "RUNEWAKER_LOOT_IMPORT_REPORT.md");
await Promise.all([
mkdir(path.dirname(runtimeFile), { recursive: true }),
mkdir(path.dirname(publicFile), { recursive: true }),
]);
const serialized = JSON.stringify(catalog, null, 2) + "\n";
const reportSummary = {
...Object.fromEntries(Object.entries(catalog).filter(([key]) => key !== "bosses")),
bosses: bosses.map((boss) => ({
bossKey: boss.bossKey,
dungeonId: boss.dungeonId,
sourceNpcId: boss.sourceNpcId,
bossName: boss.bossName,
status: boss.status,
sourceRollCount: boss.rolls.length,
executableOutcomeCount: boss.executableOutcomeCount ?? 0,
blockedOutcomeCount: boss.blockedOutcomeCount ?? 0,
blockedReasons: boss.blockedReasons,
})),
};
const markdown = [
"# RuneWaker loot import report",
"",
"Generated from the preserved fixed-record databases. Source rates use the original 1-in-100,000 denominator.",
"",
`- Boss templates audited: ${catalog.bossCount}`,
`- Bosses with source rolls: ${catalog.bossWithSourceRollsCount}`,
`- Fully portable bosses: ${catalog.portableBossCount}`,
`- Partially portable bosses: ${catalog.partialBossCount}`,
`- Bosses with no authored drop slots: ${catalog.noSourceDropsBossCount}`,
`- Executable item outcomes: ${catalog.executableOutcomeCount}`,
`- Blocked outcomes: ${catalog.blockedOutcomeCount}`,
"",
"Unknown object relationships and Lua-gated treasure paths fail closed. Imported source items are collected as named, non-equipable generic items because the RuneWaker item-stat and equipment-slot conversion is not yet proven.",
"",
"| Dungeon | Boss | Status | Rolls | Executable | Blocked | Blocker |",
"| --- | --- | --- | ---: | ---: | ---: | --- |",
...reportSummary.bosses.map((boss) => (
`| ${boss.dungeonId} | ${boss.bossName.replaceAll("|", "\\|")} | ${boss.status} | ${boss.sourceRollCount} | ${boss.executableOutcomeCount} | ${boss.blockedOutcomeCount} | ${(boss.blockedReasons.join("; ") || "—").replaceAll("|", "\\|")} |`
)),
"",
].join("\n");
await Promise.all([
writeFile(runtimeFile, serialized, "utf8"),
writeFile(publicFile, serialized, "utf8"),
writeFile(reportFile, JSON.stringify(reportSummary, null, 2) + "\n", "utf8"),
writeFile(reportMarkdownFile, markdown, "utf8"),
]);
console.log("[runewaker-loot] exported " + catalog.bossCount + " bosses, "
+ catalog.executableOutcomeCount + " executable outcomes, and "
+ catalog.blockedOutcomeCount + " blocked outcomes.");
console.log("[runewaker-loot] " + projectPath(runtimeFile));