forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
39458d5de6
commit
6cc1b9217f
203 files changed
+133929
-31271
No files matched your search
@@ -0,0 +1,224 @@
|
||||
#!/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)}.`);
|
||||
Reference in new issue
Block a user