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