forked from phenom/healer-man
Add source-backed RoM heirloom reference curves
This commit is contained in:
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.`);
|
||||
Reference in new issue
Block a user