204 lines
10 KiB
JavaScript
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.`);
|
|
}
|