forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
39458d5de6
commit
6cc1b9217f
203 files changed
+133929
-31271
No files matched your search
@@ -483,6 +483,9 @@ function attacksFor(template, sourceSpells, spellCatalog, bossMechanics) {
|
||||
delivery: spell.runtime.delivery,
|
||||
target: spell.runtime.target,
|
||||
school: spell.runtime.school,
|
||||
damageOrigin: "runewaker",
|
||||
attackKind: spell.runtime.attackKind,
|
||||
runewakerEffectType: spell.runtime.runewakerEffectType,
|
||||
animation: spell.runtime.animation,
|
||||
range: spell.runtime.range,
|
||||
cooldownMs: spell.runtime.cooldownMs,
|
||||
@@ -549,6 +552,7 @@ function entityFor(template, actor, sourceSpells, spellCatalog, bossMechanics) {
|
||||
...(boss && template.title ? { title: template.title } : {}),
|
||||
hasLoot: template.hasLoot === true,
|
||||
combat: {
|
||||
runewakerNpcId: Number(template.id),
|
||||
level: template.level,
|
||||
healthMultiplier: combat.healthMultiplier ?? (boss ? defaults.bossHealthMultiplier ?? 7.5 : defaults.healthMultiplier ?? 1),
|
||||
damageMultiplier: combat.damageMultiplier ?? (boss ? defaults.bossDamageMultiplier ?? 1.3 : defaults.damageMultiplier ?? 1),
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { projectRoot, readJson, sourceRootFor } from "./lib/recipe.mjs";
|
||||
|
||||
const DATA_START = 140;
|
||||
const NPC_SIZE = 2224;
|
||||
const TABLE_SIZE = 408;
|
||||
const NPC_TYPE = 1;
|
||||
const NPC_RACE = 176;
|
||||
const NPC_SPELL_START = 568;
|
||||
const NPC_SPELL_SIZE = 28;
|
||||
const NPC_RESIST_START = 888;
|
||||
const NPC_BOW_EQUIPMENT = 1260;
|
||||
const NPC_ORNAMENT_EQUIPMENT = 1648;
|
||||
const SPELL_COUNT = 8;
|
||||
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 CLASS_IDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
const NPC_CLASS_IDS = [0, ...CLASS_IDS];
|
||||
const STAT_NAMES = ["strength", "stamina", "intellect", "wisdom", "agility", "health", "mana"];
|
||||
const GROWTH_NAMES = [
|
||||
"baseStrengthLevelOne", "baseStaminaLevelOne", "baseIntellectLevelOne", "baseWisdomLevelOne",
|
||||
"baseAgilityLevelOne", "baseHealthLevelOne", "baseManaLevelOne", "baseStrengthFlatGrowth",
|
||||
"baseStaminaFlatGrowth", "baseIntellectFlatGrowth", "baseWisdomFlatGrowth", "baseAgilityFlatGrowth",
|
||||
"baseHealthFlatGrowth", "baseManaFlatGrowth", "strengthGrowthRate", "staminaGrowthRate", "intellectGrowthRate",
|
||||
"wisdomGrowthRate", "agilityGrowthRate", "healthGrowthRate", "manaGrowthRate", "hpStrength", "hpStamina",
|
||||
"mpIntellect", "mpWisdom", "attackStrength", "attackIntellect", "attackAgility", "defenseStamina",
|
||||
"baseHealthRecovery", "baseManaRecovery", "healthRecoveryStamina", "manaRecoveryWisdom",
|
||||
"magicalAttackIntellect", "magicalDefenseWisdom",
|
||||
];
|
||||
|
||||
const recipeDirectory = path.join(projectRoot, "scripts/runewaker-pipeline/recipes");
|
||||
const recipes = await Promise.all((await readdir(recipeDirectory))
|
||||
.filter((name) => name.endsWith("-population.json"))
|
||||
.sort()
|
||||
.map((name) => readJson(path.join(recipeDirectory, name))));
|
||||
if (!recipes.length) throw new Error("No population recipes found.");
|
||||
const sourceRoot = sourceRootFor(recipes[0]);
|
||||
const dataRoot = path.join(sourceRoot, recipes[0].source.resourceRoot ?? "Resource", "data");
|
||||
const readSource = (name) => readFile(path.join(dataRoot, name));
|
||||
const [npcDb, collectDb, magicDb, vocDb, raceDb, sexDb, fixDb, skillBaseDb, jobSkillDb] = await Promise.all([
|
||||
readSource("npcobject.db"), readSource("magiccollectobject.db"), readSource("magicobject.db"),
|
||||
readSource("voctable.db"), readSource("racetable.db"), readSource("sextable.db"), readSource("fixtable.db"),
|
||||
readSource("skillbasetable.db"), readSource("jobskill.db"),
|
||||
]);
|
||||
const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex");
|
||||
const int32 = (buffer, offset) => buffer.readInt32LE(offset);
|
||||
const float32 = (buffer, offset) => {
|
||||
const value = buffer.readFloatLE(offset);
|
||||
if (!Number.isFinite(value)) throw new Error(`Non-finite native source float at ${offset}.`);
|
||||
return Number(value.toFixed(6));
|
||||
};
|
||||
function fixedRecords(buffer, size, type, label) {
|
||||
const count = int32(buffer, 132);
|
||||
if (count <= 0 || DATA_START + count * size > buffer.length) throw new Error(`${label} fixed record layout changed.`);
|
||||
if (type !== null) {
|
||||
for (const i of [0, Math.floor(count / 2), count - 1]) {
|
||||
if (int32(buffer, DATA_START + i * size + 4) !== type) throw new Error(`${label} type sentinel drifted.`);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
function indexById(buffer, size, count) {
|
||||
const result = new Map();
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const offset = DATA_START + i * size;
|
||||
const id = int32(buffer, offset);
|
||||
if (id > 0) result.set(id, offset);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const npcCount = fixedRecords(npcDb, NPC_SIZE, NPC_TYPE, "NPCObjectDB");
|
||||
const collectCount = fixedRecords(collectDb, 1032, 21, "MagicCollectObjectDB");
|
||||
const magicCount = fixedRecords(magicDb, 992, 3, "MagicObjectDB");
|
||||
const npcOffsets = indexById(npcDb, NPC_SIZE, npcCount);
|
||||
const collectOffsets = indexById(collectDb, 1032, collectCount);
|
||||
const magicOffsets = indexById(magicDb, 992, magicCount);
|
||||
|
||||
function npcRow(id) {
|
||||
const offset = npcOffsets.get(id);
|
||||
if (offset === undefined || int32(npcDb, offset + 4) !== NPC_TYPE) throw new Error(`Missing NPCObjectDB row ${id}.`);
|
||||
const int = (delta) => int32(npcDb, offset + delta);
|
||||
const spells = [];
|
||||
for (let slot = 0; slot < SPELL_COUNT; slot += 1) {
|
||||
const start = offset + NPC_SPELL_START + slot * NPC_SPELL_SIZE;
|
||||
const sourceSkillId = int32(npcDb, start + 12);
|
||||
const magicLevel = int32(npcDb, start + 16);
|
||||
if (sourceSkillId <= 0 || !collectOffsets.has(sourceSkillId)) continue;
|
||||
spells.push({ slot, rightTime: int32(npcDb, start), targetType: int32(npcDb, start + 4), rate: int32(npcDb, start + 8), sourceSkillId, magicLevel });
|
||||
}
|
||||
return {
|
||||
npcId: id,
|
||||
raceId: int(NPC_RACE),
|
||||
vocationId: int(NPC_RACE + 4),
|
||||
vocationSubId: int(NPC_RACE + 8),
|
||||
sexId: int(NPC_RACE + 12),
|
||||
level: int(NPC_RACE + 24),
|
||||
levelRange: int(NPC_RACE + 28),
|
||||
authoredAttributes: {
|
||||
strength: int(NPC_RACE + 32), stamina: int(NPC_RACE + 36), intellect: int(NPC_RACE + 40),
|
||||
wisdom: int(NPC_RACE + 44), agility: int(NPC_RACE + 48),
|
||||
},
|
||||
nativeMagicResistances: Array.from({ length: 6 }, (_, type) => float32(npcDb, offset + NPC_RESIST_START + type * 4)),
|
||||
// RD_Setting chooses rand()%5: keep zero rows and duplicates so later
|
||||
// equipment resolution can preserve the source candidate distribution.
|
||||
mainHandEquipmentIds: Array.from({ length: 5 }, (_, i) => int(412 + i * 4)),
|
||||
offHandEquipmentIds: Array.from({ length: 5 }, (_, i) => int(432 + i * 4)),
|
||||
bowEquipmentId: int(NPC_BOW_EQUIPMENT),
|
||||
ornamentEquipmentId: int(NPC_ORNAMENT_EQUIPMENT),
|
||||
spellBindings: spells.map((spell) => ({ ...spell, ...collectBinding(spell.sourceSkillId) })),
|
||||
};
|
||||
}
|
||||
|
||||
function componentDescriptor(componentId) {
|
||||
const offset = magicOffsets.get(componentId);
|
||||
if (offset === undefined) return null;
|
||||
const settingFlags = int32(magicDb, offset + 0xd8);
|
||||
const settingExFlags = int32(magicDb, offset + 988);
|
||||
return {
|
||||
componentId,
|
||||
magicFunc: int32(magicDb, offset + 0xb0),
|
||||
magicType: int32(magicDb, offset + 0xb4),
|
||||
effectType: int32(magicDb, offset + 0xb8),
|
||||
effectTimeSeconds: float32(magicDb, offset + 0xbc),
|
||||
levelDeltaEffectTime: float32(magicDb, offset + 0xc0),
|
||||
effectTimeSkillLevelArg: float32(magicDb, offset + 0xc4),
|
||||
maxBuffLevelBase: int32(magicDb, offset + 0xcc),
|
||||
maxBuffLevelSkillArg: float32(magicDb, offset + 0xd0),
|
||||
settingFlags,
|
||||
isDotMagic: (settingFlags & (1 << 3)) !== 0,
|
||||
isShowPowerLv: (settingFlags & (1 << 14)) !== 0,
|
||||
isNoKill: (settingFlags & (1 << 15)) !== 0,
|
||||
isTargetBound: (settingFlags & (1 << 17)) !== 0,
|
||||
isFixDotPower: (settingFlags & (1 << 20)) !== 0,
|
||||
dotTimeSeconds: int32(magicDb, offset + 0x184),
|
||||
dotType: int32(magicDb, offset + 0x188),
|
||||
dotBase: int32(magicDb, offset + 0x18c),
|
||||
dotSkillLevelArg: float32(magicDb, offset + 0x190),
|
||||
attackType: int32(magicDb, offset + 0x194),
|
||||
damagePower: float32(magicDb, offset + 0x198),
|
||||
damagePowerSkillLevelArg: float32(magicDb, offset + 0x19c),
|
||||
fixedValue: float32(magicDb, offset + 0x1a0),
|
||||
fixedType: int32(magicDb, offset + 0x1a4),
|
||||
fixedDamageSkillLevelArg: float32(magicDb, offset + 0x1a8),
|
||||
randomRange: float32(magicDb, offset + 0x1ac),
|
||||
criticalRate: int32(magicDb, offset + 0x1b0),
|
||||
calculationType: int32(magicDb, offset + 0x2d4),
|
||||
buffMaxLevel: int32(magicDb, offset + 0x3b4),
|
||||
ignoreMagicAbsorb: (settingExFlags & 1) !== 0,
|
||||
rawSettingExFlags: settingExFlags,
|
||||
isFixDmgEffect: false,
|
||||
};
|
||||
}
|
||||
|
||||
function collectBinding(sourceSkillId) {
|
||||
const offset = collectOffsets.get(sourceSkillId);
|
||||
if (offset === undefined) return { missingSource: true, components: [] };
|
||||
const componentIds = new Set();
|
||||
for (let field = 0x180; field <= 0x2c0; field += 4) {
|
||||
const id = int32(collectDb, offset + field);
|
||||
if (magicOffsets.has(id)) componentIds.add(id);
|
||||
}
|
||||
const rawCollectMode = int32(collectDb, offset + 0x368);
|
||||
return {
|
||||
magicCollect: {
|
||||
effectType: int32(collectDb, offset + 0xb4),
|
||||
rangeType: int32(collectDb, offset + 0xc4),
|
||||
decreaseArg: int32(collectDb, offset + 0xcc),
|
||||
hitRateFunc: int32(collectDb, offset + 0xd4),
|
||||
hitRateArgs: [float32(collectDb, offset + 0xd8), float32(collectDb, offset + 0xdc)],
|
||||
spellTimeSeconds: float32(collectDb, offset + 0x114),
|
||||
rawSettingExFlags: int32(collectDb, offset + 0x110),
|
||||
rawCollectMode,
|
||||
isFixDmgEffect: (rawCollectMode & (1 << 16)) !== 0,
|
||||
isShareDamage: (rawCollectMode & (1 << 18)) !== 0,
|
||||
},
|
||||
components: [...componentIds].sort((a, b) => a - b).map(componentDescriptor).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
const entityIdToNpcId = {};
|
||||
const npcIds = new Set();
|
||||
for (const recipe of recipes) {
|
||||
const generatedPath = path.join(projectRoot, recipe.files.generatedSource);
|
||||
const generated = await readFile(generatedPath, "utf8");
|
||||
const runtimePrefix = recipe.runtimeIdPrefix ?? `rw-${recipe.dungeonId}`;
|
||||
const entityPattern = new RegExp(`"(${runtimePrefix}-([0-9]+))"\\s*:`, "g");
|
||||
for (const match of generated.matchAll(entityPattern)) {
|
||||
const entityId = match[1];
|
||||
const npcId = Number(match[2]);
|
||||
if (!Number.isSafeInteger(npcId) || npcId <= 0) throw new Error(`Invalid Runewaker NPC id in ${entityId}.`);
|
||||
if (entityIdToNpcId[entityId] !== undefined && entityIdToNpcId[entityId] !== npcId) throw new Error(`Entity mapping collision for ${entityId}.`);
|
||||
entityIdToNpcId[entityId] = npcId;
|
||||
npcIds.add(npcId);
|
||||
}
|
||||
}
|
||||
if (!npcIds.size) throw new Error("No generated population entity to NPC mappings were found.");
|
||||
const npcs = [...npcIds].sort((a, b) => a - b).map(npcRow);
|
||||
|
||||
// Combat hit bindings are exported only for source rows already used by the
|
||||
// native damage/periodic helpers. This preserves the bound skill/component
|
||||
// pair without attempting to infer player hit rules from an ability label.
|
||||
const playerDamageCatalog = await readJson(path.join(projectRoot, "src/game/generated/romNativeHolyHealBindings.generated.json"));
|
||||
const playerPeriodicCatalog = await readJson(path.join(projectRoot, "src/game/generated/romNativePeriodicDamageBindings.generated.json"));
|
||||
const playerSkillIds = new Set();
|
||||
for (const ability of [...(playerDamageCatalog.attackBindings ?? []), ...(playerDamageCatalog.holyHealBindings ?? [])]) {
|
||||
for (const rank of ability.ranks ?? []) if (Number.isSafeInteger(rank.sourceSkillId)) playerSkillIds.add(rank.sourceSkillId);
|
||||
}
|
||||
for (const ability of playerPeriodicCatalog.activeDamageBindings ?? []) {
|
||||
for (const rank of ability.ranks ?? []) if (Number.isSafeInteger(rank.sourceSkillId)) playerSkillIds.add(rank.sourceSkillId);
|
||||
}
|
||||
const playerSpellBindings = [...playerSkillIds].sort((a, b) => a - b).map((sourceSkillId) => {
|
||||
const binding = collectBinding(sourceSkillId);
|
||||
return binding.missingSource ? null : { sourceSkillId, ...binding };
|
||||
}).filter(Boolean);
|
||||
|
||||
const growthTable = (buffer, rowSize, count, expectedBase) => {
|
||||
const rows = new Map();
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const offset = DATA_START + i * rowSize;
|
||||
const id = int32(buffer, offset);
|
||||
if (id < expectedBase) continue;
|
||||
rows.set(id, Array.from({ length: 100 }, (_, index) => float32(buffer, offset + 8 + index * 4)));
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
fixedRecords(vocDb, TABLE_SIZE, null, "VocationTable");
|
||||
fixedRecords(raceDb, TABLE_SIZE, null, "RaceTable");
|
||||
fixedRecords(sexDb, TABLE_SIZE, null, "SexTable");
|
||||
fixedRecords(fixDb, TABLE_SIZE, null, "FixTable");
|
||||
const vocCount = (vocDb.length - DATA_START) / TABLE_SIZE;
|
||||
const raceCount = (raceDb.length - DATA_START) / TABLE_SIZE;
|
||||
const sexCount = (sexDb.length - DATA_START) / TABLE_SIZE;
|
||||
const fixCount = (fixDb.length - DATA_START) / TABLE_SIZE;
|
||||
const vocRows = growthTable(vocDb, TABLE_SIZE, vocCount, 301000);
|
||||
const raceRows = growthTable(raceDb, TABLE_SIZE, raceCount, 300000);
|
||||
const sexRows = growthTable(sexDb, TABLE_SIZE, sexCount, 302000);
|
||||
const fixRows = growthTable(fixDb, TABLE_SIZE, fixCount, 303000);
|
||||
const growthFields = (values) => ({
|
||||
growth: Object.fromEntries(STAT_NAMES.map((name, i) => [name, {
|
||||
levelOne: values[i], flat: values[i + 7], percent: values[i + 14],
|
||||
}])),
|
||||
coefficients: Object.fromEntries(GROWTH_NAMES.slice(21).map((name, i) => [name, values[21 + i]])),
|
||||
damageRate: values[43],
|
||||
});
|
||||
const npcClasses = Object.fromEntries(NPC_CLASS_IDS.map((id) => {
|
||||
const values = vocRows.get(301000 + id * 2);
|
||||
if (!values) throw new Error(`Missing NPC vocation row ${301000 + id * 2}.`);
|
||||
return [id, { sourceRecordId: 301000 + id * 2, ...growthFields(values) }];
|
||||
}));
|
||||
const raceGrowth = Object.fromEntries([...raceRows].filter(([id]) => id >= 300000 && id <= 300031).map(([id, values]) => [id, growthFields(values)]));
|
||||
const sexGrowth = Object.fromEntries([...sexRows].filter(([id]) => id >= 302000 && id <= 302004).map(([id, values]) => [id, growthFields(values)]));
|
||||
const fix = (recordId) => {
|
||||
const values = fixRows.get(recordId);
|
||||
if (!values) throw new Error(`Missing FIX row ${recordId}.`);
|
||||
return Object.fromEntries(FIX_FIELDS.map((name, i) => [name, values[i]]));
|
||||
};
|
||||
|
||||
const skillBaseCount = int32(skillBaseDb, 132);
|
||||
const jobSkillCount = int32(jobSkillDb, 132);
|
||||
if (skillBaseCount !== 100 || jobSkillCount < 15) throw new Error("Skill table count drifted.");
|
||||
const skillBaseRows = Array.from({ length: skillBaseCount }, (_, i) => {
|
||||
const offset = DATA_START + i * 88;
|
||||
if (int32(skillBaseDb, offset) !== 305000 + i) throw new Error(`SkillValueBase row ${i} drifted.`);
|
||||
return Array.from({ length: 20 }, (_, j) => int32(skillBaseDb, offset + 8 + j * 4));
|
||||
});
|
||||
const jobSkillRows = Array.from({ length: jobSkillCount }, (_, i) => {
|
||||
const offset = DATA_START + i * 136;
|
||||
if (int32(jobSkillDb, offset) !== 306000 + i) throw new Error(`JobSkillValue row ${i} drifted.`);
|
||||
return Array.from({ length: 32 }, (_, j) => int32(jobSkillDb, offset + 8 + j * 4));
|
||||
});
|
||||
const maxSkillByClass = Object.fromEntries(NPC_CLASS_IDS.map((classId) => [classId,
|
||||
Array.from({ length: skillBaseCount }, (_, levelIndex) => Array.from({ length: 15 }, (_, skillId) => {
|
||||
const skillType = jobSkillRows[skillId][classId];
|
||||
if (!Number.isInteger(skillType) || skillType < 0 || skillType >= 20) throw new Error(`Invalid skill type ${skillType}.`);
|
||||
return skillBaseRows[levelIndex][skillType];
|
||||
})),
|
||||
]));
|
||||
|
||||
const output = {
|
||||
schemaVersion: 1,
|
||||
source: {
|
||||
npcObjectSha256: sha256(npcDb), magicCollectSha256: sha256(collectDb), magicObjectSha256: sha256(magicDb),
|
||||
vocationTableSha256: sha256(vocDb), raceTableSha256: sha256(raceDb), sexTableSha256: sha256(sexDb),
|
||||
fixTableSha256: sha256(fixDb), skillBaseTableSha256: sha256(skillBaseDb), jobSkillTableSha256: sha256(jobSkillDb),
|
||||
npcRecordSize: NPC_SIZE, npcRaceOffset: NPC_RACE, npcSpellStartOffset: NPC_SPELL_START,
|
||||
npcSpellStride: NPC_SPELL_SIZE, npcEquipmentOffsets: { mainHand: 412, offHand: 432 },
|
||||
npcMagicResistanceOffset: NPC_RESIST_START,
|
||||
npcFixedEquipmentOffsets: { bow: NPC_BOW_EQUIPMENT, ornament: NPC_ORNAMENT_EQUIPMENT },
|
||||
npcClassFixTable: 0, playerFixTable: 1, playerSkillPolicy: "simulator-max-trained-by-source-tables",
|
||||
playerBindingCatalogs: ["romNativeHolyHealBindings.generated.json", "romNativePeriodicDamageBindings.generated.json"],
|
||||
},
|
||||
sourceRules: {
|
||||
npcClasses, raceGrowth, sexGrowth, npcFix: fix(303000), playerFix: fix(303001), maxSkillByClass,
|
||||
npcLevelClamp: 200, growthFormulaMaxSourceLevel: 200, maxSkillTableLevel: skillBaseCount,
|
||||
},
|
||||
entityIdToNpcId,
|
||||
npcs,
|
||||
playerSpellBindings,
|
||||
};
|
||||
await writeFile(path.join(projectRoot, "src/game/generated/romNativeCombatData.generated.json"), `${JSON.stringify(output)}\n`);
|
||||
console.log(`Exported ${npcs.length} NPC rows and ${Object.keys(entityIdToNpcId).length} exact population entity→NPC mappings.`);
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/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 inputPath = path.join(projectRoot, "src", "game", "generated", "romHeirloomReference.json");
|
||||
const outputPath = path.join(projectRoot, "src", "game", "generated", "romNativeDamageTrainingProfiles.generated.json");
|
||||
const classes = ["rom-knight", "rom-priest", "rom-scout", "rom-rogue", "rom-mage"];
|
||||
const slots = ["main-hand", "off-hand", "ranged"];
|
||||
const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex");
|
||||
|
||||
const inputBuffer = await readFile(inputPath);
|
||||
const catalog = JSON.parse(inputBuffer.toString("utf8"));
|
||||
const selectedItemIds = new Set(Object.values(catalog.profiles).flatMap((profile) =>
|
||||
Object.values(profile).flatMap((curve) => curve.flatMap((row) => row.referenceItemIds))));
|
||||
const selectedItems = catalog.sourceItems.filter((item) => selectedItemIds.has(item.itemId));
|
||||
const itemById = new Map(selectedItems.map((item) => [item.itemId, item]));
|
||||
if (selectedItems.length !== selectedItemIds.size || selectedItems.some((item) =>
|
||||
item.unmappedEffects?.length || item.unresolvedAttributeIds?.length)) {
|
||||
throw new Error("Training reference has unresolved native body channels.");
|
||||
}
|
||||
const profiles = {};
|
||||
for (const classId of classes) {
|
||||
const sourceProfile = catalog.profiles?.[classId];
|
||||
if (!sourceProfile) throw new Error(`Missing source training profile for ${classId}.`);
|
||||
const classProfile = {};
|
||||
for (const slot of slots) {
|
||||
const curve = sourceProfile[slot];
|
||||
if (!curve) continue;
|
||||
if (curve.length !== 79) throw new Error(`${classId}/${slot} must cover levels 1–79.`);
|
||||
let previousShieldDefense = 0;
|
||||
classProfile[slot] = curve.map((entry, index) => {
|
||||
const weaponDamage = entry.stats?.weaponDamage ?? 0;
|
||||
const magicalWeaponDamage = entry.stats?.magicalWeaponDamage ?? 0;
|
||||
const speedTenths = entry.speedTenths ?? null;
|
||||
const referenceItems = entry.referenceItemIds.map((id) => itemById.get(id));
|
||||
const weaponType = referenceItems.length && referenceItems.every((item) => item?.sourceCatalog === "weaponobject.db"
|
||||
&& item.type === referenceItems[0].type) ? referenceItems[0].type : null;
|
||||
const shields = referenceItems.length && referenceItems.every((item) => item?.sourceCatalog === "armorobject.db"
|
||||
&& item.type === 5) ? [...referenceItems].sort((left, right) => left.level - right.level) : [];
|
||||
if (shields.length) {
|
||||
const level = index + 1;
|
||||
const lower = shields[0];
|
||||
const upper = shields.at(-1);
|
||||
const blend = level < lower.level ? level / lower.level : level > upper.level ? level / upper.level
|
||||
: lower.level === upper.level ? 0 : (level - lower.level) / (upper.level - lower.level);
|
||||
const rawDefense = level < lower.level ? (upper.baseStats.physicalDefense ?? 0) * blend
|
||||
: level > upper.level ? (lower.baseStats.physicalDefense ?? 0) * blend
|
||||
: (lower.baseStats.physicalDefense ?? 0) * (1 - blend) + (upper.baseStats.physicalDefense ?? 0) * blend;
|
||||
previousShieldDefense = Math.max(previousShieldDefense, rawDefense > 0 ? Math.max(1, Math.round(rawDefense)) : 0);
|
||||
}
|
||||
if (![weaponDamage, magicalWeaponDamage].every(Number.isFinite)
|
||||
|| (speedTenths !== null && !Number.isFinite(speedTenths))) {
|
||||
throw new Error(`Non-finite source training weapon row ${classId}/${slot}/${index + 1}.`);
|
||||
}
|
||||
return {
|
||||
level: index + 1,
|
||||
sourceItemIds: entry.referenceItemIds,
|
||||
weaponDamage,
|
||||
magicalWeaponDamage,
|
||||
speedTenths,
|
||||
weaponType,
|
||||
...(shields.length ? { shieldDefense: previousShieldDefense } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
profiles[classId] = classProfile;
|
||||
}
|
||||
|
||||
const output = {
|
||||
schemaVersion: 1,
|
||||
status: "compact source-selected training weapon references; simulator gear profile and level interpolation policy",
|
||||
source: {
|
||||
file: "src/game/generated/romHeirloomReference.json",
|
||||
sha256: sha256(inputBuffer),
|
||||
sourceItemDatabases: catalog.sourceFiles,
|
||||
quality: catalog.quality,
|
||||
levelCap: 79,
|
||||
selectedItemCount: selectedItems.length,
|
||||
// Selection excludes unmapped effects, including typed MPow/Resist/AllRange.
|
||||
// These are item contributions only; actor auras and racial/source base inputs remain separate.
|
||||
trainingItemBodyDefaults: {
|
||||
perSchoolMagicPowerRate: 1, perSchoolResistance: 0, perSchoolResistanceRate: 1,
|
||||
allRangeDamageRate: 1, throwDamageRate: 1, throwingWeaponDamage: 0,
|
||||
},
|
||||
},
|
||||
profiles,
|
||||
};
|
||||
await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`);
|
||||
console.log(`Wrote compact native training weapon profiles to ${path.relative(projectRoot, outputPath)}.`);
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/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)}.`);
|
||||
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { projectRoot, readJson, sourceRootFor } from "./lib/recipe.mjs";
|
||||
|
||||
const DATA_START = 140;
|
||||
const NPC_RECORD_SIZE = 2224;
|
||||
const ITEM_RECORD_SIZE = 1776;
|
||||
const REFINE_RECORD_SIZE = 280;
|
||||
const NPC_OBJECT_TYPE = 1;
|
||||
const ITEM_OBJECT_TYPE = 2;
|
||||
const REFINE_OBJECT_TYPE = 22;
|
||||
const NPC_EQUIPMENT = Object.freeze({
|
||||
mainHand: { start: 412, count: 5 },
|
||||
offHand: { start: 432, count: 5 },
|
||||
bow: { start: 1260, count: 1 },
|
||||
ornament: { start: 1648, count: 1 },
|
||||
});
|
||||
const ITEM_FIELDS = Object.freeze({
|
||||
durable: 0x54,
|
||||
quality: 0x60,
|
||||
level: 0x78,
|
||||
gearType: 0xd0,
|
||||
gearPosition: 0xd4,
|
||||
eqTypes: 0xd8,
|
||||
eqValues: 0x100,
|
||||
dropAbilities: 0x134,
|
||||
attackRange: 0x1ac,
|
||||
attackSpeed: 0x1b0,
|
||||
refineTableId: 0x3d0,
|
||||
refineLevel: ITEM_RECORD_SIZE - 12,
|
||||
addPowerQuality: ITEM_RECORD_SIZE - 8,
|
||||
collectionNpc: ITEM_RECORD_SIZE - 4,
|
||||
});
|
||||
const REFINE_FIELDS = Object.freeze({
|
||||
type: 0xb4,
|
||||
level: 0xb8,
|
||||
eqTypes: 0xbc,
|
||||
eqValues: 0xe4,
|
||||
exEqPowerRate: 0x114,
|
||||
});
|
||||
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
const int32 = (bytes, offset) => bytes.readInt32LE(offset);
|
||||
const checkTable = (bytes, recordSize, objectType, label) => {
|
||||
const count = int32(bytes, 132);
|
||||
if (!Number.isInteger(count) || count <= 0 || DATA_START + count * recordSize > bytes.length) {
|
||||
throw new Error(`${label} fixed-record layout changed.`);
|
||||
}
|
||||
for (const index of [0, Math.floor(count / 2), count - 1]) {
|
||||
if (int32(bytes, DATA_START + index * recordSize + 4) !== objectType) {
|
||||
throw new Error(`${label} object type sentinel changed.`);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
const recipeDirectory = path.join(projectRoot, "scripts/runewaker-pipeline/recipes");
|
||||
const recipes = (await readdir(recipeDirectory)).filter((name) => name.endsWith("-population.json")).sort();
|
||||
if (!recipes.length) throw new Error("No population recipes found.");
|
||||
const recipe = await readJson(path.join(recipeDirectory, recipes[0]));
|
||||
const sourceRoot = sourceRootFor(recipe);
|
||||
const dataRoot = path.join(sourceRoot, recipe.source.resourceRoot ?? "Resource", "data");
|
||||
const filenames = ["npcobject.db", "armorobject.db", "weaponobject.db", "eqrefineabilityobject.db"];
|
||||
const buffers = Object.fromEntries(await Promise.all(filenames.map(async (filename) => (
|
||||
[filename, await readFile(path.join(dataRoot, filename))]
|
||||
))));
|
||||
const npcCount = checkTable(buffers["npcobject.db"], NPC_RECORD_SIZE, NPC_OBJECT_TYPE, "NPCObjectDB");
|
||||
const itemCounts = Object.fromEntries(["armorobject.db", "weaponobject.db"].map((filename) => (
|
||||
[filename, checkTable(buffers[filename], ITEM_RECORD_SIZE, ITEM_OBJECT_TYPE, filename)]
|
||||
)));
|
||||
const refineCount = checkTable(buffers["eqrefineabilityobject.db"], REFINE_RECORD_SIZE, REFINE_OBJECT_TYPE, "EqRefineAbilityObjectDB");
|
||||
|
||||
const nativeCombatData = await readJson(path.join(projectRoot, "src/game/generated/romNativeCombatData.generated.json"));
|
||||
if (!Array.isArray(nativeCombatData.npcs) || nativeCombatData.npcs.length === 0) {
|
||||
throw new Error("Native combat NPC catalog is empty.");
|
||||
}
|
||||
const npcSourceOffsets = new Map();
|
||||
for (let index = 0; index < npcCount; index += 1) {
|
||||
const offset = DATA_START + index * NPC_RECORD_SIZE;
|
||||
if (int32(buffers["npcobject.db"], offset + 4) !== NPC_OBJECT_TYPE) continue;
|
||||
const npcId = int32(buffers["npcobject.db"], offset);
|
||||
if (npcId > 0) npcSourceOffsets.set(npcId, offset);
|
||||
}
|
||||
|
||||
const sourceItemsById = new Map();
|
||||
for (const filename of ["armorobject.db", "weaponobject.db"]) {
|
||||
const bytes = buffers[filename];
|
||||
for (let index = 0; index < itemCounts[filename]; index += 1) {
|
||||
const offset = DATA_START + index * ITEM_RECORD_SIZE;
|
||||
if (int32(bytes, offset + 4) !== ITEM_OBJECT_TYPE) throw new Error(`${filename} object type changed at ${index}.`);
|
||||
const itemId = int32(bytes, offset);
|
||||
if (itemId <= 0) continue;
|
||||
if (sourceItemsById.has(itemId)) throw new Error(`Duplicate source item ID ${itemId} across equipment catalogs.`);
|
||||
const eqPairs = Array.from({ length: 10 }, (_, slot) => ({
|
||||
slot,
|
||||
eqType: int32(bytes, offset + ITEM_FIELDS.eqTypes + slot * 4),
|
||||
value: int32(bytes, offset + ITEM_FIELDS.eqValues + slot * 4),
|
||||
}));
|
||||
const dropAbilityPairs = Array.from({ length: 6 }, (_, slot) => ({
|
||||
slot,
|
||||
attributeId: int32(bytes, offset + ITEM_FIELDS.dropAbilities + slot * 8),
|
||||
rate: int32(bytes, offset + ITEM_FIELDS.dropAbilities + slot * 8 + 4),
|
||||
}));
|
||||
sourceItemsById.set(itemId, {
|
||||
sourceItemId: itemId,
|
||||
sourceCatalog: filename,
|
||||
sourceRecordIndex: index,
|
||||
objectKind: "equipment-item",
|
||||
objectType: int32(bytes, offset + 4),
|
||||
quality: int32(bytes, offset + ITEM_FIELDS.quality),
|
||||
itemLevel: int32(bytes, offset + ITEM_FIELDS.level),
|
||||
gearType: int32(bytes, offset + ITEM_FIELDS.gearType),
|
||||
gearPosition: int32(bytes, offset + ITEM_FIELDS.gearPosition),
|
||||
eqTypeValuePairs: eqPairs,
|
||||
dropAbilityIdRatePairs: dropAbilityPairs,
|
||||
durable: int32(bytes, offset + ITEM_FIELDS.durable),
|
||||
refineTableId: int32(bytes, offset + ITEM_FIELDS.refineTableId),
|
||||
attackRange: filename === "weaponobject.db" ? int32(bytes, offset + ITEM_FIELDS.attackRange) : null,
|
||||
attackSpeed: filename === "weaponobject.db" ? int32(bytes, offset + ITEM_FIELDS.attackSpeed) : null,
|
||||
refineLevel: int32(bytes, offset + ITEM_FIELDS.refineLevel),
|
||||
addPowerQuality: int32(bytes, offset + ITEM_FIELDS.addPowerQuality),
|
||||
collectionNpcId: int32(bytes, offset + ITEM_FIELDS.collectionNpc),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const referencedItemIds = new Set();
|
||||
const npcs = nativeCombatData.npcs.map((catalogNpc) => {
|
||||
const offset = npcSourceOffsets.get(catalogNpc.npcId);
|
||||
if (offset === undefined) throw new Error(`Missing NPCObjectDB row ${catalogNpc.npcId}.`);
|
||||
const bytes = buffers["npcobject.db"];
|
||||
const candidates = (slot) => Array.from({ length: slot.count }, (_, candidateIndex) => {
|
||||
const sourceItemId = int32(bytes, offset + slot.start + candidateIndex * 4);
|
||||
if (sourceItemId > 0) referencedItemIds.add(sourceItemId);
|
||||
return { candidateIndex, sourceItemId, selectionWeightPercent: 20 };
|
||||
});
|
||||
const bowEquipmentId = int32(bytes, offset + NPC_EQUIPMENT.bow.start);
|
||||
const ornamentEquipmentId = int32(bytes, offset + NPC_EQUIPMENT.ornament.start);
|
||||
if (bowEquipmentId > 0) referencedItemIds.add(bowEquipmentId);
|
||||
if (ornamentEquipmentId > 0) referencedItemIds.add(ornamentEquipmentId);
|
||||
return {
|
||||
npcId: catalogNpc.npcId,
|
||||
sourceRecordIndex: (offset - DATA_START) / NPC_RECORD_SIZE,
|
||||
mainHandCandidates: candidates(NPC_EQUIPMENT.mainHand),
|
||||
offHandCandidates: candidates(NPC_EQUIPMENT.offHand),
|
||||
bowEquipmentId,
|
||||
ornamentEquipmentId,
|
||||
};
|
||||
});
|
||||
|
||||
const referencedRefineIds = new Set();
|
||||
for (const itemId of referencedItemIds) {
|
||||
const item = sourceItemsById.get(itemId);
|
||||
if (!item || item.refineLevel <= 0) continue;
|
||||
const level = Math.min(item.refineLevel, 20);
|
||||
for (let refineLevel = 1; refineLevel <= level; refineLevel += 1) {
|
||||
referencedRefineIds.add(item.refineTableId + refineLevel - 1);
|
||||
}
|
||||
}
|
||||
const refineById = new Map();
|
||||
const refineBytes = buffers["eqrefineabilityobject.db"];
|
||||
for (let index = 0; index < refineCount; index += 1) {
|
||||
const offset = DATA_START + index * REFINE_RECORD_SIZE;
|
||||
if (int32(refineBytes, offset + 4) !== REFINE_OBJECT_TYPE) {
|
||||
throw new Error(`eqrefineabilityobject.db object type changed at ${index}.`);
|
||||
}
|
||||
const id = int32(refineBytes, offset);
|
||||
if (!referencedRefineIds.has(id)) continue;
|
||||
refineById.set(id, {
|
||||
refineObjectId: id,
|
||||
sourceRecordIndex: index,
|
||||
refineType: int32(refineBytes, offset + REFINE_FIELDS.type),
|
||||
refineLevel: int32(refineBytes, offset + REFINE_FIELDS.level),
|
||||
eqTypeValuePairs: Array.from({ length: 10 }, (_, slot) => ({
|
||||
slot,
|
||||
eqType: int32(refineBytes, offset + REFINE_FIELDS.eqTypes + slot * 4),
|
||||
value: int32(refineBytes, offset + REFINE_FIELDS.eqValues + slot * 4),
|
||||
})),
|
||||
exEqPowerRate: int32(refineBytes, offset + REFINE_FIELDS.exEqPowerRate),
|
||||
});
|
||||
}
|
||||
const refineItemsById = Object.fromEntries([...referencedRefineIds].sort((left, right) => left - right)
|
||||
.map((id) => [id, refineById.get(id) ?? null]));
|
||||
const unresolvedRefineIds = Object.entries(refineItemsById).filter(([, item]) => item === null).map(([id]) => Number(id));
|
||||
|
||||
const itemsById = {};
|
||||
for (const itemId of [...referencedItemIds].sort((left, right) => left - right)) {
|
||||
itemsById[itemId] = sourceItemsById.get(itemId) ?? null;
|
||||
}
|
||||
const unresolvedItemIds = Object.entries(itemsById).filter(([, item]) => item === null).map(([id]) => Number(id));
|
||||
const output = {
|
||||
schemaVersion: 1,
|
||||
source: {
|
||||
root: path.relative(projectRoot, sourceRoot).split(path.sep).join("/"),
|
||||
files: Object.fromEntries(filenames.map((filename) => [filename, {
|
||||
sha256: sha256(buffers[filename]),
|
||||
recordStart: DATA_START,
|
||||
recordSize: filename === "npcobject.db" ? NPC_RECORD_SIZE
|
||||
: filename === "eqrefineabilityobject.db" ? REFINE_RECORD_SIZE : ITEM_RECORD_SIZE,
|
||||
recordCount: filename === "npcobject.db" ? npcCount
|
||||
: filename === "eqrefineabilityobject.db" ? refineCount : itemCounts[filename],
|
||||
}])),
|
||||
npcEquipmentOffsets: NPC_EQUIPMENT,
|
||||
itemFieldOffsets: ITEM_FIELDS,
|
||||
refineRecordSize: REFINE_RECORD_SIZE,
|
||||
refineFieldOffsets: REFINE_FIELDS,
|
||||
npcWearDefaults: { fieldDurable: 10000, fieldQuality: 100, fieldPowerQuality: 10, fieldRare: 0, inherentIds: [], runeIds: [] },
|
||||
candidateRule: "RD_Setting.cxx selects MainHandEQ and SecHandEQ with rand() % 5; preserve every slot and duplicate at 20 percent each.",
|
||||
simulatorSelectionPolicy: "Stable weighted selection over the source candidate slots using a caller-provided actor seed (or npcId default); each preserved 20-percent slot remains an independent source outcome and no item-stat averaging is performed.",
|
||||
cxxSource: "Runewaker/SourceCodeVS2019/Libraries/Union/RoleData/ObjectStruct.h:GameObjectNpcStruct",
|
||||
trailingItemFields: "ObjectStruct.h GameObjectItemStruct ends with RefineLv, AddPowerQuality, CollectionNPC; each is int32, so the 1776-byte fixed item row maps them to offsets 1764, 1768, 1772 respectively.",
|
||||
itemOffset134Meaning: "GameObjectItemStruct::DropItemAbility[] (drop metadata, not equipment affixes). NPC spawn initialization does not populate runtime ItemFieldStruct inherent or rune slots.",
|
||||
refineSource: "RoleDataEx::WearEQ reads RefineLv (capped at 20) and RoleDataEx::GetRefinAblility resolves Item.RefineTableID + level - 1.",
|
||||
unresolvedItemIds,
|
||||
unresolvedRefineIds,
|
||||
},
|
||||
npcs,
|
||||
itemsById,
|
||||
refineItemsById,
|
||||
};
|
||||
const outputPath = path.join(projectRoot, "src/game/generated/romNativeNpcEquipment.generated.json");
|
||||
await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`);
|
||||
console.log(`Wrote ${npcs.length} NPC rows and ${Object.keys(itemsById).length} referenced items to ${path.relative(projectRoot, outputPath)}.`);
|
||||
@@ -0,0 +1,335 @@
|
||||
#!/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)}.`);
|
||||
@@ -35,6 +35,18 @@ const MAGIC_COLLECT_RECORD_SIZE = 1032;
|
||||
const MAGIC_OBJECT_RECORD_SIZE = 992;
|
||||
const MAGIC_COLLECT_TYPE = 21;
|
||||
const MAGIC_OBJECT_TYPE = 3;
|
||||
const PHYSICAL_CALCULATION_TYPES = new Set([0, 3, 9]);
|
||||
const MAGICAL_CALCULATION_TYPES = new Set([1, 10]);
|
||||
const SOURCE_CHANNELS = ["earth", "water", "fire", "wind", "light", "darkness"];
|
||||
const RUNTIME_SCHOOLS_BY_SOURCE_CHANNEL = {
|
||||
earth: "nature",
|
||||
water: "frost",
|
||||
fire: "fire",
|
||||
wind: "nature",
|
||||
light: "holy",
|
||||
darkness: "shadow",
|
||||
physical: "physical",
|
||||
};
|
||||
|
||||
function sha256(buffer) {
|
||||
return createHash("sha256").update(buffer).digest("hex");
|
||||
@@ -128,6 +140,7 @@ function magicComponent(id) {
|
||||
magicFunc,
|
||||
magicType,
|
||||
effectType,
|
||||
calculationType: int32(magicObject, offset + 0x2d4),
|
||||
attackType,
|
||||
damagePower,
|
||||
fixedValue,
|
||||
@@ -141,10 +154,27 @@ function magicComponent(id) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function sourceDamageClassForCalculationType(calculationType) {
|
||||
if (PHYSICAL_CALCULATION_TYPES.has(calculationType)) return "physical";
|
||||
if (MAGICAL_CALCULATION_TYPES.has(calculationType)) return "magical";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sourceChannelForMagicType(magicType) {
|
||||
if (magicType === -1) return "physical";
|
||||
return SOURCE_CHANNELS[magicType];
|
||||
}
|
||||
|
||||
function schoolFor(component) {
|
||||
if (component.effectType === 1) return "physical";
|
||||
return ["nature", "frost", "fire", "nature", "holy", "shadow"][component.magicType]
|
||||
?? "arcane";
|
||||
const sourceChannel = sourceChannelForMagicType(component.magicType);
|
||||
return sourceChannel ? RUNTIME_SCHOOLS_BY_SOURCE_CHANNEL[sourceChannel] : undefined;
|
||||
}
|
||||
|
||||
function attackKindForCalculationType(calculationType) {
|
||||
if (calculationType === 0 || calculationType === 9) return "melee";
|
||||
if (calculationType === 3) return "ranged";
|
||||
if (calculationType === 1 || calculationType === 10) return "spell";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formulaFor(spellId, component) {
|
||||
@@ -159,7 +189,8 @@ function formulaFor(spellId, component) {
|
||||
spellId,
|
||||
componentId: component.id,
|
||||
spellLevel: 0,
|
||||
calculationPath: component.effectType === 1 ? "physical" : "magic",
|
||||
calculationPath: sourceDamageClassForCalculationType(component.calculationType) === "physical"
|
||||
? "physical" : "magic",
|
||||
powerKind,
|
||||
damagePower: component.damagePower,
|
||||
fixedValue: component.fixedValue,
|
||||
@@ -257,12 +288,23 @@ for (const [spellId, assignmentMap] of [...assignmentsBySpell].sort((left, right
|
||||
const effectRange = int32(magicCollect, offset + 0xc0);
|
||||
const rangeType = int32(magicCollect, offset + 0xc4);
|
||||
const sourceCooldownSeconds = int32(magicCollect, offset + 0x108);
|
||||
const primaryDamage = damageComponents[0];
|
||||
const runtimeDamageComponents = damageComponents.filter((component) => (
|
||||
sourceDamageClassForCalculationType(component.calculationType)
|
||||
&& attackKindForCalculationType(component.calculationType)
|
||||
&& schoolFor(component)
|
||||
));
|
||||
const primaryDamage = runtimeDamageComponents[0];
|
||||
const unsupportedReasons = [];
|
||||
if (!components.length) unsupportedReasons.push("No referenced MagicObjectDB components were found.");
|
||||
if (!damageComponents.length) {
|
||||
unsupportedReasons.push("No component is positively classified as direct HP damage.");
|
||||
}
|
||||
if (damageComponents.length !== runtimeDamageComponents.length) {
|
||||
unsupportedReasons.push(
|
||||
(damageComponents.length - runtimeDamageComponents.length)
|
||||
+ " direct-damage component(s) have an unsupported Attack.CalType or MagicType.",
|
||||
);
|
||||
}
|
||||
if (components.length !== damageComponents.length) {
|
||||
unsupportedReasons.push(
|
||||
(components.length - damageComponents.length)
|
||||
@@ -294,7 +336,9 @@ for (const [spellId, assignmentMap] of [...assignmentsBySpell].sort((left, right
|
||||
delivery: range > 5 ? "projectile" : "melee",
|
||||
target: "primary",
|
||||
school: schoolFor(primaryDamage),
|
||||
animation: range > 5 || primaryDamage.effectType === 0 ? "cast" : "attack",
|
||||
runewakerEffectType: sourceDamageClassForCalculationType(primaryDamage.calculationType) === "magical" ? 0 : 1,
|
||||
attackKind: attackKindForCalculationType(primaryDamage.calculationType),
|
||||
animation: range > 5 || attackKindForCalculationType(primaryDamage.calculationType) === "spell" ? "cast" : "attack",
|
||||
range,
|
||||
cooldownMs: Math.max(2500, Math.min(20000, sourceCooldownSeconds > 0
|
||||
? sourceCooldownSeconds * 1000
|
||||
|
||||
@@ -149,6 +149,9 @@ function damageMechanic(entityId, spell) {
|
||||
delivery: spatial.delivery,
|
||||
target: spatial.target,
|
||||
school: runtime.school,
|
||||
damageOrigin: "runewaker",
|
||||
attackKind: runtime.attackKind,
|
||||
runewakerEffectType: runtime.runewakerEffectType,
|
||||
animation: runtime.animation,
|
||||
castTimeMs,
|
||||
range: Math.max(runtime.range, spatial.radius ?? 0),
|
||||
|
||||
@@ -5,9 +5,9 @@ head 0 0
|
||||
hair 0 0
|
||||
helmet 0 0
|
||||
shoulder 0 0
|
||||
torso 0 0
|
||||
hand 0 0
|
||||
torso cloth010-001 0 0
|
||||
hand cloth010-001 0 0
|
||||
belt 0 0
|
||||
leg 0 0
|
||||
foot 0 0
|
||||
leg cloth010-001 0 0
|
||||
foot cloth010-001 0 0
|
||||
back 0 0
|
||||
|
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"dungeonId": "runewaker-starter-weapons",
|
||||
"zoneId": 1,
|
||||
"coordinateSystem": {
|
||||
"metersPerSourceUnit": 0.1,
|
||||
"directionUnitsPerTurn": 256,
|
||||
"maximumProjectionDistance": 2
|
||||
},
|
||||
"files": {
|
||||
"snapshot": "scripts/runewaker-pipeline/snapshots/runewaker-starter-weapons-unused.json",
|
||||
"generatedSource": "src/game/generated/runewakerStarterWeapons.unused.ts",
|
||||
"report": "scripts/runewaker-pipeline/snapshots/runewaker-starter-weapons-import-report.json",
|
||||
"environmentMetadata": "scripts/runewaker-pipeline/snapshots/runewaker-starter-weapons-environment-unused.json",
|
||||
"navigation": "scripts/runewaker-pipeline/snapshots/runewaker-starter-weapons-navigation-unused.glb",
|
||||
"actorManifest": "public/assets/equipment/runewaker-v1/import-manifest.json",
|
||||
"actorOutput": "public/assets/equipment/runewaker-v1"
|
||||
},
|
||||
"source": {
|
||||
"rootEnvironmentVariable": "RUNEWAKER_ROOT",
|
||||
"relativeDefault": "../Runewaker",
|
||||
"resourceRoot": "Resource",
|
||||
"catalogs": []
|
||||
},
|
||||
"actors": [
|
||||
{
|
||||
"id": "starter-sword",
|
||||
"sourceModel": "model/item/weapon/blade/act_blade01_000_001.ros",
|
||||
"runtimeRotationY": 0
|
||||
},
|
||||
{
|
||||
"id": "starter-mace",
|
||||
"sourceModel": "model/item/weapon/hammer/act_hammer01_000_001.ros",
|
||||
"runtimeRotationY": 0
|
||||
},
|
||||
{
|
||||
"id": "starter-shield",
|
||||
"sourceModel": "model/item/weapon/shield/act_shield01_000_001.ros",
|
||||
"runtimeRotationY": 0
|
||||
},
|
||||
{
|
||||
"id": "starter-staff",
|
||||
"sourceModel": "model/item/weapon/staff/act_staff00_000_001.ros",
|
||||
"runtimeRotationY": 0
|
||||
},
|
||||
{
|
||||
"id": "starter-bow",
|
||||
"sourceModel": "model/item/weapon/bow/act_bow03_010_001.ros",
|
||||
"runtimeRotationY": 0
|
||||
},
|
||||
{
|
||||
"id": "starter-dagger",
|
||||
"sourceModel": "model/item/weapon/dagger/act_dagger01_010_005.ros",
|
||||
"runtimeRotationY": 0
|
||||
}
|
||||
],
|
||||
"templates": [],
|
||||
"deferred": []
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user