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