Files
healer-man/scripts/runewaker-pipeline/export-native-lighting.mjs
T
2026-10-06 22:25:34 -04:00

204 lines
10 KiB
JavaScript

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.`);
}