269 lines
13 KiB
JavaScript
269 lines
13 KiB
JavaScript
#!/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.`);
|