Release Healer Man 0.1.21
This commit is contained in:
1 parent
699fc858c9
commit
f6f2375145
210 files changed
+976039
-6247
No files matched your search
@@ -0,0 +1,401 @@
|
||||
#!/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));
|
||||
Reference in new issue
Block a user