#!/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.`);