Update Healer Man server
This commit is contained in:
1 parent
39458d5de6
commit
6cc1b9217f
203 files changed
+133929
-31271
No files matched your search
@@ -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.`);
|
||||
Reference in new issue
Block a user