225 lines
11 KiB
JavaScript
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)}.`);
|