import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; import { mkdir, readFile, readdir, 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 recipesDirectory = path.join(scriptDirectory, "recipes"); const sourceRoot = path.resolve( process.env.RUNEWAKER_ROOT ?? path.join(projectRoot, "..", "rom-pvt-server-backups", "Runewaker"), ); const outputRoot = path.resolve( process.argv.find((value) => value.startsWith("--output="))?.slice("--output=".length) ?? path.join(projectRoot, "runewaker-export-work", "native-lighting-source"), ); const executable = path.join(outputRoot, "bin", "runewaker-zone-lighting-exporter.exe"); const sourceMeters = 0.1; function stable(value) { return JSON.stringify(value, null, 2) + "\n"; } function round(value) { return Number(Number(value).toFixed(9)); } function requireVector(value, label) { if (!Array.isArray(value) || value.length !== 3 || value.some((component) => !Number.isFinite(Number(component)))) { throw new Error(`${label} must be a three-number vector.`); } return value.map(Number); } async function readJson(file) { return JSON.parse(await readFile(file, "utf8")); } async function sha256(file) { return createHash("sha256").update(await readFile(file)).digest("hex"); } async function findPrimaryPlacement(slug) { const candidates = [ path.join(projectRoot, "runewaker-export-work", slug, "converted", "import-metadata.json"), path.join(projectRoot, "src", "assets", "game", "dungeons", slug, "import-metadata.json"), ]; for (const candidate of candidates) { try { const metadata = await readJson(candidate); const placement = metadata.source?.wdbReport?.primaryPlacement; if (placement) return placement; } catch {} } const legacy = await readJson(path.join(projectRoot, "runewaker-export-work", slug, "inspection", "wdb-environment-candidates.json")); return legacy.recommendation?.placement ?? null; } async function parseLuaEntry(recipe) { const prefix = recipe.source?.entryConstantPrefix; const relative = recipe.source?.entryLua; if (!prefix || !relative) return null; const lua = await readFile(path.join(sourceRoot, relative), "utf8"); const readConstant = (suffix) => { const match = lua.match(new RegExp(`^\\s*${prefix}_${suffix}\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)`, "m")); if (!match) throw new Error(`${recipe.slug}: missing ${prefix}_${suffix} in ${relative}`); return Number(match[1]); }; return [readConstant("X"), readConstant("Y"), readConstant("Z")]; } async function resolveAuthoredEntry(recipe) { const configured = recipe.source?.entryCoordinates?.position; if (configured) return { position: requireVector(configured, `${recipe.slug} entry`), evidence: recipe.source.entryCoordinates.evidence ?? "recipe source entry" }; const luaPosition = await parseLuaEntry(recipe); if (luaPosition) return { position: luaPosition, evidence: `${recipe.source.entryLua}:${recipe.source.entryConstantPrefix}_X/Y/Z` }; return null; } async function nativeDump(wdbFile, outFile, probe) { const args = [wdbFile, outFile]; if (probe) args.push(...probe.map((value) => String(round(value)))); execFileSync(executable, args, { stdio: "inherit" }); return readJson(outFile); } const recipeFiles = (await readdir(recipesDirectory)) .filter((file) => file.endsWith(".json") && !file.endsWith("-population.json")) .sort(); const recipes = await Promise.all(recipeFiles.map((file) => readJson(path.join(recipesDirectory, file)))); const dungeons = {}; const missing = []; await mkdir(path.join(outputRoot, "dumps"), { recursive: true }); for (const recipe of recipes) { if (!recipe.slug || !recipe.source?.wdb) continue; const wdbFile = path.resolve(sourceRoot, recipe.source.wdb); const primaryPlacement = await findPrimaryPlacement(recipe.slug); if (!primaryPlacement) throw new Error(`${recipe.slug}: primary WDB placement evidence is missing.`); const wdbSha256 = await sha256(wdbFile); const authoredEntry = await resolveAuthoredEntry(recipe); const reviewedEntryPosition = recipe.source?.reviewedRuntimeEntrance?.sourcePosition ? requireVector(recipe.source.reviewedRuntimeEntrance.sourcePosition, `${recipe.slug} reviewed runtime entry`) : null; const dumpFile = path.join(outputRoot, "dumps", `${recipe.slug}.json`); const native = await nativeDump(wdbFile, dumpFile, authoredEntry?.position ?? null); const entrySelection = native.entryZoneSelection; const entryCovered = Boolean(entrySelection?.triggerZoneId != null && entrySelection?.settingsZoneId != null); const entryEvidence = authoredEntry ? { sourcePosition: authoredEntry.position, kind: "source-authored-entry", evidence: authoredEntry.evidence } : null; let reviewedRuntimeEntry = null; if (reviewedEntryPosition) { const reviewedFile = path.join(outputRoot, "dumps", `${recipe.slug}-runtime-entry.json`); const reviewedDump = await nativeDump(wdbFile, reviewedFile, reviewedEntryPosition); reviewedRuntimeEntry = { sourcePosition: reviewedEntryPosition, evidence: recipe.source.reviewedRuntimeEntrance.sourcePathNode?.graphKey ? `reviewed source path ${recipe.source.reviewedRuntimeEntrance.sourcePathNode.graphKey} node ${recipe.source.reviewedRuntimeEntrance.sourcePathNode.nodeId}` : "reviewed runtime entrance metadata", selection: reviewedDump.entryZoneSelection, covered: Boolean(reviewedDump.entryZoneSelection?.triggerZoneId != null && reviewedDump.entryZoneSelection?.settingsZoneId != null), }; } if (native.zoneTriggerCount !== native.zoneTriggers.length) { throw new Error(`${recipe.slug}: native exporter omitted zone triggers (${native.zoneTriggerCount} reported, ${native.zoneTriggers.length} written).`); } const metadataWdbSha = (await findMetadataWdbSha(recipe.slug)) ?? null; if (metadataWdbSha && metadataWdbSha.toLowerCase() !== wdbSha256) { throw new Error(`${recipe.slug}: import metadata WDB hash does not match the source file.`); } dungeons[recipe.slug] = { source: { wdb: recipe.source.wdb.replaceAll("\\", "/"), wdbSha256, primaryPlacement: { scale: requireVector(primaryPlacement.scale, `${recipe.slug} primary scale`), rotation: primaryPlacement.rotation.map(Number), translation: requireVector(primaryPlacement.translation, `${recipe.slug} primary translation`), }, metersPerSourceUnit: Number(recipe.coordinateSystem?.metersPerSourceUnit ?? sourceMeters), coordinateSystem: recipe.coordinateSystem?.source ?? "left-handed Y-up", }, zones: native.zones, zoneTriggers: native.zoneTriggers, authoredEntry: entryEvidence ? { ...entryEvidence, selection: entrySelection, covered: entryCovered } : null, ...(reviewedRuntimeEntry ? { reviewedRuntimeEntry } : {}), evidence: { sourceEngineZoneSettings: true, sourceEngineZoneTriggerQuery: true, zoneTriggerCount: native.zoneTriggerCount, sourceDescriptorCount: native.zoneDescriptorCount, deterministicDefaults: { timeGroup: 0, weatherGroup: 0 }, triggerSelection: "highest priority; closest vertical-segment hit; child zone on exact tie", }, }; if (!entryCovered) missing.push({ slug: recipe.slug, reason: "authored entry did not resolve to a trigger and zone settings", triggerCount: native.zoneTriggerCount }); if (reviewedRuntimeEntry && !reviewedRuntimeEntry.covered) missing.push({ slug: recipe.slug, reason: "reviewed runtime entry did not resolve to a trigger and zone settings", triggerCount: native.zoneTriggerCount }); } async function findMetadataWdbSha(slug) { const candidates = [ path.join(projectRoot, "runewaker-export-work", slug, "converted", "import-metadata.json"), path.join(projectRoot, "src", "assets", "game", "dungeons", slug, "import-metadata.json"), ]; for (const candidate of candidates) { try { return (await readJson(candidate)).source?.wdbSha256 ?? null; } catch {} } return null; } const manifest = { schemaVersion: 1, generatedAt: new Date().toISOString(), sourceEngine: "Runewaker Rune Engine WDB zone descriptors and trigger QueryCollision", runtimeDefaults: { timeGroup: 0, weatherGroup: 0 }, colorPolicy: "ARGB bytes are normalized directly to [0,1], matching CRuColor4(RUCOLOR); no sRGB transfer is applied by Rune Engine.", directionPolicy: "Native source vector convention from RuMathHelper_GenerateLightingVector; runtime resolver adapts vectors through inverse WDB placement, Z reflection, and dungeon transform.", fogPolicy: "When fog end > 1, Rune Engine divides raw start/end by 1600 then multiplies by camera far distance; raw values and factors are retained.", lightModel: "Zone settings synthesize ambient plus three diffuse directional lights; specular color/direction are separate renderer properties.", limitations: [ "CRuEntity_Light serialization is a no-op; no serialized ROS point/spot/directional light records were recovered.", "RuLightSettings has no cast-shadow field; source zone data does not author runtime shadow policy.", "Rune Engine computes SH lighting from active lights at runtime; zone settings contain no precomputed SH coefficients.", ], coverage: { dungeonCount: Object.keys(dungeons).length, entryMisses: missing }, dungeons, }; await writeFile(path.join(outputRoot, "lighting-source-report.json"), stable(manifest)); const generatedFile = path.join(projectRoot, "src", "game", "generated", "runewakerNativeLighting.generated.ts"); const moduleText = [ "/* Generated by scripts/runewaker-pipeline/export-native-lighting.mjs. */", `export const RUNEWAKER_NATIVE_LIGHTING_SOURCE = (${JSON.stringify({ schemaVersion: 1, runtimeDefaults: manifest.runtimeDefaults, dungeons }, null, 2)}) as const;`, "", ].join("\n"); await writeFile(generatedFile, moduleText, "utf8"); console.log(`Exported native zone lighting for ${Object.keys(dungeons).length} RoM dungeons.`); if (missing.length) { console.warn(`${missing.length} entry probes lack a selected zone/settings pair; the runtime resolver returns null there and keeps the existing profile.`); }