Files
healer-man/scripts/runewaker-pipeline/export-rom-heirloom-reference.mjs

269 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { projectRoot, sourceRootFor } from "./lib/recipe.mjs";
const sourceRoot = sourceRootFor({
source: { rootEnvironmentVariable: "RUNEWAKER_ROOT", relativeDefault: "../rom-pvt-server-backups/Runewaker" },
});
const dataRoot = path.join(sourceRoot, "Resource", "data");
const outputPath = path.join(projectRoot, "src", "game", "generated", "romHeirloomReference.json");
const packagePath = path.join(projectRoot, "src", "game", "generated", "romLevelingGearPackages.json");
const files = ["armorobject.db", "weaponobject.db", "addpowerobject.db", "string_enus.db"];
const buffers = Object.fromEntries(await Promise.all(files.map(async (name) => (
[name, await readFile(path.join(dataRoot, name))]
))));
const packageCatalog = JSON.parse(await readFile(packagePath, "utf8"));
const RECORD_START = 140;
const RECORD_SIZE = 1776;
const ATTRIBUTE_RECORD_SIZE = 372;
const QUALITY_GOOD_BLUE = 2;
const STAT_TYPES = Object.freeze({
2: "strength", 3: "stamina", 4: "intellect", 5: "wisdom", 6: "agility",
8: "health", 9: "mana", 10: "healthRecovery", 11: "manaRecovery",
12: "physicalAttack", 13: "physicalDefense", 14: "magicalDefense", 15: "magicalAttack",
16: "physicalHit", 17: "dodge", 18: "physicalCritical", 20: "magicalCritical",
22: "parry", 25: "weaponDamage", 148: "magicalDamage", 150: "healingPower",
191: "magicalWeaponDamage", 195: "magicalHit",
});
const STAT_KEYS = [...new Set(Object.values(STAT_TYPES))];
const ARMOR_SLOTS = { head: 0, hands: 1, feet: 2, chest: 3, legs: 4, back: 5, waist: 6, shoulder: 7 };
const COMMON_SLOTS = Object.keys(ARMOR_SLOTS);
const PROFILES = {
"rom-knight": { armorType: 0, weaponType: 5, weaponPos: 2, offHand: "shield", weights: { stamina: 5, health: 0.2, physicalDefense: 0.12, magicalDefense: 0.06, parry: 2, physicalAttack: 0.15 } },
"rom-priest": { armorType: 3, weaponType: 7, weaponPos: 3, weights: { wisdom: 5, intellect: 2, healingPower: 1.5, mana: 0.1, magicalAttack: 0.3, magicalDefense: 0.02 } },
"rom-scout": { armorType: 2, weaponType: 2, weaponPos: 2, rangedType: 11, weights: { strength: 3, agility: 2, physicalAttack: 1, weaponDamage: 1, physicalHit: 1, physicalCritical: 1 } },
"rom-rogue": { armorType: 2, weaponType: 2, weaponPos: 2, offHand: "dagger", weights: { agility: 3, strength: 2, physicalAttack: 1, weaponDamage: 1, physicalHit: 1, physicalCritical: 1 } },
"rom-mage": { armorType: 3, weaponType: 7, weaponPos: 3, weights: { intellect: 5, wisdom: 1, magicalAttack: 1, magicalDamage: 1, magicalWeaponDamage: 1, magicalCritical: 1 } },
};
function indexNames(buffer) {
const text = buffer.toString("latin1");
const positions = new Map();
for (const match of text.matchAll(/Sys(\d+)_name\0/g)) positions.set(Number(match[1]), match.index + match[0].length);
return positions;
}
const namePositions = indexNames(buffers["string_enus.db"]);
function sourceName(id) {
const buffer = buffers["string_enus.db"];
const position = namePositions.get(id);
if (position === undefined) throw new Error(`Missing English item name for ${id}.`);
for (let offset = position; offset < Math.min(buffer.length - 4, position + 600); offset += 1) {
const length = buffer.readInt32LE(offset);
if (length < 2 || length > 300 || offset + 4 + length > buffer.length) continue;
const value = buffer.subarray(offset + 4, offset + 3 + length);
if (buffer[offset + 3 + length] !== 0 || value.includes(0)) continue;
try { return new TextDecoder("utf-8", { fatal: true }).decode(value); } catch { /* metadata before the text */ }
}
throw new Error(`Could not decode English item name for ${id}.`);
}
function effectStats(buffer, offset, unmappedEffects = []) {
const stats = {};
for (let effect = 0; effect < 10; effect += 1) {
const type = buffer.readInt32LE(offset + 0xd8 + effect * 4);
const value = buffer.readInt32LE(offset + 0x100 + effect * 4);
if (type === 7 && value > 0) {
for (const key of ["strength", "stamina", "intellect", "wisdom", "agility"]) {
stats[key] = (stats[key] ?? 0) + value;
}
continue;
}
const key = STAT_TYPES[type];
if (key && value > 0) stats[key] = (stats[key] ?? 0) + value;
else if (type && value > 0) unmappedEffects.push({ type, value });
}
return stats;
}
const attributeBuffer = buffers["addpowerobject.db"];
const attributeCount = attributeBuffer.readInt32LE(132);
if (attributeCount < 1 || RECORD_START + attributeCount * ATTRIBUTE_RECORD_SIZE > attributeBuffer.length) {
throw new Error("addpowerobject.db fixed-record layout changed.");
}
const attributes = new Map();
for (let index = 0; index < attributeCount; index += 1) {
const offset = RECORD_START + index * ATTRIBUTE_RECORD_SIZE;
if (attributeBuffer.readInt32LE(offset + 4) !== 5) throw new Error(`Attribute object type changed at row ${index}.`);
const unmappedEffects = [];
const stats = effectStats(attributeBuffer, offset, unmappedEffects);
attributes.set(attributeBuffer.readInt32LE(offset), { stats, unmappedEffects });
}
function sourceItems(name) {
const buffer = buffers[name];
const count = buffer.readInt32LE(132);
if (count < 1 || RECORD_START + count * RECORD_SIZE > buffer.length) {
throw new Error(`${name} fixed-record layout changed.`);
}
const items = [];
for (let index = 0; index < count; index += 1) {
const offset = RECORD_START + index * RECORD_SIZE;
if (buffer.readInt32LE(offset + 4) !== 2) throw new Error(`${name} object type changed at row ${index}.`);
const level = buffer.readInt32LE(offset + 0x78);
const quality = buffer.readInt32LE(offset + 0x60);
if (level < 1 || level > 79 || quality !== QUALITY_GOOD_BLUE) continue;
const itemId = buffer.readInt32LE(offset);
const unmappedEffects = [];
const baseStats = effectStats(buffer, offset, unmappedEffects);
const fixedStats = {};
const fixedAttributeIds = [];
const randomAttributeIds = [];
const unresolvedAttributeIds = [];
for (let effect = 0; effect < 6; effect += 1) {
const attributeId = buffer.readInt32LE(offset + 0x134 + effect * 8);
const rate = buffer.readInt32LE(offset + 0x138 + effect * 8);
if (!attributeId) continue;
if (!attributes.has(attributeId)) {
unresolvedAttributeIds.push(attributeId);
continue;
}
if (rate !== 100_000) {
randomAttributeIds.push(attributeId);
continue;
}
fixedAttributeIds.push(attributeId);
const attribute = attributes.get(attributeId);
for (const [key, value] of Object.entries(attribute.stats)) {
fixedStats[key] = (fixedStats[key] ?? 0) + value;
}
unmappedEffects.push(...attribute.unmappedEffects.map((entry) => ({ ...entry, attributeId })));
}
const stats = { ...baseStats };
for (const [key, value] of Object.entries(fixedStats)) stats[key] = (stats[key] ?? 0) + value;
items.push({
itemId,
name: sourceName(itemId),
level,
quality,
sourceCatalog: name,
type: buffer.readInt32LE(offset + 0xd0),
position: buffer.readInt32LE(offset + 0xd4),
suitId: buffer.readInt32LE(offset + 0x1a8),
...(name === "weaponobject.db" ? { speedTenths: buffer.readInt32LE(offset + 0x1b0) } : {}),
baseStats,
fixedStats,
fixedAttributeIds,
...(unmappedEffects.length ? { unmappedEffects } : {}),
...(randomAttributeIds.length ? { randomAttributeIds } : {}),
...(unresolvedAttributeIds.length ? { unresolvedAttributeIds } : {}),
stats,
});
}
return items;
}
const items = [...sourceItems("armorobject.db"), ...sourceItems("weaponobject.db")]
.sort((left, right) => left.level - right.level || left.itemId - right.itemId);
const byId = new Map(items.map((item) => [item.itemId, item]));
const packageAnchors = new Map();
for (const milestone of packageCatalog.milestones) {
if (milestone.level < 50 || milestone.level > 65) continue;
for (const choice of milestone.packages) {
for (const member of choice.matchingArmorItems ?? []) {
const item = byId.get(member.itemId);
if (!item || item.quality !== 2) throw new Error(`Package suit member ${member.itemId} is not Good quality source gear.`);
if (item.unresolvedAttributeIds?.length || item.unmappedEffects?.length) continue;
packageAnchors.set(`${milestone.level}:${item.type}:${item.position}`, {
...item,
packageId: choice.packageId,
packageLevel: milestone.level,
});
}
}
}
function medianItem(candidates, weights) {
const scored = candidates.map((item) => ({
item,
score: Object.entries(weights).reduce((sum, [key, weight]) => sum + (item.stats[key] ?? 0) * weight, 0),
})).sort((left, right) => left.score - right.score || left.item.itemId - right.item.itemId);
return scored[Math.floor((scored.length - 1) / 2)].item;
}
function referenceCurve(candidates, weights, packageType = null, packagePosition = null) {
if (!candidates.length) throw new Error("A RoM heirloom slot has no Good quality source references.");
const byLevel = new Map();
for (const item of candidates) {
const group = byLevel.get(item.level) ?? [];
group.push(item);
byLevel.set(item.level, group);
}
const anchors = new Map([...byLevel].map(([level, group]) => [level, medianItem(group, weights)]));
if (packageType !== null) for (const level of [50, 55, 60, 65]) {
const item = packageAnchors.get(`${level}:${packageType}:${packagePosition}`);
if (item) anchors.set(level, item);
}
const anchorLevels = [...anchors.keys()].sort((left, right) => left - right);
const result = [];
const relevant = new Set([
...Object.keys(weights), "stamina", "health", "physicalDefense", "magicalDefense",
"weaponDamage", "magicalWeaponDamage",
]);
const relevantKeys = STAT_KEYS.filter((key) => relevant.has(key));
const previous = Object.fromEntries(relevantKeys.map((key) => [key, 0]));
for (let level = 1; level <= 79; level += 1) {
const lowerLevel = anchorLevels.filter((entry) => entry <= level).at(-1);
const upperLevel = anchorLevels.find((entry) => entry >= level);
const lower = anchors.get(lowerLevel ?? upperLevel);
const upper = anchors.get(upperLevel ?? lowerLevel);
const blend = lowerLevel === undefined ? level / upperLevel
: upperLevel === undefined ? level / lowerLevel
: lowerLevel === upperLevel ? 0 : (level - lowerLevel) / (upperLevel - lowerLevel);
const stats = {};
for (const key of relevantKeys) {
const value = lowerLevel === undefined ? (upper.stats[key] ?? 0) * blend
: upperLevel === undefined ? (lower.stats[key] ?? 0) * blend
: (lower.stats[key] ?? 0) * (1 - blend) + (upper.stats[key] ?? 0) * blend;
previous[key] = Math.max(previous[key], value > 0 ? Math.max(1, Math.round(value)) : 0);
if (previous[key] > 0) stats[key] = previous[key];
}
result.push({
level,
stats,
referenceItemIds: [...new Set([lower.itemId, upper.itemId])],
...(anchors.get(level)?.packageId ? { packageId: anchors.get(level).packageId } : {}),
...(lower.speedTenths || upper.speedTenths ? { speedTenths: lower.speedTenths || upper.speedTenths } : {}),
});
}
return result;
}
function armorCandidates(type, position) {
return items.filter((item) => item.sourceCatalog === "armorobject.db" && item.type === type && item.position === position && !item.unresolvedAttributeIds?.length && !item.unmappedEffects?.length);
}
function weaponCandidates(type, position) {
return items.filter((item) => item.sourceCatalog === "weaponobject.db" && item.type === type && item.position === position && !item.unresolvedAttributeIds?.length && !item.unmappedEffects?.length);
}
const profiles = Object.fromEntries(Object.entries(PROFILES).map(([classId, profile]) => {
const curves = {};
for (const slot of COMMON_SLOTS) {
const armorType = slot === "back" ? 3 : profile.armorType;
curves[slot] = referenceCurve(armorCandidates(armorType, ARMOR_SLOTS[slot]), profile.weights, armorType, ARMOR_SLOTS[slot]);
}
for (const [slot, position] of [["neck", 8], ["finger", 9], ["trinket", 10]]) {
curves[slot] = referenceCurve(armorCandidates(7, position), profile.weights);
}
curves["main-hand"] = referenceCurve(weaponCandidates(profile.weaponType, profile.weaponPos), profile.weights);
if (profile.offHand === "shield") curves["off-hand"] = referenceCurve(armorCandidates(5, 11), profile.weights);
if (profile.offHand === "dagger") curves["off-hand"] = referenceCurve(weaponCandidates(2, 2), profile.weights);
if (profile.rangedType) curves.ranged = referenceCurve(weaponCandidates(profile.rangedType, 5), profile.weights);
return [classId, curves];
}));
const output = {
schemaVersion: 1,
status: "source-base-and-fixed-effects-with-synthetic-interpolation",
sourceFiles: Object.fromEntries(files.map((name) => [name, createHash("sha256").update(buffers[name]).digest("hex")])),
quality: { sourceValue: QUALITY_GOOD_BLUE, name: "Good", color: "blue" },
statTypeMap: STAT_TYPES,
sourceItems: items,
profiles,
};
await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`);
console.log(`Exported ${items.length} Good source items (${items.filter((item) => item.unresolvedAttributeIds?.length).length} with unresolved attributes) and ${Object.keys(profiles).length} RoM heirloom curves at levels 1–79.`);