import { createHash } from "node:crypto"; import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { camelCase, optionValue, pipelineDirectory, projectPath, projectRoot, readJson, slugify, } from "./lib/recipe.mjs"; import { inspectWdb } from "./lib/wdb.mjs"; const argv = process.argv.slice(2); const rawFile = path.resolve(projectRoot, optionValue(argv, "--raw", "runewaker-export-work/all-instances-forensic-raw.json")); const sourceRoot = path.resolve(process.env.RUNEWAKER_ROOT ?? path.join(projectRoot, "../rom-pvt-server-backups/Runewaker")); const resourceRoot = path.join(sourceRoot, "Resource"); const configFile = path.join(sourceRoot, "Tools", "dungeon_config.json"); const entryScriptFile = path.join(resourceRoot, "luascript", "03246.lua"); const overridesFile = path.join(pipelineDirectory, "discovery-overrides.json"); const recipesDirectory = path.join(pipelineDirectory, "recipes"); const snapshotsDirectory = path.join(pipelineDirectory, "snapshots"); const SOURCE_ANCHOR_SUPPORT_DUNGEONS = new Set([ "mystic_altar", "treasure_trove", "the_origin", "demon_stronghold", "raksha_temple", "aeternal_circle", ]); const clean = (value) => String(value ?? "").replaceAll(String.fromCharCode(0), "").trim(); const normalizeModelPath = (value) => clean(value) .replaceAll(String.fromCharCode(92), "/") .replace(/^\/+/, "") .replace(/^resource\//i, "") .toLowerCase(); const stableHash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex"); const sha256 = async (file) => createHash("sha256").update(await readFile(file)).digest("hex"); function entrySpawns(source) { const result = new Map(); const number = "(-?\\d+(?:\\.\\d+)?)"; const pattern = new RegExp( "id=\\\"([^\\\"]+)\\\"[^\\n]*spawn=\\{\\s*x=" + number + ",\\s*y=" + number + ",\\s*z=" + number + ",\\s*dir=" + number, "g", ); for (const match of source.matchAll(pattern)) { result.set(match[1], { position: match.slice(2, 5).map(Number), direction: Number(match[5]), evidence: "Resource/luascript/03246.lua:MS_TEST_DUNGEONS", }); } return result; } function normalizedWdb(value) { return clean(value).replaceAll(String.fromCharCode(92), "/").replace(/^\/+/, "").replace(/^wdb\//i, ""); } function actorIdFor(model, used) { const base = slugify(path.parse(model).name.replace(/^act[_-]?/i, "")) || "actor"; if (!used.has(base) || used.get(base) === model) return base; return base + "-" + stableHash(model).slice(0, 8); } function colorsFor(id) { const hue = (Number(id) * 47) % 360; return { primaryColor: `hsl(${hue} 22% 38%)`, accentColor: `hsl(${(hue + 28) % 360} 38% 64%)`, }; } function archetypeFor(model) { if (/slime|ooze/.test(model)) return "ooze"; if (/bat|bird|harpy|dragon|wing/.test(model)) return "winged"; if (/serpent|snake|naga/.test(model)) return "serpent"; if (/turtle|tortoise/.test(model)) return "turtle"; if (/raptor/.test(model)) return "raptor"; if (/crocolisk|crocodile/.test(model)) return "crocolisk"; if (/plant|myconid|fung|flower|tree/.test(model)) return "plant"; if (/murloc/.test(model)) return "murloc"; if (/spider|lizard|salamander|frog|bug|scarab|ant/.test(model)) return "lizard"; return "humanoid"; } function preferredModel(rows) { const counts = new Map(); for (const row of rows) { const model = normalizeModelPath(row.modelPath); if (!/^model\/character\/.+\.ros$/i.test(model)) continue; counts.set(model, (counts.get(model) ?? 0) + 1); } return [...counts].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))[0]?.[0] ?? null; } function templateDefinition(templateId, rows, model, actor) { const first = rows[0]; const bossFlag = rows.some((row) => Number(row.sex) === 3); const level = Math.max(...rows.map((row) => Number(row.nativeLevel ?? 0))); const combat = Boolean(model) && level > 0; // sex=3 is authoritative crown/boss evidence only for an actual character // display. Several scripted doors and invisible FX controllers also carry // sex=3; promoting those props to combat entities creates fake bosses and // impossible objective routes. const boss = bossFlag && combat; const classification = boss ? "boss" : combat ? "combat" : "deferred-object"; const sourceName = clean(first.localizedName) || clean(first.roleName); const name = sourceName && !/^sys\d+_name$/i.test(sourceName) ? sourceName : "Template " + templateId; const proceduralFallback = boss && !actor; return { ...colorsFor(templateId), id: Number(templateId), name, level, classification, archetype: archetypeFor(model ?? name.toLowerCase()), ...(actor ? { actor } : {}), ...(boss ? { title: name } : {}), ...(proceduralFallback ? { proceduralFallback: true, fallbackReason: "Authoritative NPCObjectDB.sex=3 boss uses a runtime/dynamic display and has no static actor ROS.", } : {}), ...(classification === "deferred-object" ? { reason: level === 0 ? "Level-zero quest, trigger, prop, or noncombat NPC retained for a later interaction pass." : "Positive-level source object has no static character ROS and is retained for mechanics review.", } : {}), source: { imageId: Number(first.imageId ?? 0), modelPath: model, classificationRule: boss ? "NPCObjectDB.sex=3 plus static model/character ROS" : combat ? "positive native level plus static model/character ROS" : bossFlag ? "NPCObjectDB.sex=3 scripted non-character object retained for mechanics review" : "noncombat/dynamic-display source object", }, }; } const [raw, config, entrySource, existingOverrides] = await Promise.all([ readJson(rawFile), readJson(configFile), readFile(entryScriptFile, "utf8"), readJson(overridesFile), ]); if (raw.schemaVersion !== 1 || !Array.isArray(raw.zones)) throw new Error("Unsupported all-instance forensic export."); const rawByZone = new Map(raw.zones.map((zone) => [Number(zone.zoneId), zone])); const entries = entrySpawns(entrySource); const sourceHashes = { backups: { "../rom-pvt-server-backups/OneForAll/SQL/databases/ROM_Global.bak": await sha256(path.join(projectRoot, "../rom-pvt-server-backups/OneForAll/SQL/databases/ROM_Global.bak")), "../rom-pvt-server-backups/OneForAll/SQL/databases/ObjectEdit.bak": await sha256(path.join(projectRoot, "../rom-pvt-server-backups/OneForAll/SQL/databases/ObjectEdit.bak")), }, catalogs: Object.fromEntries(await Promise.all([ "Resource/data/npcobject.db", "Resource/data/imageobject.db", "Resource/data/string_enus.db", ].map(async (file) => [file, await sha256(path.join(sourceRoot, file))]))), }; await Promise.all([mkdir(recipesDirectory, { recursive: true }), mkdir(snapshotsDirectory, { recursive: true })]); const overrides = { ...existingOverrides }; const prepared = []; for (const dungeon of config.dungeons ?? []) { const slug = slugify(dungeon.id); const zone = rawByZone.get(Number(dungeon.base_zone)); if (!zone) throw new Error("Forensic export is missing Zone " + dungeon.base_zone + "."); const zoneRows = Array.isArray(zone.rows) ? zone.rows : []; const wdb = normalizedWdb(zone.zoneObject?.mapFile); if (!wdb) throw new Error("Zone " + dungeon.base_zone + " has no authoritative mapfile."); overrides[dungeon.id] = { wdb, source: "ObjectEdit.dbo.ZoneObjectDB.mapfile", zoneObjectTemplateId: Number(zone.zoneObjectTemplateId), }; if (dungeon.id === "forsaken_abbey") { prepared.push({ id: dungeon.id, zoneId: dungeon.base_zone, rows: zone.rowCount, wdb, status: "preserved-existing-pilot" }); continue; } const wdbFile = path.join(resourceRoot, "wdb", ...wdb.split("/")); const workRoot = path.join(projectRoot, "runewaker-export-work", slug); const inspection = await inspectWdb({ wdbFile, reportFile: path.join(workRoot, "inspection", "wdb-report.json") }); const hasDungeonModel = inspection.candidates.some((entry) => entry.isDungeonModel); const candidate = inspection.candidates.find((entry) => entry.isDungeonModel && entry.placements === 1) ?? inspection.candidates.find((entry) => entry.isDungeonModel) ?? inspection.candidates[0]; if (!candidate) throw new Error(dungeon.name + " WDB contains no ROS environment candidates."); const model = candidate.resource.replaceAll(String.fromCharCode(92), "/").replace(/^\/+/, ""); await stat(path.join(resourceRoot, ...model.split("/"))); const entry = entries.get(dungeon.id); if (!entry) throw new Error("03246.lua has no entrance for " + dungeon.id + "."); const normalizedModel = normalizeModelPath(model); const modelPlacements = (inspection.report.descriptors ?? []).filter((descriptor) => ( normalizeModelPath(descriptor.resource) === normalizedModel )); const selectedPlacement = [...modelPlacements].sort((left, right) => { const distanceSquared = (descriptor) => descriptor.translation.reduce((sum, value, index) => ( sum + (Number(value) - entry.position[index]) ** 2 ), 0); return distanceSquared(left) - distanceSquared(right); })[0]; if (!selectedPlacement) throw new Error(dungeon.name + " primary model has no WDB placement."); const proceduralTerrainFallback = !hasDungeonModel ? (() => { const extents = (inspection.report.descriptors ?? []).map((descriptor) => { const center = descriptor.worldBounds?.center ?? descriptor.translation; const radius = Number(descriptor.worldBounds?.radius ?? 0); return { center: center.map(Number), radius }; }); const xValues = extents.flatMap(({ center, radius }) => [center[0] - radius, center[0] + radius]); const zValues = extents.flatMap(({ center, radius }) => [center[2] - radius, center[2] + radius]); const combatY = zoneRows.filter((row) => Number(row.nativeLevel) > 0).map((row) => Number(row.sourcePosition[1])); return { kind: "flat-source-evidence-arena", reason: "The authoritative WDB contains placed props but no model/dungeon terrain ROS.", sourceBounds: { min: [Math.min(...xValues, entry.position[0]), 0, Math.min(...zValues, entry.position[2])], max: [Math.max(...xValues, entry.position[0]), 0, Math.max(...zValues, entry.position[2])], }, floorYSource: Math.min(Number(entry.position[1]), ...combatY), paddingSourceUnits: 20, thicknessSourceUnits: 4, evidence: ["ObjectEdit.dbo.ZoneObjectDB.mapfile", "WDB descriptor world bounds", entry.evidence], reviewRequired: true, }; })() : null; const assemblyPlacementTranslations = dungeon.id === "ystra_labyrinth_boss1" ? modelPlacements.map((placement) => placement.translation.map(Number)) : null; const sourceAnchorSupport = SOURCE_ANCHOR_SUPPORT_DUNGEONS.has(dungeon.id) ? (() => { const unique = new Map(); for (const position of [ entry.position, ...zoneRows.filter((row) => Number(row.nativeLevel) > 0).map((row) => row.sourcePosition), ]) { const point = position.map(Number); unique.set(point.map((value) => value.toFixed(3)).join(":"), point); } return { kind: "source-anchor-support-pads", reason: "The authoritative WDB is modular or its primary ROS collision has navigation gaps at source spawn anchors.", points: [...unique.values()], halfExtentSourceUnits: 60, thicknessSourceUnits: 3, evidence: ["ROM_Global.dbo.NPCData source coordinates", entry.evidence], reviewRequired: true, }; })() : null; const camel = camelCase(slug); const environmentRecipeFile = path.join(recipesDirectory, slug + ".json"); const populationRecipeFile = path.join(recipesDirectory, slug + "-population.json"); const environmentRecipe = { schemaVersion: 2, slug, title: dungeon.name, source: { rootEnvironmentVariable: "RUNEWAKER_ROOT", relativeDefault: "../rom-pvt-server-backups/Runewaker", resourceRoot: "Resource", wdb: "Resource/wdb/" + wdb, model, primaryPlacementTranslation: selectedPlacement.translation.map(Number), ...(assemblyPlacementTranslations ? { assemblyPlacementTranslations } : {}), ...(proceduralTerrainFallback ? { proceduralTerrainFallback } : {}), ...(sourceAnchorSupport ? { sourceAnchorSupport } : {}), modelSelection: { rule: proceduralTerrainFallback ? "largest WDB prop retained as the coordinate anchor; playable terrain generated from authoritative WDB bounds" : candidate.placements === 1 ? "largest unique model/dungeon ROS" : "largest model/dungeon ROS placement nearest the authoritative entrance", score: candidate.score, placements: candidate.placements, dominance: inspection.recommendation?.dominance ?? null, }, dungeonConfig: "Tools/dungeon_config.json", dungeonConfigId: dungeon.id, entryCoordinates: { position: entry.position, direction: entry.direction, reviewRequired: false, evidence: entry.evidence }, }, coordinateSystem: { source: "left-handed Y-up", nativeBridge: "three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)", metersPerSourceUnit: 0.1, directionUnitsPerTurn: 360, calibration: "model/character/pc/dummy/fake_pc.ros mesh height is 16.4977 source units", }, navigation: { cellSize: 0.3, cellHeight: 0.1, agentHeight: 1.8, agentRadius: 0.36, agentMaxClimb: 0.5, agentMaxSlope: 50, tileSize: 64, retainTileIntermediates: false, }, review: { environment: proceduralTerrainFallback ? "authoritative-wdb-has-no-terrain-procedural-fallback-review-required" : "authoritative-wdb-largest-model-selected", collision: sourceAnchorSupport ? "primary-ros-plus-source-anchor-support-review-required" : "generated-review-required", entrance: "source-script", bossAnchors: "source-population", wdbPlacements: assemblyPlacementTranslations ? "all-repeated-primary-room-placements-assembled" : candidate.placements === 1 ? "unique-primary-model" : "nearest-authoritative-entrance", }, exceptions: [ "Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.", "Collision initially includes decorative primary-ROS geometry.", "Original GR2 actor animations are recovered where source graphs and validation permit; scripted encounter mechanics remain evidence-driven per-instance work.", ...(proceduralTerrainFallback ? [ "The source WDB has no terrain ROS; a flat playable arena is derived from source descriptor bounds and must be visually reviewed.", ] : []), ...(sourceAnchorSupport ? [ "Source-anchor support pads preserve combat and entrance coordinates where modular WDB sections or primary-ROS collision leave navmesh gaps; full secondary placement assembly remains a visual follow-up.", ] : []), ], }; const rowsByTemplate = new Map(); for (const row of zoneRows) { const bucket = rowsByTemplate.get(Number(row.templateId)) ?? []; bucket.push(row); rowsByTemplate.set(Number(row.templateId), bucket); } const actorsByModel = new Map(); const actorIds = new Map(); const templates = []; for (const [templateId, templateRows] of [...rowsByTemplate].sort(([left], [right]) => left - right)) { const sourceModel = preferredModel(templateRows); let actor = null; if (sourceModel) { let definition = actorsByModel.get(sourceModel); if (!definition) { const id = actorIdFor(sourceModel, actorIds); definition = { id, sourceModel, runtimeRotationY: 0 }; actorsByModel.set(sourceModel, definition); actorIds.set(id, sourceModel); } actor = definition.id; } templates.push(templateDefinition(templateId, templateRows, sourceModel, actor)); } const templateMap = new Map(templates.map((template) => [template.id, template])); const snapshotRows = zoneRows.map((row) => { const template = templateMap.get(Number(row.templateId)); const modelPath = normalizeModelPath(row.modelPath) || template.source.modelPath; return { spawnId: Number(row.spawnId), templateId: Number(row.templateId), name: template.name, sourceName: clean(row.localizedName) || clean(row.roleName) || template.name, classification: template.classification, level: Number(row.nativeLevel), sex: Number(row.sex), imageId: Number(row.imageId ?? 0), modelPath: modelPath || null, sourcePosition: row.sourcePosition.map(Number), direction: Number(row.direction), source: { roomId: Number(row.roomId), roleName: clean(row.roleName), autoPlot: clean(row.autoPlot), plotClassName: clean(row.plotClassName), templateScripts: Object.fromEntries(Object.entries(row.templateScripts ?? {}).map(([key, value]) => [key, clean(value)])), spells: row.spells ?? [], }, }; }).sort((left, right) => left.spawnId - right.spawnId); const content = { schemaVersion: 2, dungeonId: slug, zoneId: Number(dungeon.base_zone), queryVersion: raw.queryVersion, rowCount: snapshotRows.length, sourceHashes, rows: snapshotRows, }; const snapshot = { ...content, contentSha256: stableHash(content) }; const snapshotFile = path.join(snapshotsDirectory, slug + "-zone-" + dungeon.base_zone + ".json"); const bossSpawns = snapshotRows.filter((row) => row.classification === "boss").length; const populationRecipe = { schemaVersion: 2, dungeonId: slug, sourceDungeonConfigId: dungeon.id, zoneId: Number(dungeon.base_zone), expectedActiveRows: snapshotRows.length, expectedBossSpawns: bossSpawns, runtimeIdPrefix: "rw-" + slug, coordinateSystem: { metersPerSourceUnit: 0.1, directionUnitsPerTurn: 360, maximumProjectionDistance: 4 }, files: { environmentRecipe: projectPath(environmentRecipeFile), snapshot: projectPath(snapshotFile), generatedSource: "src/game/generated/" + camel + "Population.generated.ts", report: "src/assets/game/dungeons/" + slug + "/population-import-report.json", mechanicsReport: "src/assets/game/dungeons/" + slug + "/mechanics-audit.json", mechanicsSource: "src/game/generated/" + camel + "Mechanics.generated.ts", environmentMetadata: "src/assets/game/dungeons/" + slug + "/import-metadata.json", navigation: "src/assets/game/dungeons/" + slug + "/" + slug + "-navigation.glb", actorManifest: "public/assets/creatures/" + slug + "/manifest.json", actorOutput: "public/assets/creatures/" + slug, }, source: { rootEnvironmentVariable: "RUNEWAKER_ROOT", relativeDefault: "../rom-pvt-server-backups/Runewaker", resourceRoot: "Resource", dungeonConfig: "Tools/dungeon_config.json", dungeonConfigId: dungeon.id, luaRoot: "Resource/luascript", catalogs: ["Resource/data/npcobject.db", "Resource/data/imageobject.db", "Resource/data/string_enus.db"], backups: { global: "../rom-pvt-server-backups/OneForAll/SQL/databases/ROM_Global.bak", objects: "../rom-pvt-server-backups/OneForAll/SQL/databases/ObjectEdit.bak", }, serverEnvironmentVariable: "RUNEWAKER_SQL_SERVER", serverDefault: ".\\HEALERMAN_RW", }, actors: [...actorsByModel.values()].sort((left, right) => left.id.localeCompare(right.id)), templates, deferred: [ "Doors, summons, patrols, phases, interactables, and loot require reviewed mechanics mappings.", "Direct GR2 actors and exact paperdoll appearances use recovered native animation when validated; explicit pose-only/proxy exceptions retain procedural presentation.", ], }; await Promise.all([ writeFile(environmentRecipeFile, JSON.stringify(environmentRecipe, null, 2) + "\n", "utf8"), writeFile(populationRecipeFile, JSON.stringify(populationRecipe, null, 2) + "\n", "utf8"), writeFile(snapshotFile, JSON.stringify(snapshot, null, 2) + "\n", "utf8"), ]); prepared.push({ id: dungeon.id, slug, zoneId: dungeon.base_zone, rows: snapshotRows.length, bosses: bossSpawns, actors: actorsByModel.size, wdb, model, modelPlacements: candidate.placements, modelSelection: candidate.placements === 1 ? "automatic-unique" : "automatic-nearest-entrance", }); } await writeFile(overridesFile, JSON.stringify(overrides, null, 2) + "\n", "utf8"); const reportFile = path.join(projectRoot, "runewaker-export-work", "all-instances-preparation-report.json"); await writeFile(reportFile, JSON.stringify({ schemaVersion: 1, generatedAt: new Date().toISOString(), source: projectPath(rawFile), counts: { configured: prepared.length, generated: prepared.filter((entry) => entry.status !== "preserved-existing-pilot").length, sourceRows: prepared.reduce((sum, entry) => sum + entry.rows, 0), bossSpawns: prepared.reduce((sum, entry) => sum + Number(entry.bosses ?? 0), 0), }, dungeons: prepared, }, null, 2) + "\n", "utf8"); console.log("[runewaker] prepared " + (prepared.length - 1) + " remaining instance recipes and portable snapshots."); console.log("[runewaker] report: " + projectPath(reportFile));