Files
healer-man/scripts/runewaker-pipeline/export-rom-native-stat-rules.mjs
T

205 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 { fileURLToPath } from "node:url";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDirectory, "../..");
const sourceRoot = path.resolve(process.env.RUNEWAKER_ROOT ?? path.join(projectRoot, "..", "Runewaker"));
const dataRoot = path.join(sourceRoot, "Resource", "data");
const ruleOutput = path.join(projectRoot, "src", "game", "generated", "romNativeStatRules.generated.json");
const gearOutput = path.join(projectRoot, "src", "game", "generated", "romNativeGearCurves.generated.json");
const referencePath = path.join(projectRoot, "src", "game", "generated", "romHeirloomReference.json");
const baselinePath = path.join(projectRoot, "src", "game", "romBaseStatGrowth.generated.json");
const DATA_START = 140;
const RECORD_SIZE = 408;
const CLASS_ROWS = [
["rom-warrior", 1], ["rom-scout", 2], ["rom-rogue", 3], ["rom-mage", 4], ["rom-priest", 5],
["rom-knight", 6], ["rom-warden", 7], ["rom-druid", 8], ["rom-warlock", 9], ["rom-champion", 10],
];
const RACE_ROWS = [["rom-human", 0], ["rom-elf", 1], ["rom-dwarf", 2]];
const GENDER_ROWS = [["male", 0], ["female", 1]];
const STAT_KEYS = ["strength", "stamina", "intellect", "wisdom", "agility", "health", "mana"];
const ARG_FIELDS = [
"baseStrengthLevelOne", "baseStaminaLevelOne", "baseIntellectLevelOne", "baseWisdomLevelOne", "baseAgilityLevelOne", "baseHealthLevelOne", "baseManaLevelOne",
"baseStrengthGrowth", "baseStaminaGrowth", "baseIntellectGrowth", "baseWisdomGrowth", "baseAgilityGrowth", "baseHealthGrowth", "baseManaGrowth",
"strengthGrowthRate", "staminaGrowthRate", "intellectGrowthRate", "wisdomGrowthRate", "agilityGrowthRate", "healthGrowthRate", "manaGrowthRate",
"hpStrength", "hpStamina", "mpIntellect", "mpWisdom", "attackStrength", "attackIntellect", "attackAgility", "defenseStamina",
"baseHealthRecovery", "baseManaRecovery", "healthRecoveryStamina", "manaRecoveryWisdom", "magicalAttackIntellect", "magicalDefenseWisdom",
"baseMoveSpeed", "recoverySpMove", "recoverySpStop", "recoverySpAttack", "recoverySpUnderAttack", "recoverySpAttackTimeMove", "recoverySpAttackTimeStop",
"maxSP", "damageRate",
];
const FIX_FIELDS = [
"damageAttackCoefficient", "magicalDamageAttackCoefficient", "criticalLevelCoefficient", "damageLevelCoefficient", "missLevelCoefficient",
"secondHandMissCoefficient", "secondHandDamageCoefficient", "basePhysicalCritical", "basePhysicalCriticalPower", "baseMagicalCritical", "baseMagicalCriticalPower",
"magicDamageCoefficient", "magicMagicalDamageCoefficient", "dotMagicalDamageCoefficient", "healingMagicalDamageCoefficient", "healingDotMagicalDamageCoefficient",
"baseRateCoefficient", "baseResistCoefficient", "levelRateCoefficient", "levelResistCoefficient", "physicalHitBase", "physicalHitRange",
"physicalHitAgility", "physicalDodgeAgility", "magicalHitBase", "magicalHitRange", "magicalHitWisdom", "magicalDodgeAgility", "magicalDodgeWisdom",
"phantomRankCoefficient", "phantomLevelCoefficient",
];
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
function cleanFloat(value) {
if (!Number.isFinite(value)) throw new Error("Native source table contains a non-finite float.");
return Number(value.toFixed(5));
}
function database(buffer, name, recordSize) {
const count = buffer.readInt32LE(132);
if (count <= 0 || DATA_START + count * recordSize > buffer.length) throw new Error(`${name} fixed-record layout changed.`);
return count;
}
function row(buffer, index, expectedId, label) {
const offset = DATA_START + index * RECORD_SIZE;
if (offset + RECORD_SIZE > buffer.length
|| buffer.readInt32LE(offset) !== expectedId
|| buffer.readInt32LE(offset + 4) !== expectedId) {
throw new Error(`${label} row sentinel drifted (expected ${expectedId}).`);
}
return Array.from({ length: 100 }, (_, field) => cleanFloat(buffer.readFloatLE(offset + 8 + field * 4)));
}
function named(values, names) {
return Object.fromEntries(names.map((name, index) => [name, values[index]]));
}
function validateGrowthMirror(variants, baseline) {
for (const [classId] of CLASS_ROWS) for (const [raceId] of RACE_ROWS) for (const [gender] of GENDER_ROWS) {
const row = variants[classId][raceId][gender];
for (const [statIndex, stat] of STAT_KEYS.entries()) {
if (raceId !== "rom-human" || gender !== "male") continue;
const expected = baseline[classId][stat];
const actual = row.growth[stat];
for (const key of ["levelOne", "flatGrowth", "percentGrowth"]) {
if (Math.abs(actual[key] - expected[key]) > 0.00001) {
throw new Error(`${classId}.${stat}.${key} no longer matches romBaseStatGrowth.generated.json.`);
}
}
if (statIndex < 0) throw new Error("Unreachable stat index.");
}
}
}
const filenames = ["racetable.db", "sextable.db", "voctable.db", "fixtable.db"];
const buffers = Object.fromEntries(await Promise.all(filenames.map(async (name) => [
name, await readFile(path.join(dataRoot, name)),
])));
database(buffers["racetable.db"], "racetable.db", RECORD_SIZE);
database(buffers["sextable.db"], "sextable.db", RECORD_SIZE);
database(buffers["voctable.db"], "voctable.db", RECORD_SIZE * 2);
database(buffers["fixtable.db"], "fixtable.db", RECORD_SIZE);
const classRows = Object.fromEntries(CLASS_ROWS.map(([classId, sourceClassId]) => {
const args = row(buffers["voctable.db"], sourceClassId * 2 + 1, 301000 + sourceClassId * 2 + 1, `vocation ${classId}`);
return [classId, { sourceClassId, args: named(args.slice(0, ARG_FIELDS.length), ARG_FIELDS) }];
}));
const raceRows = Object.fromEntries(RACE_ROWS.map(([raceId, sourceRaceId]) => [
raceId, { sourceRaceId, args: named(row(buffers["racetable.db"], sourceRaceId, 300000 + sourceRaceId, `race ${raceId}`).slice(0, ARG_FIELDS.length), ARG_FIELDS) },
]));
const genderRows = Object.fromEntries(GENDER_ROWS.map(([gender, sourceGenderId]) => [
gender, { sourceGenderId, args: named(row(buffers["sextable.db"], sourceGenderId, 302000 + sourceGenderId, `gender ${gender}`).slice(0, ARG_FIELDS.length), ARG_FIELDS) },
]));
const fixedRow = row(buffers["fixtable.db"], 1, 303001, "player fixed formula");
const playerFix = named(fixedRow.slice(0, FIX_FIELDS.length), FIX_FIELDS);
const variants = {};
for (const [classId] of CLASS_ROWS) {
variants[classId] = {};
for (const [raceId] of RACE_ROWS) {
variants[classId][raceId] = {};
for (const [gender] of GENDER_ROWS) {
const classArgs = classRows[classId].args;
const raceArgs = raceRows[raceId].args;
const genderArgs = genderRows[gender].args;
const growth = Object.fromEntries(STAT_KEYS.map((stat, index) => [stat, {
levelOne: cleanFloat(classArgs[ARG_FIELDS[index]] + raceArgs[ARG_FIELDS[index]] + genderArgs[ARG_FIELDS[index]]),
flatGrowth: cleanFloat(classArgs[ARG_FIELDS[index + 7]] + raceArgs[ARG_FIELDS[index + 7]] + genderArgs[ARG_FIELDS[index + 7]]),
percentGrowth: cleanFloat(classArgs[ARG_FIELDS[index + 14]] + raceArgs[ARG_FIELDS[index + 14]] + genderArgs[ARG_FIELDS[index + 14]]),
}]));
const coefficients = Object.fromEntries(ARG_FIELDS.slice(21, 42).map((field) => [
field,
cleanFloat(classArgs[field] + raceArgs[field] + genderArgs[field]),
]));
coefficients.damageRate = cleanFloat(classArgs.damageRate * raceArgs.damageRate * genderArgs.damageRate);
variants[classId][raceId][gender] = { growth, coefficients };
}
}
}
validateGrowthMirror(variants, JSON.parse(await readFile(baselinePath, "utf8")));
if (Math.abs(playerFix.damageAttackCoefficient) > 1e-8
|| Math.abs(playerFix.magicalDamageAttackCoefficient) > 1e-8
|| Math.abs(playerFix.healingMagicalDamageCoefficient - 0.5) > 1e-8) {
throw new Error("Player formula row changed: DMG/MDMG/heal MDMG mapping needs a source review.");
}
const ruleData = {
schemaVersion: 1,
source: {
project: "Runewaker client/server source data",
formulaFiles: [
"SourceCodeVS2019/Libraries/Union/RoleData/RoleAttr.cpp",
"SourceCodeVS2019/Libraries/Union/RoleData/ObjectStruct.h",
"SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp",
],
recordFiles: Object.fromEntries(filenames.map((name) => [name, sha256(buffers[name])])),
recordLayout: { dataStart: DATA_START, recordSize: RECORD_SIZE, vocationSubrecordSize: RECORD_SIZE },
playerFixRecordId: 303001,
raceRecordIds: Object.fromEntries(RACE_ROWS.map(([key, id]) => [key, 300000 + id])),
genderRecordIds: Object.fromEntries(GENDER_ROWS.map(([key, id]) => [key, 302000 + id])),
vocationRecordIds: Object.fromEntries(CLASS_ROWS.map(([key, id]) => [key, 301000 + id * 2 + 1])),
fieldOrder: "ObjectStruct.h FightArgumentStruct fields 0-43; player FIX fields 0-30",
},
classRows,
raceRows,
genderRows,
playerFix,
variants,
manaClassIds: ["rom-mage", "rom-priest", "rom-knight", "rom-warden", "rom-druid", "rom-warlock", "rom-champion"],
formulaNotes: {
secondaryBase: "Main base at Level + secondary base at Level_Sub divided by 10; runtime supplies the source secondary level.",
baseProgression: "For each level after 1: value = value * (1 + combined percent growth) + combined flat growth.",
maxHealth: "(base HP + STR*HpStrArg + STA*HpStaArg + flat MaxHP) * MaxHP rate; minimum 1.",
maxMana: "MP classes only: (base MP + INT*MPIntArg + WIS*MpMndArg + flat MaxMP) * MaxMP rate; minimum 0.",
physicalAttack: "(STR*AtkStrArg + INT*AtkIntArg + AGI*AtkAgiArg + flat ATK) * ATK rate; minimum 1.",
physicalDefense: "(STA*DefArg + flat DEF) * DEF rate; source also adds armor-category base-defense contributions not represented by this curve adapter.",
magicalDefense: "(WIS*MDefArg + flat MDEF) * MDEF rate; minimum 1.",
magicalAttack: "(INT*MAtkArg + flat MATK) * MATK rate; minimum 1.",
physicalDamage: "(ATK / DmgAtkArg when nonzero + flat DMG) * merged class/race/sex DmgRate * DMG rate; player FIX DmgAtkArg is zero.",
magicalDamage: "(MATK / MDmgMAtkArg when nonzero + flat MDMG) * MDMG rate; player FIX MDmgMAtkArg is zero.",
magicPower: "MPow is the native all-magic BodyRate, neutral 1; it is not MATK or MDMG.",
healer: "HolyHeal uses skill OrgPower, capped MND contribution, optional MDMG*Dmg_HealArg (player FIX Dmg_HealArg is 0.5), MPow*HealPowerRate, and HealPoint*SpellTime. Target HealAbsorbRate and HealAbsorb apply after the caster amount. Skill OrgPower/timing and other skill flags are outside this stat helper.",
secondaryGate: "The source formula assumes a present secondary vocation and uses Level_Sub; the level-10 unlock gate and same-level fallback are simulator policy.",
},
};
const reference = JSON.parse(await readFile(referencePath, "utf8"));
const legacyToNative = (stats) => {
const native = {};
const direct = [
"strength", "stamina", "intellect", "wisdom", "agility", "health", "mana", "healthRecovery", "manaRecovery",
"physicalAttack", "physicalDefense", "magicalDefense", "magicalAttack", "physicalHit", "magicalHit",
"physicalCritical", "magicalCritical", "parry",
];
for (const key of direct) if (stats[key] > 0) native[key] = stats[key];
if (stats.dodge > 0) native.physicalDodge = stats.dodge;
if (stats.weaponDamage > 0) native.physicalDamage = stats.weaponDamage;
if (stats.magicalDamage > 0) native.magicalDamagePoint = stats.magicalDamage;
if (stats.magicalWeaponDamage > 0) native.magicalDamage = stats.magicalWeaponDamage;
if (stats.healingPower > 0) native.healPoint = stats.healingPower;
return native;
};
const compactProfiles = Object.fromEntries(Object.entries(reference.profiles).map(([classId, slots]) => [
classId,
Object.fromEntries(Object.entries(slots).map(([slot, rows]) => [slot, rows.map((entry) => legacyToNative(entry.stats))])),
]));
const gearData = {
schemaVersion: 1,
status: "source-item stat references with synthetic class/slot selection and level interpolation",
sourceRecordSha256: reference.sourceFiles,
statTypeMap: {
"12": "physicalAttack", "13": "physicalDefense", "14": "magicalDefense", "15": "magicalAttack",
"16": "physicalHit", "17": "physicalDodge", "18": "physicalCritical", "20": "magicalCritical",
"22": "parry", "25": "physicalDamage", "148": "magicalDamagePoint", "150": "healPoint", "191": "magicalDamage", "195": "magicalHit",
},
profiles: compactProfiles,
};
await writeFile(ruleOutput, `${JSON.stringify(ruleData, null, 2)}\n`);
await writeFile(gearOutput, `${JSON.stringify(gearData)}\n`);
console.log(`Exported native formula rules for ${CLASS_ROWS.length} classes, ${RACE_ROWS.length} races, and ${GENDER_ROWS.length} genders; wrote compact gear curves for ${Object.keys(compactProfiles).length} reference classes.`);