forked from phenom/healer-man
402 lines
15 KiB
JavaScript
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));
|