336 lines
16 KiB
JavaScript
336 lines
16 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 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)}.`);
|