Add source-backed RoM heirloom reference curves

This commit is contained in:
phenom committed 2026-09-23 16:56:48 -04:00
1 parent d3cc27a54a
commit f4a7d33f74
9 files changed
+148524 -8

No files matched your search

@@ -0,0 +1,268 @@
#!/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.`);