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