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