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