#!/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 magicCollectPath = path.join(dataRoot, "magiccollectobject.db"); const magicObjectPath = path.join(dataRoot, "magicobject.db"); const magicProcessPath = path.join(sourceRoot, "SourceCodeVS2019", "Server", "NetGameServer", "MainProc", "MagicProc", "MagicProcess.cpp"); const objectStructPath = path.join(sourceRoot, "SourceCodeVS2019", "Libraries", "Union", "RoleData", "ObjectStruct.h"); const periodicRankSourcePath = path.join(sourceRoot, "SourceCodeVS2019", "Libraries", "Union", "RoleData", "RD_Normal.cxx"); const periodicTickSourcePath = path.join(sourceRoot, "SourceCodeVS2019", "Libraries", "Union", "RoleData", "RD_ValueCal.cxx"); const catalogPath = path.join(projectRoot, "src", "game", "romPlayerCatalog.generated.json"); const nativeStatRulesPath = path.join(projectRoot, "src", "game", "generated", "romNativeStatRules.generated.json"); const outputPath = path.join(projectRoot, "src", "game", "generated", "romNativeHolyHealBindings.generated.json"); const DATA_START = 140; const COUNT_OFFSET = 132; const MAGIC_COLLECT_RECORD_SIZE = 1032; const MAGIC_OBJECT_RECORD_SIZE = 992; const HOLY_HEAL_CALCULATION_TYPE = 5; const HEALING_FORMULA_SOURCE = { orgPower: "DmgPower + DmgPower * DmgPower_SkillLVArg * MagicProc.MagicLv / 100", spellTime: "MagicCol.SpellTime, replaced by 1 when less than 1 or IsFixDmgEffect is set", mind: "MND * 0.3 * SpellTime; if OrgPower * 5 < this value use OrgPower * 6, else add this value", mdmg: "unless IsIgnoreDmgEffect, add MDMG * FIX.Dmg_HealArg once for fixed effects or multiplied by SpellTime otherwise", scale: "multiply by MPow * HealPowerRate, then add HealPoint * SpellTime", target: "unless MagicBase.SettingEx.IgnoreMagicAbsorb, multiply by 1 + HealAbsorbRate / 100 then add HealAbsorb; clamp below zero; undead reverses sign", random: "Power *= 1 - (Atk.Rand - Atk.Rand * 2 * rand() / 0x7fff) / 100", periodicHeal: "RD_Normal::CalAddDotMagicLv snapshots rank from positive magic HP DotMagic: DotTimes=max(1,int(actual EffectTime/Dot.Time)); DotBase=Base*(1+MagicLv*DotArg/100); Power=DotBase+ExRate*MDMG*int(SpellTime)*FIX.Dmg_HealDotArg/DotTimes; then MND cap/add, HealPowerRate and HealPoint*SpellTime/DotTimes, target absorb rate and absorb/DotTimes unless ignored, clamp below zero, then MagicPow(MagicType); buff Power is that helper's correction plus MagicLv. RD_ValueCal::BuffProc tick is int(-Base*(100+DotArg*buff.Power)/100), then AddHP(-Damage).", }; function sha256(buffer) { return createHash("sha256").update(buffer).digest("hex"); } function int32(buffer, offset) { return buffer.readInt32LE(offset); } function number32(buffer, offset) { const value = buffer.readFloatLE(offset); if (!Number.isFinite(value)) throw new Error(`Non-finite source float at 0x${offset.toString(16)}.`); return Number(value.toFixed(6)); } function records(buffer, recordSize, expectedType, label) { const count = int32(buffer, COUNT_OFFSET); if (count <= 0 || DATA_START + count * recordSize > buffer.length) { throw new Error(`${label} record count or record size does not match this exporter.`); } for (const index of [0, Math.floor(count / 2), count - 1]) { if (int32(buffer, DATA_START + index * recordSize + 4) !== expectedType) { throw new Error(`${label} type sentinel does not match this exporter.`); } } return Array.from({ length: count }, (_, index) => DATA_START + index * recordSize); } const [magicCollect, magicObject, magicProcess, objectStruct, periodicRankSource, periodicTickSource, catalogBuffer, nativeStatRulesBuffer] = await Promise.all([ readFile(magicCollectPath), readFile(magicObjectPath), readFile(magicProcessPath), readFile(objectStructPath), readFile(periodicRankSourcePath), readFile(periodicTickSourcePath), readFile(catalogPath), readFile(nativeStatRulesPath), ]); const catalog = JSON.parse(catalogBuffer.toString("utf8")); const nativeStatRules = JSON.parse(nativeStatRulesBuffer.toString("utf8")); const collectOffsets = new Map(records(magicCollect, MAGIC_COLLECT_RECORD_SIZE, 21, "magiccollectobject.db") .map((offset) => [int32(magicCollect, offset), offset])); const objectOffsets = new Map(records(magicObject, MAGIC_OBJECT_RECORD_SIZE, 3, "magicobject.db") .map((offset) => [int32(magicObject, offset), offset])); function rawComponents(collectOffset) { const componentIds = new Set(); for (let offset = 0x180; offset <= 0x2c0; offset += 4) { const id = int32(magicCollect, collectOffset + offset); if (objectOffsets.has(id)) componentIds.add(id); } return [...componentIds].sort((left, right) => left - right); } function sourceComponent(componentId) { const offset = objectOffsets.get(componentId); if (offset === undefined) return null; const attackType = int32(magicObject, offset + 0x194); const calculationType = int32(magicObject, offset + 0x2d4); const settingFlags = int32(magicObject, offset + 0xd8); // GameObjectMagicStruct ends with SettingEx (ObjectStruct.h:4131-4132). const settingExFlags = int32(magicObject, offset + MAGIC_OBJECT_RECORD_SIZE - 4); return { componentId, magicFunc: int32(magicObject, offset + 0xb0), magicType: int32(magicObject, offset + 0xb4), effectType: int32(magicObject, offset + 0xb8), effectTimeSeconds: number32(magicObject, offset + 0xbc), levelDeltaEffectTime: number32(magicObject, offset + 0xc0), effectTimeSkillLevelArg: number32(magicObject, offset + 0xc4), settingFlags, isDotMagic: (settingFlags & (1 << 3)) !== 0, isFixDotPower: (settingFlags & (1 << 20)) !== 0, dotTimeSeconds: int32(magicObject, offset + 0x184), dotType: int32(magicObject, offset + 0x188), dotBase: int32(magicObject, offset + 0x18c), dotSkillLevelArg: number32(magicObject, offset + 0x190), attackType, calculationType, damagePower: number32(magicObject, offset + 0x198), damagePowerSkillLevelArg: number32(magicObject, offset + 0x19c), fixedValue: number32(magicObject, offset + 0x1a0), fixedType: int32(magicObject, offset + 0x1a4), fixedDamageSkillLevelArg: number32(magicObject, offset + 0x1a8), randomRange: number32(magicObject, offset + 0x1ac), criticalRate: int32(magicObject, offset + 0x1b0), ignoreMagicAbsorb: (settingExFlags & 1) !== 0, rawSettingExFlags: settingExFlags, }; } const attackBindings = []; const periodicHealBindings = []; for (const skill of catalog.skills ?? []) { const ranks = skill.ranks?.length ? skill.ranks : [{ level: skill.unlockLevel, sourceSkillId: skill.sourceSkillId }]; const directRankRows = []; const dotHealRankRows = []; for (const rank of ranks) { const sourceSkillId = Number(rank.sourceSkillId ?? skill.sourceSkillId); const collectOffset = collectOffsets.get(sourceSkillId); if (collectOffset === undefined) continue; const collectRefs = rawComponents(collectOffset); const collectFlags = int32(magicCollect, collectOffset + 0x110); const collectMode = int32(magicCollect, collectOffset + 0x368); const rankRow = { rank: rank.rank ?? null, unlockLevel: rank.level ?? skill.unlockLevel, sourceSkillId, configuredCollectMagicLevel: int32(magicCollect, collectOffset + 0xb0), spellTimeSeconds: number32(magicCollect, collectOffset + 0x114), nextSpellTimeSeconds: number32(magicCollect, collectOffset + 0x120), spellCount: int32(magicCollect, collectOffset + 0x11c), rangeType: int32(magicCollect, collectOffset + 0xc4), decreaseArg: int32(magicCollect, collectOffset + 0xd0), rawCollectTimingFlags: collectFlags, referenceWeaponBowSpellTime: (collectFlags & (1 << 13)) !== 0, referenceWeaponThrowSpellTime: (collectFlags & (1 << 14)) !== 0, rawCollectMode: collectMode, isIgnoreDmgEffect: (collectMode & (1 << 15)) !== 0, isFixDmgEffect: (collectMode & (1 << 16)) !== 0, isShareDamage: (collectMode & (1 << 19)) !== 0, }; const directComponents = []; const dotHealComponents = []; for (const componentId of collectRefs) { const component = sourceComponent(componentId); if (!component) continue; if (component.magicFunc === 0 && component.attackType === 0) directComponents.push(component); if (component.isDotMagic && component.effectType === 0 && component.dotType === 0 && component.dotTimeSeconds > 0 && component.dotBase > 0) { dotHealComponents.push(component); } } if (directComponents.length) directRankRows.push({ ...rankRow, componentIds: directComponents }); if (dotHealComponents.length) dotHealRankRows.push({ ...rankRow, componentIds: dotHealComponents }); } const skillBase = { abilityId: skill.id, classId: skill.classId, ...(skill.secondaryClassId ? { secondaryClassId: skill.secondaryClassId } : {}), group: skill.group, unlockLevel: skill.unlockLevel, name: skill.name, }; if (directRankRows.length) attackBindings.push({ ...skillBase, ranks: directRankRows }); if (dotHealRankRows.length) periodicHealBindings.push({ ...skillBase, ranks: dotHealRankRows }); } const output = { schemaVersion: 1, source: { magicCollectDatabase: "Resource/data/magiccollectobject.db", magicCollectSha256: sha256(magicCollect), magicObjectDatabase: "Resource/data/magicobject.db", magicObjectSha256: sha256(magicObject), magicProcessSource: "SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp", magicProcessSha256: sha256(magicProcess), objectStructSource: "SourceCodeVS2019/Libraries/Union/RoleData/ObjectStruct.h", objectStructSha256: sha256(objectStruct), periodicRankSource: "SourceCodeVS2019/Libraries/Union/RoleData/RD_Normal.cxx", periodicRankSourceSha256: sha256(periodicRankSource), periodicTickSource: "SourceCodeVS2019/Libraries/Union/RoleData/RD_ValueCal.cxx", periodicTickSourceSha256: sha256(periodicTickSource), playerCatalog: "src/game/romPlayerCatalog.generated.json", playerCatalogSha256: sha256(catalogBuffer), nativeStatRules: "src/game/generated/romNativeStatRules.generated.json", nativeStatRulesSha256: sha256(nativeStatRulesBuffer), }, recordLayout: { magicCollect: { offset: DATA_START, recordSize: MAGIC_COLLECT_RECORD_SIZE, type: 21, magicColBaseOffset: 0xb0 }, magicObject: { offset: DATA_START, recordSize: MAGIC_OBJECT_RECORD_SIZE, type: 3 }, magicCollectSpellTimeOffset: 0x114, magicCollectFlagsOffset: 0x110, magicCollectTimingFlagsBowSpellTimeBit: 13, magicCollectTimingFlagsThrowSpellTimeBit: 14, magicObjectAttackPowerOffset: 0x198, magicObjectAttackPowerSkillLevelArgOffset: 0x19c, magicObjectAttackCalculationTypeOffset: 0x2d4, magicObjectSettingFlagsOffset: 0xd8, magicObjectDotMagicEnabledBit: 3, magicObjectFixDotPowerBit: 20, magicObjectEffectTimeOffset: 0xbc, magicObjectLevelDeltaEffectTimeOffset: 0xc0, magicObjectEffectTimeSkillLevelArgOffset: 0xc4, magicObjectDotTimeOffset: 0x184, magicObjectDotTypeOffset: 0x188, magicObjectDotBaseOffset: 0x18c, magicObjectDotSkillLevelArgOffset: 0x190, magicObjectSettingExFlagsOffset: MAGIC_OBJECT_RECORD_SIZE - 4, magicObjectIgnoreMagicAbsorbBit: 0, magicCollectModeOffset: 0x368, magicCollectModeIgnoreDamageBit: 15, magicCollectModeFixedDamageBit: 16, magicCollectModeShareDamageBit: 19, }, formula: { classification: "source-backed local RuneWaker HolyHeal resolver; no Chronicles of Arcadia values", magicLevel: "MagicProc.MagicLv is a required runtime input; the source catalog does not supply a universal rank scalar", fixHealMagicDamageCoefficient: 0.5, fixHealingDotMagicDamageCoefficient: nativeStatRules.playerFix.healingDotMagicalDamageCoefficient, periodicMagicLevel: "CalAddDotMagicLv returns 0 when FixDotPower is set or DotSkillLVArg is 0; otherwise the caster computes a source correction and adds it to MagicProc.MagicLv at buff application. The tick helper consumes that frozen Buff.Power.", periodicSourceActorPolicy: "Runewaker uses its player ExRate only for IsPlayer() owners; this simulator treats both active player and player-build party identities as source-player healing owners.", expressions: HEALING_FORMULA_SOURCE, }, periodicHealBindings, attackBindings, holyHealBindings: attackBindings.flatMap((ability) => ability.ranks.flatMap((rank) => { const components = rank.componentIds.filter((component) => component.calculationType === HOLY_HEAL_CALCULATION_TYPE); return components.length ? [{ ...ability, ranks: [{ ...rank, componentIds: components }] }] : []; })), }; await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`); console.log(`Wrote ${attackBindings.length} source attack bindings, ${output.holyHealBindings.length} HolyHeal bindings, and ${periodicHealBindings.length} positive HP HoT bindings to ${path.relative(projectRoot, outputPath)}.`);