Files
healer-man/scripts/runewaker-pipeline/export-rom-native-npc-equipment.mjs
T
2026-10-04 13:13:35 -04:00

225 lines
11 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { projectRoot, readJson, sourceRootFor } from "./lib/recipe.mjs";
const DATA_START = 140;
const NPC_RECORD_SIZE = 2224;
const ITEM_RECORD_SIZE = 1776;
const REFINE_RECORD_SIZE = 280;
const NPC_OBJECT_TYPE = 1;
const ITEM_OBJECT_TYPE = 2;
const REFINE_OBJECT_TYPE = 22;
const NPC_EQUIPMENT = Object.freeze({
mainHand: { start: 412, count: 5 },
offHand: { start: 432, count: 5 },
bow: { start: 1260, count: 1 },
ornament: { start: 1648, count: 1 },
});
const ITEM_FIELDS = Object.freeze({
durable: 0x54,
quality: 0x60,
level: 0x78,
gearType: 0xd0,
gearPosition: 0xd4,
eqTypes: 0xd8,
eqValues: 0x100,
dropAbilities: 0x134,
attackRange: 0x1ac,
attackSpeed: 0x1b0,
refineTableId: 0x3d0,
refineLevel: ITEM_RECORD_SIZE - 12,
addPowerQuality: ITEM_RECORD_SIZE - 8,
collectionNpc: ITEM_RECORD_SIZE - 4,
});
const REFINE_FIELDS = Object.freeze({
type: 0xb4,
level: 0xb8,
eqTypes: 0xbc,
eqValues: 0xe4,
exEqPowerRate: 0x114,
});
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
const int32 = (bytes, offset) => bytes.readInt32LE(offset);
const checkTable = (bytes, recordSize, objectType, label) => {
const count = int32(bytes, 132);
if (!Number.isInteger(count) || count <= 0 || DATA_START + count * recordSize > bytes.length) {
throw new Error(`${label} fixed-record layout changed.`);
}
for (const index of [0, Math.floor(count / 2), count - 1]) {
if (int32(bytes, DATA_START + index * recordSize + 4) !== objectType) {
throw new Error(`${label} object type sentinel changed.`);
}
}
return count;
};
const recipeDirectory = path.join(projectRoot, "scripts/runewaker-pipeline/recipes");
const recipes = (await readdir(recipeDirectory)).filter((name) => name.endsWith("-population.json")).sort();
if (!recipes.length) throw new Error("No population recipes found.");
const recipe = await readJson(path.join(recipeDirectory, recipes[0]));
const sourceRoot = sourceRootFor(recipe);
const dataRoot = path.join(sourceRoot, recipe.source.resourceRoot ?? "Resource", "data");
const filenames = ["npcobject.db", "armorobject.db", "weaponobject.db", "eqrefineabilityobject.db"];
const buffers = Object.fromEntries(await Promise.all(filenames.map(async (filename) => (
[filename, await readFile(path.join(dataRoot, filename))]
))));
const npcCount = checkTable(buffers["npcobject.db"], NPC_RECORD_SIZE, NPC_OBJECT_TYPE, "NPCObjectDB");
const itemCounts = Object.fromEntries(["armorobject.db", "weaponobject.db"].map((filename) => (
[filename, checkTable(buffers[filename], ITEM_RECORD_SIZE, ITEM_OBJECT_TYPE, filename)]
)));
const refineCount = checkTable(buffers["eqrefineabilityobject.db"], REFINE_RECORD_SIZE, REFINE_OBJECT_TYPE, "EqRefineAbilityObjectDB");
const nativeCombatData = await readJson(path.join(projectRoot, "src/game/generated/romNativeCombatData.generated.json"));
if (!Array.isArray(nativeCombatData.npcs) || nativeCombatData.npcs.length === 0) {
throw new Error("Native combat NPC catalog is empty.");
}
const npcSourceOffsets = new Map();
for (let index = 0; index < npcCount; index += 1) {
const offset = DATA_START + index * NPC_RECORD_SIZE;
if (int32(buffers["npcobject.db"], offset + 4) !== NPC_OBJECT_TYPE) continue;
const npcId = int32(buffers["npcobject.db"], offset);
if (npcId > 0) npcSourceOffsets.set(npcId, offset);
}
const sourceItemsById = new Map();
for (const filename of ["armorobject.db", "weaponobject.db"]) {
const bytes = buffers[filename];
for (let index = 0; index < itemCounts[filename]; index += 1) {
const offset = DATA_START + index * ITEM_RECORD_SIZE;
if (int32(bytes, offset + 4) !== ITEM_OBJECT_TYPE) throw new Error(`${filename} object type changed at ${index}.`);
const itemId = int32(bytes, offset);
if (itemId <= 0) continue;
if (sourceItemsById.has(itemId)) throw new Error(`Duplicate source item ID ${itemId} across equipment catalogs.`);
const eqPairs = Array.from({ length: 10 }, (_, slot) => ({
slot,
eqType: int32(bytes, offset + ITEM_FIELDS.eqTypes + slot * 4),
value: int32(bytes, offset + ITEM_FIELDS.eqValues + slot * 4),
}));
const dropAbilityPairs = Array.from({ length: 6 }, (_, slot) => ({
slot,
attributeId: int32(bytes, offset + ITEM_FIELDS.dropAbilities + slot * 8),
rate: int32(bytes, offset + ITEM_FIELDS.dropAbilities + slot * 8 + 4),
}));
sourceItemsById.set(itemId, {
sourceItemId: itemId,
sourceCatalog: filename,
sourceRecordIndex: index,
objectKind: "equipment-item",
objectType: int32(bytes, offset + 4),
quality: int32(bytes, offset + ITEM_FIELDS.quality),
itemLevel: int32(bytes, offset + ITEM_FIELDS.level),
gearType: int32(bytes, offset + ITEM_FIELDS.gearType),
gearPosition: int32(bytes, offset + ITEM_FIELDS.gearPosition),
eqTypeValuePairs: eqPairs,
dropAbilityIdRatePairs: dropAbilityPairs,
durable: int32(bytes, offset + ITEM_FIELDS.durable),
refineTableId: int32(bytes, offset + ITEM_FIELDS.refineTableId),
attackRange: filename === "weaponobject.db" ? int32(bytes, offset + ITEM_FIELDS.attackRange) : null,
attackSpeed: filename === "weaponobject.db" ? int32(bytes, offset + ITEM_FIELDS.attackSpeed) : null,
refineLevel: int32(bytes, offset + ITEM_FIELDS.refineLevel),
addPowerQuality: int32(bytes, offset + ITEM_FIELDS.addPowerQuality),
collectionNpcId: int32(bytes, offset + ITEM_FIELDS.collectionNpc),
});
}
}
const referencedItemIds = new Set();
const npcs = nativeCombatData.npcs.map((catalogNpc) => {
const offset = npcSourceOffsets.get(catalogNpc.npcId);
if (offset === undefined) throw new Error(`Missing NPCObjectDB row ${catalogNpc.npcId}.`);
const bytes = buffers["npcobject.db"];
const candidates = (slot) => Array.from({ length: slot.count }, (_, candidateIndex) => {
const sourceItemId = int32(bytes, offset + slot.start + candidateIndex * 4);
if (sourceItemId > 0) referencedItemIds.add(sourceItemId);
return { candidateIndex, sourceItemId, selectionWeightPercent: 20 };
});
const bowEquipmentId = int32(bytes, offset + NPC_EQUIPMENT.bow.start);
const ornamentEquipmentId = int32(bytes, offset + NPC_EQUIPMENT.ornament.start);
if (bowEquipmentId > 0) referencedItemIds.add(bowEquipmentId);
if (ornamentEquipmentId > 0) referencedItemIds.add(ornamentEquipmentId);
return {
npcId: catalogNpc.npcId,
sourceRecordIndex: (offset - DATA_START) / NPC_RECORD_SIZE,
mainHandCandidates: candidates(NPC_EQUIPMENT.mainHand),
offHandCandidates: candidates(NPC_EQUIPMENT.offHand),
bowEquipmentId,
ornamentEquipmentId,
};
});
const referencedRefineIds = new Set();
for (const itemId of referencedItemIds) {
const item = sourceItemsById.get(itemId);
if (!item || item.refineLevel <= 0) continue;
const level = Math.min(item.refineLevel, 20);
for (let refineLevel = 1; refineLevel <= level; refineLevel += 1) {
referencedRefineIds.add(item.refineTableId + refineLevel - 1);
}
}
const refineById = new Map();
const refineBytes = buffers["eqrefineabilityobject.db"];
for (let index = 0; index < refineCount; index += 1) {
const offset = DATA_START + index * REFINE_RECORD_SIZE;
if (int32(refineBytes, offset + 4) !== REFINE_OBJECT_TYPE) {
throw new Error(`eqrefineabilityobject.db object type changed at ${index}.`);
}
const id = int32(refineBytes, offset);
if (!referencedRefineIds.has(id)) continue;
refineById.set(id, {
refineObjectId: id,
sourceRecordIndex: index,
refineType: int32(refineBytes, offset + REFINE_FIELDS.type),
refineLevel: int32(refineBytes, offset + REFINE_FIELDS.level),
eqTypeValuePairs: Array.from({ length: 10 }, (_, slot) => ({
slot,
eqType: int32(refineBytes, offset + REFINE_FIELDS.eqTypes + slot * 4),
value: int32(refineBytes, offset + REFINE_FIELDS.eqValues + slot * 4),
})),
exEqPowerRate: int32(refineBytes, offset + REFINE_FIELDS.exEqPowerRate),
});
}
const refineItemsById = Object.fromEntries([...referencedRefineIds].sort((left, right) => left - right)
.map((id) => [id, refineById.get(id) ?? null]));
const unresolvedRefineIds = Object.entries(refineItemsById).filter(([, item]) => item === null).map(([id]) => Number(id));
const itemsById = {};
for (const itemId of [...referencedItemIds].sort((left, right) => left - right)) {
itemsById[itemId] = sourceItemsById.get(itemId) ?? null;
}
const unresolvedItemIds = Object.entries(itemsById).filter(([, item]) => item === null).map(([id]) => Number(id));
const output = {
schemaVersion: 1,
source: {
root: path.relative(projectRoot, sourceRoot).split(path.sep).join("/"),
files: Object.fromEntries(filenames.map((filename) => [filename, {
sha256: sha256(buffers[filename]),
recordStart: DATA_START,
recordSize: filename === "npcobject.db" ? NPC_RECORD_SIZE
: filename === "eqrefineabilityobject.db" ? REFINE_RECORD_SIZE : ITEM_RECORD_SIZE,
recordCount: filename === "npcobject.db" ? npcCount
: filename === "eqrefineabilityobject.db" ? refineCount : itemCounts[filename],
}])),
npcEquipmentOffsets: NPC_EQUIPMENT,
itemFieldOffsets: ITEM_FIELDS,
refineRecordSize: REFINE_RECORD_SIZE,
refineFieldOffsets: REFINE_FIELDS,
npcWearDefaults: { fieldDurable: 10000, fieldQuality: 100, fieldPowerQuality: 10, fieldRare: 0, inherentIds: [], runeIds: [] },
candidateRule: "RD_Setting.cxx selects MainHandEQ and SecHandEQ with rand() % 5; preserve every slot and duplicate at 20 percent each.",
simulatorSelectionPolicy: "Stable weighted selection over the source candidate slots using a caller-provided actor seed (or npcId default); each preserved 20-percent slot remains an independent source outcome and no item-stat averaging is performed.",
cxxSource: "Runewaker/SourceCodeVS2019/Libraries/Union/RoleData/ObjectStruct.h:GameObjectNpcStruct",
trailingItemFields: "ObjectStruct.h GameObjectItemStruct ends with RefineLv, AddPowerQuality, CollectionNPC; each is int32, so the 1776-byte fixed item row maps them to offsets 1764, 1768, 1772 respectively.",
itemOffset134Meaning: "GameObjectItemStruct::DropItemAbility[] (drop metadata, not equipment affixes). NPC spawn initialization does not populate runtime ItemFieldStruct inherent or rune slots.",
refineSource: "RoleDataEx::WearEQ reads RefineLv (capped at 20) and RoleDataEx::GetRefinAblility resolves Item.RefineTableID + level - 1.",
unresolvedItemIds,
unresolvedRefineIds,
},
npcs,
itemsById,
refineItemsById,
};
const outputPath = path.join(projectRoot, "src/game/generated/romNativeNpcEquipment.generated.json");
await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`);
console.log(`Wrote ${npcs.length} NPC rows and ${Object.keys(itemsById).length} referenced items to ${path.relative(projectRoot, outputPath)}.`);