#!/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 nativeCombatDataPath = path.join(projectRoot, "src", "game", "generated", "romNativeCombatData.generated.json"); const outputPath = path.join(projectRoot, "src", "game", "generated", "romNativePeriodicDamageBindings.generated.json"); const DATA_START = 140; const COUNT_OFFSET = 132; const MAGIC_COLLECT_RECORD_SIZE = 1032; const MAGIC_OBJECT_RECORD_SIZE = 992; const ACTIVE_PAIRS = Object.freeze({ "rom-knight": "rom-scout", "rom-priest": "rom-knight", "rom-rogue": "rom-warrior", "rom-scout": "rom-rogue", "rom-mage": "rom-warlock", }); const COMPONENT_FILTER = Object.freeze({ dotMagicFlagBit: 3, showPowerLevelBit: 14, dotMagicNoKillBit: 15, maxMagicLevelTargetBoundBit: 17, fixDotPowerBit: 20, ignoreMagicAbsorbBit: 0, healthDotType: 0, percentHealthDotType: 5, }); const NATIVE_NPC_GROWTH_LEVEL_MAX = 200; 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, nativeCombatDataBuffer] = await Promise.all([ readFile(magicCollectPath), readFile(magicObjectPath), readFile(magicProcessPath), readFile(objectStructPath), readFile(periodicRankSourcePath), readFile(periodicTickSourcePath), readFile(catalogPath), readFile(nativeStatRulesPath), readFile(nativeCombatDataPath), ]); const catalog = JSON.parse(catalogBuffer.toString("utf8")); const nativeStatRules = JSON.parse(nativeStatRulesBuffer.toString("utf8")); const nativeCombatData = JSON.parse(nativeCombatDataBuffer.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 sourceDotComponent(componentId) { const offset = objectOffsets.get(componentId); if (offset === undefined) return null; const settingFlags = int32(magicObject, offset + 0xd8); const settingExFlags = int32(magicObject, offset + MAGIC_OBJECT_RECORD_SIZE - 4); const dotType = int32(magicObject, offset + 0x188); const dotBase = int32(magicObject, offset + 0x18c); const dotTimeSeconds = int32(magicObject, offset + 0x184); if ((settingFlags & (1 << COMPONENT_FILTER.dotMagicFlagBit)) === 0 || dotTimeSeconds <= 0 || dotBase >= 0 || ![COMPONENT_FILTER.healthDotType, COMPONENT_FILTER.percentHealthDotType].includes(dotType)) return null; return { kind: "dot", 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), maxBuffLevelBase: int32(magicObject, offset + 0xcc), maxBuffLevelSkillArg: number32(magicObject, offset + 0xd0), settingFlags, isDotMagic: true, isShowPowerLevel: (settingFlags & (1 << COMPONENT_FILTER.showPowerLevelBit)) !== 0, isDotMagicNoKill: (settingFlags & (1 << COMPONENT_FILTER.dotMagicNoKillBit)) !== 0, isMaxMagicLevelTargetBound: (settingFlags & (1 << COMPONENT_FILTER.maxMagicLevelTargetBoundBit)) !== 0, isFixDotPower: (settingFlags & (1 << COMPONENT_FILTER.fixDotPowerBit)) !== 0, dotTimeSeconds, dotType, dotBase, dotSkillLevelArg: number32(magicObject, offset + 0x190), buffMaxLevel: int32(magicObject, offset + 0x3b4), isIgnoreMagicAbsorb: (settingExFlags & (1 << COMPONENT_FILTER.ignoreMagicAbsorbBit)) !== 0, rawSettingExFlags: settingExFlags, }; } const activeDamageBindings = []; for (const skill of catalog.skills ?? []) { const expectedSecondary = ACTIVE_PAIRS[skill.classId]; if (!expectedSecondary || (skill.secondaryClassId && skill.secondaryClassId !== expectedSecondary)) continue; const ranks = skill.ranks?.length ? skill.ranks : [{ level: skill.unlockLevel, sourceSkillId: skill.sourceSkillId }]; const dotRanks = []; for (const rank of ranks) { const sourceSkillId = Number(rank.sourceSkillId ?? skill.sourceSkillId); const collectOffset = collectOffsets.get(sourceSkillId); if (collectOffset === undefined) continue; const components = rawComponents(collectOffset).map(sourceDotComponent).filter(Boolean); if (!components.length) continue; const collectMode = int32(magicCollect, collectOffset + 0x368); dotRanks.push({ rank: rank.rank ?? null, unlockLevel: rank.level ?? skill.unlockLevel, sourceSkillId, configuredCollectMagicLevel: int32(magicCollect, collectOffset + 0xb0), rawSpellTimeSeconds: number32(magicCollect, collectOffset + 0x114), rawCollectMode: collectMode, isFixDmgEffect: (collectMode & (1 << 16)) !== 0, componentIds: components, }); } if (!dotRanks.length) continue; activeDamageBindings.push({ abilityId: skill.id, classId: skill.classId, ...(skill.secondaryClassId ? { secondaryClassId: skill.secondaryClassId } : {}), group: skill.group, unlockLevel: skill.unlockLevel, semanticKind: "dot", sourceSkillIds: dotRanks.map((rank) => rank.sourceSkillId), ranks: dotRanks, }); } function importedEnemyDotComponent(component) { if (component.magicFunc !== 1 || component.isDotMagic !== true || component.dotTimeSeconds <= 0 || component.dotBase >= 0 || ![COMPONENT_FILTER.healthDotType, COMPONENT_FILTER.percentHealthDotType].includes(component.dotType)) { return null; } return { kind: "dot", componentId: component.componentId, magicFunc: component.magicFunc, magicType: component.magicType, effectType: component.effectType, effectTimeSeconds: component.effectTimeSeconds, levelDeltaEffectTime: component.levelDeltaEffectTime, effectTimeSkillLevelArg: component.effectTimeSkillLevelArg, maxBuffLevelBase: component.maxBuffLevelBase, maxBuffLevelSkillArg: component.maxBuffLevelSkillArg, settingFlags: component.settingFlags, isDotMagic: true, isShowPowerLevel: component.isShowPowerLv, isDotMagicNoKill: component.isNoKill, isMaxMagicLevelTargetBound: component.isTargetBound, isFixDotPower: component.isFixDotPower, dotTimeSeconds: component.dotTimeSeconds, dotType: component.dotType, dotBase: component.dotBase, dotSkillLevelArg: component.dotSkillLevelArg, buffMaxLevel: component.buffMaxLevel, isIgnoreMagicAbsorb: component.ignoreMagicAbsorb, rawSettingExFlags: component.rawSettingExFlags, }; } const enemyDamageBindings = []; for (const npc of nativeCombatData.npcs ?? []) { const npcId = Number(npc.npcId); const level = Number(npc.level); const maxCasterLevel = NATIVE_NPC_GROWTH_LEVEL_MAX; if (!Number.isSafeInteger(npcId) || npcId <= 0 || !Number.isSafeInteger(maxCasterLevel) || maxCasterLevel < 1) continue; const ranks = []; for (const spell of npc.spellBindings ?? []) { const sourceSkillId = Number(spell.sourceSkillId); const collect = spell.magicCollect ?? {}; const maxMagicLevel = Number(spell.magicLevel); const componentIds = (spell.components ?? []).map(importedEnemyDotComponent).filter(Boolean); if (!Number.isSafeInteger(sourceSkillId) || !Number.isSafeInteger(maxMagicLevel) || !Number.isFinite(Number(collect.spellTimeSeconds)) || componentIds.length === 0) continue; ranks.push({ rank: null, unlockLevel: level, sourceSkillId, maxCasterLevel, maxMagicLevel, configuredCollectMagicLevel: maxMagicLevel, rawSpellTimeSeconds: Number(collect.spellTimeSeconds), rawCollectMode: Number(collect.rawCollectMode), rawMagicCollectSettingExFlags: Number(collect.rawSettingExFlags), isFixDmgEffect: collect.isFixDmgEffect === true, componentIds, }); } if (!ranks.length) continue; enemyDamageBindings.push({ abilityId: `rom-npc-${npcId}`, classId: "rom-npc", npcIds: [npcId], maxCasterLevel, group: "enemy-spell", unlockLevel: level, semanticKind: "dot", sourceSkillIds: ranks.map((rank) => rank.sourceSkillId), ranks, }); } const uniqueComponents = new Map(); for (const ability of [...activeDamageBindings, ...enemyDamageBindings]) { for (const rank of ability.ranks) { for (const component of rank.componentIds) uniqueComponents.set(component.componentId, component); } } 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), nativeCombatData: "src/game/generated/romNativeCombatData.generated.json", nativeCombatDataSha256: sha256(nativeCombatDataBuffer), npcMagicCollectSha256: nativeCombatData.source?.magicCollectSha256, npcMagicObjectSha256: nativeCombatData.source?.magicObjectSha256, nativeNpcGrowthLevelMax: NATIVE_NPC_GROWTH_LEVEL_MAX, }, scope: { activePairs: ACTIVE_PAIRS, semanticKind: "dot", sourceEffectFilter: "enabled negative-Base DotMagic on HP or HP_Per; resource-drain DotType rows are excluded", abilityCount: activeDamageBindings.length, sourceRankCount: activeDamageBindings.reduce((sum, ability) => sum + ability.ranks.length, 0), uniqueComponentCount: uniqueComponents.size, enemyNpcCount: enemyDamageBindings.length, enemyCasterLevelMax: NATIVE_NPC_GROWTH_LEVEL_MAX, enemySourceRankCount: enemyDamageBindings.reduce((sum, ability) => sum + ability.ranks.length, 0), enemyUniqueComponentCount: new Set(enemyDamageBindings.flatMap((ability) => ability.ranks.flatMap((rank) => rank.componentIds.map((component) => component.componentId)))).size, }, recordLayout: { magicCollect: { offset: DATA_START, recordSize: MAGIC_COLLECT_RECORD_SIZE, type: 21 }, magicObject: { offset: DATA_START, recordSize: MAGIC_OBJECT_RECORD_SIZE, type: 3 }, magicCollectSpellTimeOffset: 0x114, magicCollectModeOffset: 0x368, magicCollectModeFixDamageBit: 16, magicObjectMaxBuffLevelBaseOffset: 0xcc, magicObjectMaxBuffLevelSkillArgOffset: 0xd0, magicObjectBuffMaxLevelOffset: 0x3b4, magicObjectEffectTimeOffset: 0xbc, magicObjectLevelDeltaEffectTimeOffset: 0xc0, magicObjectEffectTimeSkillLevelArgOffset: 0xc4, magicObjectSettingFlagsOffset: 0xd8, magicObjectDotMagicEnabledBit: COMPONENT_FILTER.dotMagicFlagBit, magicObjectShowPowerLevelBit: COMPONENT_FILTER.showPowerLevelBit, magicObjectDotMagicNoKillBit: COMPONENT_FILTER.dotMagicNoKillBit, magicObjectMaxMagicLevelTargetBoundBit: COMPONENT_FILTER.maxMagicLevelTargetBoundBit, magicObjectFixDotPowerBit: COMPONENT_FILTER.fixDotPowerBit, magicObjectDotTimeOffset: 0x184, magicObjectDotTypeOffset: 0x188, magicObjectDotBaseOffset: 0x18c, magicObjectDotSkillLevelArgOffset: 0x190, magicObjectSettingExFlagsOffset: MAGIC_OBJECT_RECORD_SIZE - 4, magicObjectIgnoreMagicAbsorbBit: COMPONENT_FILTER.ignoreMagicAbsorbBit, }, formula: { sourceOrdering: "MagicProcess computes the target-adjusted buff duration, then adds CalAddDotMagicLv to MagicProc.MagicLv; ShowPowerLv replaces that result with zero before AssistMagic stores Buff.Power. AssistMagic applies MaxBuffLv_Base/Arg, optional target-level and BuffMaxLv caps before storing the integer power.", sourceTick: "BuffProc emits int(-Dot.Base * (100 + DotSkillLVArg * frozen Buff.Power) / 100) once when remaining Buff.Time is divisible by Dot.Time. HP_Per then multiplies by the target's current MaxHP / 100 and truncates again.", rankCorrection: "CalAddDotMagicLv returns zero for FixDotPower, non-HP/MP DotType, or DotSkillLVArg zero. For negative HP DotMagic it snapshots Magic/Physics source power and target-specific source defense terms before deriving the stored Buff.Power.", fixDotMagicalDamageCoefficient: nativeStatRules.playerFix.dotMagicalDamageCoefficient, formulaFiles: [ "SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp", "SourceCodeVS2019/Libraries/Union/RoleData/RD_Normal.cxx", "SourceCodeVS2019/Libraries/Union/RoleData/RD_ValueCal.cxx", "SourceCodeVS2019/Libraries/Union/RoleData/ObjectStruct.h", ], }, activeDamageBindings, enemyDamageBindings, }; await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`); console.log(`Wrote ${activeDamageBindings.length} active-five abilities and ${enemyDamageBindings.length} imported NPC bindings with ${output.scope.sourceRankCount + output.scope.enemySourceRankCount} source ranks and ${uniqueComponents.size} unique negative HP DoT components to ${path.relative(projectRoot, outputPath)}.`);