forked from phenom/healer-man
703 lines
38 KiB
JavaScript
703 lines
38 KiB
JavaScript
import { createHash } from "node:crypto";
|
||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||
import path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import { inflateSync, constants as zlibConstants } from "node:zlib";
|
||
|
||
import { Document, NodeIO } from "@gltf-transform/core";
|
||
import { ALL_EXTENSIONS, KHRMaterialsVariants } from "@gltf-transform/extensions";
|
||
import sharp from "sharp";
|
||
import { dxt1ToRgba, parseTerrainChunk, readDataStore } from "./sand-vortex/extract-native-terrain.mjs";
|
||
import { buildNativeWaterGeometry } from "./lib/native-water-geometry.mjs";
|
||
import { decodeNativeWaterPayload } from "./lib/native-water-payload.mjs";
|
||
import { inspectNativeWaterDxt1Dds } from "./lib/native-water-dds.mjs";
|
||
import { nativeWaterRenderPolicy } from "./lib/native-water-render-policy.mjs";
|
||
import { writeNativeWaterAuditCacheAliases } from "./lib/native-water-audit-cache.mjs";
|
||
import { ensureWithinProject, parseNativeWaterBuildArgs, selectNativeWaterChunkKeys } from "./lib/native-water-build-utils.mjs";
|
||
import { sourceTerrainToThreeMatrix } from "./sand-vortex/terrain-geometry.mjs";
|
||
|
||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
||
const REPEAT = 10497;
|
||
|
||
const USAGE = `Build native Runewaker water geometry and animated normal-frame carriers.
|
||
|
||
Usage:
|
||
node scripts/runewaker-pipeline/build-native-water.mjs [options]
|
||
|
||
Options:
|
||
--recipe <path> Recipe JSON (default: scripts/runewaker-pipeline/recipes/the-origin.json)
|
||
--source-root <path> Runewaker source root (default: recipe env var/relativeDefault)
|
||
--wdb-report <path> Descriptor-placement report (default: src/assets/.../<slug>/wdb-report.json)
|
||
--output-root <path> Workspace output directory (default: runewaker-export-work/<slug>/native-water-source)
|
||
--ground-manifest <path> Optional terrain-native-source.json; water output uses only its exact chunk coordinates
|
||
--audit-cache-root <path> Optional source/cache root; writes byte-verified raw/prepared texture aliases
|
||
--help Show this help`;
|
||
|
||
function resolveFromProject(value) {
|
||
return path.resolve(projectRoot, value);
|
||
}
|
||
|
||
function safeFilePart(value) {
|
||
const safe = String(value).replace(/[^a-z\d_-]+/gi, "_").replace(/^_+|_+$/g, "");
|
||
if (!safe) throw new Error("Water sequence has an empty descriptor GUID for output naming.");
|
||
return safe;
|
||
}
|
||
|
||
function recordBytes(wdb, record) {
|
||
return wdb.subarray(record.position, record.position + record.size);
|
||
}
|
||
|
||
function normalizeModel(value) {
|
||
return String(value ?? "").replaceAll("/", "\\").toLowerCase();
|
||
}
|
||
|
||
function selectPrimaryDescriptor(report, recipe) {
|
||
const wanted = normalizeModel(recipe.source.model);
|
||
const matches = (report.descriptors ?? []).filter((row) => normalizeModel(row.resource) === wanted);
|
||
if (matches.length === 0) throw new Error(`WDB report has no primary descriptor for ${recipe.source.model}.`);
|
||
if (matches.length === 1) return matches[0];
|
||
const wantedTranslation = recipe.source.primaryPlacementTranslation;
|
||
if (!Array.isArray(wantedTranslation) || wantedTranslation.length !== 3) {
|
||
throw new Error(`Primary descriptor has ${matches.length} placements, but recipe has no selection translation.`);
|
||
}
|
||
const selected = matches.filter((row) => row.translation?.every((value, index) => (
|
||
Math.abs(Number(value) - Number(wantedTranslation[index])) <= 0.0001
|
||
)));
|
||
if (selected.length !== 1) throw new Error(`Recipe translation selected ${selected.length} primary descriptors.`);
|
||
return selected[0];
|
||
}
|
||
|
||
function getProperty(descriptor, name) {
|
||
return descriptor.baseObject?.properties?.find((property) => property.name === name)?.value ?? null;
|
||
}
|
||
|
||
async function inspectNormalSequence(descriptor, sourceRootPath, outputRoot, auditCacheRoot = null) {
|
||
const textureName = getProperty(descriptor, "waterDesc:full:bump name");
|
||
const frameCount = Number(getProperty(descriptor, "waterDesc:full:bump frame count") ?? 1);
|
||
const frameRate = Number(getProperty(descriptor, "waterDesc:full:bump frame rate") ?? 30);
|
||
if (typeof textureName !== "string" || !textureName || !Number.isInteger(frameCount) || frameCount < 1 || frameCount > 1000) {
|
||
return { report: { status: "no-supported-sequence-fields", textureName, frameCount, frameRate }, carriers: [] };
|
||
}
|
||
const relativeStem = textureName.replaceAll("\\", "/").replace(/^\/+/, "");
|
||
const frames = [];
|
||
const carriers = [];
|
||
const sequenceOutputRoot = path.join(outputRoot, "normal-frames", safeFilePart(descriptor.guid));
|
||
for (let frame = 0; frame < frameCount; frame += 1) {
|
||
const name = `${relativeStem}_${frame}.dds`;
|
||
const file = path.resolve(sourceRootPath, name);
|
||
if (!file.toLowerCase().startsWith(path.resolve(sourceRootPath).toLowerCase() + path.sep.toLowerCase())) {
|
||
throw new Error(`Water normal path escapes the source root: ${name}`);
|
||
}
|
||
try {
|
||
const bytes = await readFile(file);
|
||
const dds = inspectNativeWaterDxt1Dds(bytes);
|
||
const { width, height, mipCount, formatFourCC } = dds;
|
||
const firstMip = bytes.subarray(dds.mips[0].offset, dds.mips[0].offset + dds.mips[0].byteLength);
|
||
const rgba = dxt1ToRgba(firstMip, width, height);
|
||
const png = await sharp(rgba, { raw: { width, height, channels: 4 } }).png().toBuffer();
|
||
const pngRgba = await sharp(png).raw().toBuffer({ resolveWithObject: true });
|
||
if (pngRgba.info.width !== width || pngRgba.info.height !== height || pngRgba.info.channels !== 4
|
||
|| !pngRgba.data.equals(rgba)) throw new Error("PNG normal carrier did not preserve decoded DDS RGBA exactly");
|
||
const variantName = `rom-water-normal-${descriptor.guid}-${String(frame).padStart(2, "0")}`;
|
||
const frameName = `frame-${String(frame).padStart(2, "0")}`;
|
||
const ddsArchivePath = path.join(sequenceOutputRoot, `${frameName}.dds`);
|
||
const pngCarrierPath = path.join(sequenceOutputRoot, `${frameName}.png`);
|
||
await mkdir(sequenceOutputRoot, { recursive: true });
|
||
await Promise.all([writeFile(ddsArchivePath, bytes), writeFile(pngCarrierPath, png)]);
|
||
const auditCacheAliases = auditCacheRoot ? await writeNativeWaterAuditCacheAliases({
|
||
projectRoot,
|
||
auditCacheRoot,
|
||
variantName,
|
||
ddsBytes: bytes,
|
||
pngBytes: png,
|
||
}) : null;
|
||
const [archivedDds, archivedPng] = await Promise.all([readFile(ddsArchivePath), readFile(pngCarrierPath)]);
|
||
if (sha256(archivedDds) !== dds.sha256 || sha256(archivedPng) !== sha256(png)) {
|
||
throw new Error("Archived source DDS or PNG carrier changed while writing.");
|
||
}
|
||
frames.push({
|
||
frame,
|
||
source: name.replaceAll("\\", "/"),
|
||
bytes: dds.fileBytes,
|
||
width,
|
||
height,
|
||
mipCount,
|
||
formatFourCC,
|
||
sha256: dds.sha256,
|
||
sourceDdsArchive: path.relative(outputRoot, ddsArchivePath).replaceAll("\\", "/"),
|
||
sourceDdsArchiveSha256: sha256(archivedDds),
|
||
mips: dds.mips,
|
||
exactMipChainConsumption: dds.exactMipChainConsumption,
|
||
topMipCompressedSha256: dds.mips[0].sha256,
|
||
topMipDecodedRgbaSha256: sha256(rgba),
|
||
pngCarrierSha256: sha256(png),
|
||
pngCarrierBytes: png.length,
|
||
pngCarrierArchive: path.relative(outputRoot, pngCarrierPath).replaceAll("\\", "/"),
|
||
pngCarrierArchiveSha256: sha256(archivedPng),
|
||
pngRgbaRoundTripByteEqual: true,
|
||
...(auditCacheAliases ? { auditCacheAliases } : {}),
|
||
variantName,
|
||
});
|
||
carriers.push({
|
||
frame,
|
||
variantName,
|
||
source: name.replaceAll("\\", "/"),
|
||
ddsSha256: dds.sha256,
|
||
rgbaSha256: sha256(rgba),
|
||
pngSha256: sha256(png),
|
||
width,
|
||
height,
|
||
mips: dds.mips,
|
||
ddsArchivePath,
|
||
pngCarrierPath,
|
||
ddsArchive: path.relative(outputRoot, ddsArchivePath).replaceAll("\\", "/"),
|
||
pngCarrierArchive: path.relative(outputRoot, pngCarrierPath).replaceAll("\\", "/"),
|
||
auditCacheAliases,
|
||
png,
|
||
});
|
||
} catch (error) {
|
||
frames.push({ frame, source: name.replaceAll("\\", "/"), unavailable: error.message });
|
||
}
|
||
}
|
||
const octaveValues = (kind, fallback) => Array.from({ length: 4 }, (_, index) => (
|
||
Number(getProperty(descriptor, `waterDesc:full:bump ${kind} ${index}`) ?? fallback[index])
|
||
));
|
||
return {
|
||
report: {
|
||
status: frames.length === frameCount && frames.every((frame) => frame.sha256) ? "all-source-frames-hashed-and-decoded" : "sequence-has-unavailable-frames",
|
||
samplerRole: "native full-water bump sequence; source shader samples DDS RGB as a procedural normal payload; alpha is unused",
|
||
sourceTextureStem: textureName,
|
||
frameCount,
|
||
frameRate,
|
||
bumpFactor: Number(getProperty(descriptor, "waterDesc:common:bump factor") ?? 0.85),
|
||
octaveScales: octaveValues("scale", [0.005, 0.01, 0.02, 0.04]),
|
||
octaveWeights: octaveValues("weight", [0.5, 0.25, 0.15, 0.1]),
|
||
octaveScrolls: Array.from({ length: 4 }, (_, index) => [
|
||
Number(getProperty(descriptor, `waterDesc:full:bump x scroll ${index}`) ?? 0),
|
||
Number(getProperty(descriptor, `waterDesc:full:bump y scroll ${index}`) ?? 0),
|
||
]),
|
||
carrierPixelPolicy: "DXT1 top mip decoded with the shared source decoder; PNG decode byte-compared against RGBA output; no sRGB transform or resampling",
|
||
frames,
|
||
},
|
||
carriers,
|
||
};
|
||
}
|
||
|
||
function createWaterMaterial(document, variants, descriptor, normalSequence, materialCache, carrierCache, variantCache, sourceFromAsset, sourceWdbSha256) {
|
||
const key = descriptor.guid;
|
||
let material = materialCache.get(key);
|
||
if (!material) {
|
||
material = document.createMaterial(`Native water ${key}`)
|
||
.setBaseColorFactor([1, 1, 1, 1])
|
||
.setMetallicFactor(0)
|
||
.setRoughnessFactor(0.38)
|
||
.setAlphaMode("BLEND")
|
||
.setDoubleSided(true)
|
||
.setExtras({ runewakerWater: runtimeWaterMetadata(descriptor, normalSequence, sourceFromAsset, sourceWdbSha256) });
|
||
materialCache.set(key, material);
|
||
}
|
||
const mapping = variants.createMappingList();
|
||
for (const frame of normalSequence.carriers) {
|
||
let carrier = carrierCache.get(frame.variantName);
|
||
if (!carrier) {
|
||
const texture = document.createTexture(frame.variantName)
|
||
.setImage(frame.png)
|
||
.setMimeType("image/png")
|
||
.setExtras({
|
||
runewakerWaterNormalFrame: {
|
||
variantName: frame.variantName,
|
||
sourceDDS: frame.source,
|
||
sourceDDSSha256: frame.ddsSha256,
|
||
decodedRgbaSha256: frame.rgbaSha256,
|
||
carrierPngSha256: frame.pngSha256,
|
||
sourceDdsArchive: frame.ddsArchive,
|
||
pngCarrierArchive: frame.pngCarrierArchive,
|
||
auditCacheAliases: frame.auditCacheAliases,
|
||
sourceMipChain: frame.mips,
|
||
width: frame.width,
|
||
height: frame.height,
|
||
colorSpace: "no conversion; source shader consumes RGB directly",
|
||
},
|
||
});
|
||
carrier = document.createMaterial(`Runewaker water normal frame ${frame.frame}`)
|
||
.setNormalTexture(texture)
|
||
.setMetallicFactor(0)
|
||
.setRoughnessFactor(1)
|
||
.setAlphaMode("OPAQUE")
|
||
.setExtras({ runewakerWaterNormalFrameCarrier: { variantName: frame.variantName, frame: frame.frame } });
|
||
carrier.getNormalTextureInfo().setWrapS(REPEAT).setWrapT(REPEAT);
|
||
carrierCache.set(frame.variantName, carrier);
|
||
}
|
||
let variant = variantCache.get(frame.variantName);
|
||
if (!variant) {
|
||
variant = variants.createVariant(frame.variantName);
|
||
variantCache.set(frame.variantName, variant);
|
||
}
|
||
mapping.addMapping(variants.createMapping().setMaterial(carrier).addVariant(variant));
|
||
}
|
||
return { material, mapping };
|
||
}
|
||
|
||
function addAccessor(document, buffer, name, type, array) {
|
||
return document.createAccessor(name, buffer).setType(type).setArray(array);
|
||
}
|
||
|
||
function runtimeWaterMetadata(descriptor, normalSequence, sourceFromAsset, sourceWdbSha256) {
|
||
const sequence = normalSequence.report;
|
||
return {
|
||
version: 1,
|
||
frameVariants: normalSequence.carriers.map((frame) => frame.variantName),
|
||
frameRate: sequence.frameRate,
|
||
bumpFactor: sequence.bumpFactor,
|
||
octaveScales: sequence.octaveScales,
|
||
octaveWeights: sequence.octaveWeights,
|
||
octaveScrolls: sequence.octaveScrolls,
|
||
sourceFromAsset,
|
||
nativeWaterBodyGuid: descriptor.guid,
|
||
nativeDescriptorSha256: descriptor.sha256,
|
||
sourceWdbSha256,
|
||
effectTypeRaw: descriptor.effectType,
|
||
...nativeWaterRenderPolicy(descriptor),
|
||
effectTypeCaveat: descriptor.effectType === 4
|
||
? "raw value 4 is not declared by the checked-in enum; preserve it raw and do not infer a named effect"
|
||
: null,
|
||
sourceFramePixelPolicy: sequence.carrierPixelPolicy,
|
||
nativeRenderState: {
|
||
alphaBlending: true,
|
||
alphaTest: false,
|
||
doubleSided: true,
|
||
vertexStreams: ["POSITION", "DIFFUSE"],
|
||
nativeNormalAttribute: false,
|
||
nativeTextureCoordinateAttribute: false,
|
||
gltfCompatibilityTexcoord0: "source-world XZ carrier for KHR_materials_variants normalTexture validation; runtime shader samples transformed source-world XZ and ignores TEXCOORD_0",
|
||
samplerWrap: "repeat",
|
||
},
|
||
approximation: "Three.js StandardMaterial lighting with the source animated procedural bump sequence. The builder does not implement projected reflection/refraction or source SH lighting/fog; effectType semantics remain source-defined and are not inferred here.",
|
||
};
|
||
}
|
||
|
||
function addLayersToDocument(document, scene, mesh, geometryRows, normalSequences, sourceFromAsset, sourceWdbSha256) {
|
||
const sharedBuffer = document.createBuffer("Origin source water grids");
|
||
const variants = document.createExtension(KHRMaterialsVariants).setRequired(false);
|
||
const materialCache = new Map();
|
||
const carrierCache = new Map();
|
||
const variantCache = new Map();
|
||
for (let index = 0; index < geometryRows.length; index += 1) {
|
||
const row = geometryRows[index];
|
||
const geometry = row.geometry;
|
||
const descriptor = row.layer.descriptorJoin.descriptors[0];
|
||
const normalSequence = normalSequences.get(descriptor.guid);
|
||
const selectedMaterial = createWaterMaterial(document, variants, descriptor, normalSequence, materialCache,
|
||
carrierCache, variantCache, sourceFromAsset, sourceWdbSha256);
|
||
const primitive = document.createPrimitive()
|
||
.setAttribute("POSITION", addAccessor(document, sharedBuffer, `${row.id} positions`, "VEC3", geometry.positions))
|
||
.setAttribute("NORMAL", addAccessor(document, sharedBuffer, `${row.id} fixed-up normals`, "VEC3", geometry.normals))
|
||
.setAttribute("TEXCOORD_0", addAccessor(document, sharedBuffer, `${row.id} source-world XZ compatibility coordinates`, "VEC2", geometry.sourceWorldXZ))
|
||
.setAttribute("COLOR_0", addAccessor(document, sharedBuffer, `${row.id} native vertex diffuse`, "VEC4", geometry.colors))
|
||
.setIndices(addAccessor(document, sharedBuffer, `${row.id} source water indices`, "SCALAR", geometry.indices))
|
||
.setMaterial(selectedMaterial.material)
|
||
.setExtension(KHRMaterialsVariants.EXTENSION_NAME, selectedMaterial.mapping)
|
||
.setExtras({
|
||
runewakerNativeWaterLayer: {
|
||
version: row.layer.version,
|
||
waterBodyGuid: row.layer.waterBodyGuid,
|
||
sourceChunk: row.chunk,
|
||
sourceLayerIndex: row.layerIndex,
|
||
sourceSplitPlaneHeight: geometry.summary.sourceSplitPlaneHeight,
|
||
sourcePayloadSha256: row.sourcePayloadSha256,
|
||
sourceLayerByteRange: row.layer.sourceByteRange,
|
||
sourceLayerBytes: row.layer.sourceByteRange.end - row.layer.sourceByteRange.start,
|
||
sourceEntityClassGuid: row.layer.entity?.classGuid ?? null,
|
||
sourceEntityTransform: row.layer.transform,
|
||
sourceHeightParcel: {
|
||
origin: row.layer.height.origin,
|
||
spacing: row.layer.height.spacing,
|
||
overlap: row.layer.height.overlap,
|
||
width: row.layer.height.width,
|
||
height: row.layer.height.height,
|
||
quantizationStep: row.layer.height.quantizationStep,
|
||
},
|
||
sourceColorParcel: {
|
||
origin: row.layer.color.origin,
|
||
spacing: row.layer.color.spacing,
|
||
overlap: row.layer.color.overlap,
|
||
width: row.layer.color.width,
|
||
height: row.layer.color.height,
|
||
},
|
||
sourceFlagsParcel: {
|
||
origin: row.layer.flags.origin,
|
||
spacing: row.layer.flags.spacing,
|
||
overlap: row.layer.flags.overlap,
|
||
width: row.layer.flags.width,
|
||
height: row.layer.flags.height,
|
||
},
|
||
topology: geometry.summary,
|
||
},
|
||
});
|
||
mesh.addPrimitive(primitive);
|
||
}
|
||
scene.addChild(document.createNode("Native water surfaces").setMesh(mesh));
|
||
}
|
||
|
||
async function writeWaterGlb(file, geometryRows, normalSequences, title, sourceFromAsset, sourceWdbSha256) {
|
||
const document = new Document();
|
||
const scene = document.createScene(title);
|
||
const mesh = document.createMesh(title);
|
||
addLayersToDocument(document, scene, mesh, geometryRows, normalSequences, sourceFromAsset, sourceWdbSha256);
|
||
document.getRoot().setExtras({
|
||
runewakerWaterReview: {
|
||
version: 1,
|
||
title,
|
||
geometryPolicy: "native height samples, source transform, native pair topology and visibility flags; vertex color RGB from source parcel and opacity from flags low six bits; TEXCOORD_0 carries source-world XZ for glTF normalTexture validation only",
|
||
coordinateBridge: "same sourceTerrainToThreeMatrix as native terrain: inverse primary WDB TRS then reflected-Z meter scaling; each water entity TRS is applied before bridge",
|
||
sourceNormalPolicy: "native water has no NORMAL stream; fixed world-up vector from source water shader is carried as NORMAL only for static viewport preview",
|
||
knownApproximation: "MeshStandardMaterial with native alpha and animated source bump sequence; projected screen reflection/refraction, fog, dynamic native SH lighting and raw effectType=4 semantics are not emulated",
|
||
waterBodyDescriptorGuids: [...new Set(geometryRows.map((row) => row.layer.waterBodyGuid))],
|
||
nativeWaterLayerCount: geometryRows.length,
|
||
},
|
||
});
|
||
document.getRoot().setDefaultScene(scene);
|
||
await new NodeIO().registerExtensions(ALL_EXTENSIONS).write(file, document);
|
||
const bytes = await readFile(file);
|
||
const jsonLength = bytes.readUInt32LE(12);
|
||
const gltf = JSON.parse(bytes.subarray(20, 20 + jsonLength).toString("utf8"));
|
||
const expectedVariantNames = new Set(geometryRows.flatMap((row) => {
|
||
const guid = row.layer.waterBodyGuid;
|
||
return normalSequences.get(guid).carriers.map((frame) => frame.variantName);
|
||
}));
|
||
const actualVariants = gltf.extensions?.KHR_materials_variants?.variants ?? [];
|
||
const actualVariantNames = new Set(actualVariants.map((variant) => variant.name));
|
||
const carrierMaterials = (gltf.materials ?? []).filter((material) => material.extras?.runewakerWaterNormalFrameCarrier);
|
||
const waterMaterials = (gltf.materials ?? []).filter((material) => material.extras?.runewakerWater);
|
||
const primitiveMappings = (gltf.meshes ?? []).flatMap((entry) => entry.primitives ?? [])
|
||
.map((primitive) => primitive.extensions?.KHR_materials_variants?.mappings ?? []);
|
||
const carrierMaterialByName = new Map(carrierMaterials.map((material) => [
|
||
material.extras.runewakerWaterNormalFrameCarrier.variantName,
|
||
material,
|
||
]));
|
||
const expectedNamesMappedOnEveryPrimitive = primitiveMappings.every((mappings) => {
|
||
const mappedNames = new Set(mappings.flatMap((mapping) => mapping.variants.map((index) => actualVariants[index]?.name)));
|
||
return mappedNames.size === expectedVariantNames.size && [...expectedVariantNames].every((name) => mappedNames.has(name));
|
||
});
|
||
const carriersMatchSourceFrames = [...expectedVariantNames].every((name) => {
|
||
const material = carrierMaterialByName.get(name);
|
||
if (!material?.normalTexture) return false;
|
||
const texture = gltf.textures?.[material.normalTexture.index];
|
||
const image = texture?.source === undefined ? null : gltf.images?.[texture.source];
|
||
const sampler = texture?.sampler === undefined ? null : gltf.samplers?.[texture.sampler];
|
||
const imageExtras = image?.extras?.runewakerWaterNormalFrame;
|
||
return image && imageExtras?.variantName === name
|
||
&& imageExtras.sourceDDSSha256 && imageExtras.decodedRgbaSha256 && imageExtras.carrierPngSha256
|
||
&& (!sampler || (sampler.wrapS === REPEAT && sampler.wrapT === REPEAT));
|
||
});
|
||
if (actualVariants.length !== expectedVariantNames.size || actualVariantNames.size !== expectedVariantNames.size
|
||
|| [...expectedVariantNames].some((name) => !actualVariantNames.has(name))
|
||
|| carrierMaterials.length !== expectedVariantNames.size
|
||
|| waterMaterials.length !== new Set(geometryRows.map((row) => row.layer.waterBodyGuid)).size
|
||
|| primitiveMappings.length !== new Set(geometryRows.map((row) => row.id)).size
|
||
|| primitiveMappings.some((mappings) => mappings.length !== expectedVariantNames.size)
|
||
|| !expectedNamesMappedOnEveryPrimitive || !carriersMatchSourceFrames) {
|
||
throw new Error(`Staged water GLB carrier validation failed for ${file}.`);
|
||
}
|
||
return {
|
||
path: path.relative(projectRoot, file).replaceAll("\\", "/"),
|
||
bytes: bytes.length,
|
||
sha256: sha256(bytes),
|
||
carrierValidation: {
|
||
waterMaterials: waterMaterials.length,
|
||
frameVariants: actualVariants.length,
|
||
carrierMaterialsWithRepeatSampler: carrierMaterials.length,
|
||
everyPrimitiveMapsEveryFrame: expectedNamesMappedOnEveryPrimitive,
|
||
sourceFrameHashesPresent: carriersMatchSourceFrames,
|
||
},
|
||
};
|
||
}
|
||
|
||
async function main() {
|
||
const options = parseNativeWaterBuildArgs(process.argv.slice(2));
|
||
if (options.help) {
|
||
console.log(USAGE);
|
||
return;
|
||
}
|
||
|
||
const recipeFile = resolveFromProject(options.recipe ?? "scripts/runewaker-pipeline/recipes/the-origin.json");
|
||
const recipeBytes = await readFile(recipeFile);
|
||
const recipe = JSON.parse(recipeBytes.toString("utf8"));
|
||
const recipeSlug = recipe.slug;
|
||
if (typeof recipeSlug !== "string" || !/^[a-z0-9-]+$/.test(recipeSlug)) {
|
||
throw new Error("Recipe slug must be a lowercase dungeon id.");
|
||
}
|
||
if (recipe.source?.nativeTerrain?.kind !== "runewaker-wdb-terrain") {
|
||
throw new Error("Recipe source.nativeTerrain.kind must be 'runewaker-wdb-terrain'.");
|
||
}
|
||
const configuredSourceRoot = process.env[recipe.source.rootEnvironmentVariable];
|
||
const sourceRoot = path.resolve(options["source-root"]
|
||
?? configuredSourceRoot
|
||
?? path.resolve(projectRoot, recipe.source.relativeDefault ?? "../rom-pvt-server-backups/Runewaker"));
|
||
const resourceRoot = path.resolve(sourceRoot, recipe.source.resourceRoot ?? "Resource");
|
||
const wdbPath = path.resolve(sourceRoot, recipe.source.wdb);
|
||
const wdbReportPath = resolveFromProject(options["wdb-report"]
|
||
?? `src/assets/game/dungeons/${recipeSlug}/wdb-report.json`);
|
||
const outputRoot = resolveFromProject(options["output-root"]
|
||
?? path.join("runewaker-export-work", recipeSlug, "native-water-source"));
|
||
ensureWithinProject(projectRoot, outputRoot);
|
||
const auditCacheRoot = options["audit-cache-root"]
|
||
? ensureWithinProject(projectRoot, resolveFromProject(options["audit-cache-root"]))
|
||
: null;
|
||
const groundManifestPath = options["ground-manifest"] ? resolveFromProject(options["ground-manifest"]) : null;
|
||
|
||
const [wdb, wdbReportBytes, groundManifestBytes] = await Promise.all([
|
||
readFile(wdbPath),
|
||
readFile(wdbReportPath),
|
||
groundManifestPath ? readFile(groundManifestPath) : Promise.resolve(null),
|
||
]);
|
||
const wdbReport = JSON.parse(wdbReportBytes.toString("utf8"));
|
||
const groundManifest = groundManifestBytes ? JSON.parse(groundManifestBytes.toString("utf8")) : null;
|
||
const sourceWdbSha256 = sha256(wdb);
|
||
const datastore = readDataStore(wdb);
|
||
const chunkTable = datastore.tables.get("chunks");
|
||
const descriptorTable = datastore.tables.get("water_descriptor");
|
||
if (!chunkTable || !descriptorTable) throw new Error("Source WDB is missing chunks or water_descriptor table.");
|
||
const descriptorInputs = [...descriptorTable.records.entries()]
|
||
.map(([key, record]) => ({ key, payload: Buffer.from(recordBytes(wdb, record)) }));
|
||
const primaryPlacement = selectPrimaryDescriptor(wdbReport, recipe);
|
||
const metersPerSourceUnit = Number(recipe.coordinateSystem?.metersPerSourceUnit);
|
||
if (!Number.isFinite(metersPerSourceUnit) || metersPerSourceUnit <= 0) {
|
||
throw new Error("Recipe coordinateSystem.metersPerSourceUnit must be a positive number.");
|
||
}
|
||
|
||
const allChunkRows = [...chunkTable.records.entries()]
|
||
.map(([key, record]) => ({ key: Number(key) >>> 0, record }))
|
||
.sort((a, b) => a.key - b.key);
|
||
const chunkSelection = selectNativeWaterChunkKeys({
|
||
wdbChunkKeys: allChunkRows.map((row) => row.key),
|
||
groundManifest,
|
||
sourceWdbSha256,
|
||
});
|
||
const selectedKeySet = new Set(chunkSelection.selectedKeys);
|
||
const chunkRows = allChunkRows.filter(({ key }) => selectedKeySet.has(key));
|
||
const waterChunkSummaries = [];
|
||
const decodedRows = [];
|
||
const versionCounts = new Map();
|
||
for (const { key, record } of chunkRows) {
|
||
const x = key >>> 16;
|
||
const z = key & 0xffff;
|
||
const compressed = recordBytes(wdb, record);
|
||
const chunk = parseTerrainChunk(compressed, `[${x},${z}]`, { allowUnparsedWaterPayload: true });
|
||
if (chunk.waterLayerCount === 0) continue;
|
||
const decompressed = inflateSync(compressed, { finishFlush: zlibConstants.Z_SYNC_FLUSH });
|
||
const prefixBytes = decompressed.length - chunk.unconsumedBytes;
|
||
if (prefixBytes < 0 || prefixBytes > decompressed.length) {
|
||
throw new Error(`Chunk [${x},${z}] reports an invalid water offset.`);
|
||
}
|
||
const waterPayload = Buffer.from(decompressed.subarray(prefixBytes));
|
||
const decoded = decodeNativeWaterPayload(waterPayload, {
|
||
layerCount: chunk.waterLayerCount,
|
||
descriptors: descriptorInputs,
|
||
});
|
||
if (decoded.consumedBytes !== decoded.sourcePayloadBytes || decoded.trailingBytes !== 0) {
|
||
throw new Error(`Chunk [${x},${z}] water payload was not consumed exactly.`);
|
||
}
|
||
const chunkSummary = {
|
||
coordinate: { x, z },
|
||
key: `[${x},${z}]`,
|
||
keyUint32: key,
|
||
compressedBytes: compressed.length,
|
||
compressedSha256: sha256(compressed),
|
||
decompressedBytes: decompressed.length,
|
||
waterPayloadOffset: prefixBytes,
|
||
waterPayloadBytes: waterPayload.length,
|
||
waterPayloadSha256: decoded.sourcePayloadSha256,
|
||
layerCount: decoded.layerCount,
|
||
layerGuids: decoded.layers.map((layer) => layer.waterBodyGuid),
|
||
descriptorJoinStatuses: decoded.layers.map((layer) => layer.descriptorJoin.status),
|
||
layerVersions: decoded.layers.map((layer) => layer.version),
|
||
exactConsumption: {
|
||
consumed: decoded.consumedBytes,
|
||
total: decoded.sourcePayloadBytes,
|
||
trailing: decoded.trailingBytes,
|
||
},
|
||
};
|
||
waterChunkSummaries.push(chunkSummary);
|
||
for (let layerIndex = 0; layerIndex < decoded.layers.length; layerIndex += 1) {
|
||
const layer = decoded.layers[layerIndex];
|
||
versionCounts.set(layer.version, (versionCounts.get(layer.version) ?? 0) + 1);
|
||
decodedRows.push({
|
||
id: `${recipeSlug}-water-${x}-${z}-${layerIndex}`,
|
||
chunk: { x, z },
|
||
layerIndex,
|
||
sourcePayloadSha256: decoded.sourcePayloadSha256,
|
||
layer,
|
||
});
|
||
}
|
||
}
|
||
|
||
if (waterChunkSummaries.length === 0) throw new Error("Selected source WDB chunks contain no native water layers.");
|
||
for (const row of decodedRows) {
|
||
if (row.layer.descriptorJoin.status !== "matched") {
|
||
throw new Error(`${row.layer.waterBodyGuid} in chunk [${row.chunk.x},${row.chunk.z}] has ${row.layer.descriptorJoin.status} descriptor join.`);
|
||
}
|
||
}
|
||
const geometry = buildNativeWaterGeometry(decodedRows.map((row) => row.layer), primaryPlacement, metersPerSourceUnit);
|
||
if (geometry.skipped.length > 0) {
|
||
throw new Error(`Native water build skipped ${geometry.skipped.length} layer(s): ${JSON.stringify(geometry.skipped.slice(0, 3))}`);
|
||
}
|
||
const geometryRows = decodedRows.map((row, index) => ({ ...row, geometry: geometry.layers[index] }));
|
||
const descriptorByGuid = new Map();
|
||
for (const row of decodedRows) descriptorByGuid.set(row.layer.waterBodyGuid, row.layer.descriptorJoin.descriptors[0]);
|
||
const normalSequences = new Map();
|
||
for (const [guid, descriptor] of descriptorByGuid) {
|
||
normalSequences.set(guid, await inspectNormalSequence(descriptor, resourceRoot, outputRoot, auditCacheRoot));
|
||
}
|
||
for (const [guid, sequence] of normalSequences) {
|
||
if (sequence.report.status !== "all-source-frames-hashed-and-decoded"
|
||
|| !Number.isFinite(sequence.report.frameRate) || sequence.report.frameRate <= 0
|
||
|| sequence.carriers.length !== sequence.report.frameCount) {
|
||
throw new Error(`Native water frame sequence for ${guid} is incomplete; refusing a partial animation carrier.`);
|
||
}
|
||
}
|
||
|
||
await mkdir(outputRoot, { recursive: true });
|
||
const sourceFromAsset = sourceTerrainToThreeMatrix(primaryPlacement, metersPerSourceUnit).invert().toArray();
|
||
const destinationWater = path.join(outputRoot, "native-water-visual.glb");
|
||
const waterGlb = await writeWaterGlb(destinationWater, geometryRows, normalSequences,
|
||
`${recipe.title ?? recipeSlug} native water · source normal-frame approximation`, sourceFromAsset, sourceWdbSha256);
|
||
const frames = [...normalSequences.values()].flatMap((sequence) => sequence.report.frames);
|
||
const auditCacheAliases = frames.flatMap((frame) => frame.auditCacheAliases ? [frame.auditCacheAliases] : []);
|
||
const effectTypes = [...new Set(decodedRows.map((row) => row.layer.descriptorJoin.descriptors[0].effectType))].sort((a, b) => a - b);
|
||
const sourceSelection = {
|
||
kind: chunkSelection.selection.kind,
|
||
chunkCount: chunkRows.length,
|
||
coordinates: chunkRows.map(({ key }) => ({ x: key >>> 16, z: key & 0xffff })),
|
||
...(groundManifestPath ? {
|
||
groundManifest: path.relative(projectRoot, groundManifestPath).replaceAll("\\", "/"),
|
||
groundManifestSha256: sha256(groundManifestBytes),
|
||
groundManifestSourceWdbSha256: groundManifest.source?.wdbSha256 ?? groundManifest.sourceWdbSha256,
|
||
} : {}),
|
||
};
|
||
const report = {
|
||
schemaVersion: 2,
|
||
tool: "scripts/runewaker-pipeline/build-native-water.mjs",
|
||
deterministic: true,
|
||
source: {
|
||
recipe: path.relative(projectRoot, recipeFile).replaceAll("\\", "/"),
|
||
recipeSha256: sha256(recipeBytes),
|
||
sourceRoot: path.relative(projectRoot, sourceRoot).replaceAll("\\", "/"),
|
||
resourceRoot: path.relative(sourceRoot, resourceRoot).replaceAll("\\", "/"),
|
||
wdb: path.relative(sourceRoot, wdbPath).replaceAll("\\", "/"),
|
||
wdbSha256: sourceWdbSha256,
|
||
wdbReport: path.relative(projectRoot, wdbReportPath).replaceAll("\\", "/"),
|
||
wdbReportSha256: sha256(wdbReportBytes),
|
||
wdbReportContainsSourceWdbHash: Boolean(wdbReport.sourceWdbSha256 ?? wdbReport.source?.wdbSha256),
|
||
chunkTableCount: chunkTable.count,
|
||
waterDescriptorTableCount: descriptorTable.count,
|
||
selectedPrimaryPlacement: {
|
||
guid: primaryPlacement.guid,
|
||
resource: primaryPlacement.resource,
|
||
scale: primaryPlacement.scale,
|
||
rotation: primaryPlacement.rotation,
|
||
translation: primaryPlacement.translation,
|
||
selectionTranslationFromRecipe: recipe.source.primaryPlacementTranslation,
|
||
},
|
||
metersPerSourceUnit,
|
||
sourceFromAsset,
|
||
},
|
||
chunkSelection: sourceSelection,
|
||
payloadDecode: {
|
||
chunksInWdb: allChunkRows.length,
|
||
chunksSelected: chunkRows.length,
|
||
chunksWithWater: waterChunkSummaries.length,
|
||
serializedLayerCount: decodedRows.length,
|
||
layerVersionCounts: Object.fromEntries([...versionCounts].sort(([a], [b]) => a - b)),
|
||
chunkWaterPayloadsExactlyConsumed: waterChunkSummaries.every((row) => (
|
||
row.exactConsumption.consumed === row.exactConsumption.total && row.exactConsumption.trailing === 0
|
||
)),
|
||
allBodyGuidsUniquelyJoined: decodedRows.every((row) => row.layer.descriptorJoin.status === "matched"),
|
||
nativeWaterChunks: waterChunkSummaries,
|
||
},
|
||
geometry: geometry.report,
|
||
normalSequences: [...normalSequences.values()].map(({ report: sequenceReport }) => sequenceReport),
|
||
outputs: {
|
||
waterGlb,
|
||
normalFrameArchiveRoot: path.relative(projectRoot, path.join(outputRoot, "normal-frames")).replaceAll("\\", "/"),
|
||
rawSourceDdsFrameCount: frames.length,
|
||
archivedPngCarrierCount: frames.filter((frame) => frame.pngCarrierArchive).length,
|
||
...(auditCacheRoot ? {
|
||
auditCacheRoot: path.relative(projectRoot, auditCacheRoot).replaceAll("\\", "/"),
|
||
auditCacheAliasCount: auditCacheAliases.length,
|
||
auditCacheAliases,
|
||
} : {}),
|
||
nativeMipCountByFrame: frames.map((frame) => ({
|
||
source: frame.source,
|
||
width: frame.width,
|
||
height: frame.height,
|
||
mipCount: frame.mipCount,
|
||
exactMipChainConsumption: frame.exactMipChainConsumption,
|
||
})),
|
||
},
|
||
sourceEngineReferences: [
|
||
"Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Scene/Terrain/RuEntityTerrain.cpp: water-layer serialization and descriptor GUID lookup",
|
||
"Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Scene/Terrain/RuWorld_Water.cpp: water versions 1–3 and legacy parcel-origin migration",
|
||
"Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Mojo/RuMojo_Base.h: parcel byte layout and quantized height deserialization",
|
||
"Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Mojo/RuMojo_Helper.cpp: native vertex diffuse generation, 0x80 two-cell-pair mask, even/odd topology",
|
||
"Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Direct3D/Shader/SM20EZ/RuShader_SM20EZ_Water.cpp: fixed world-up SH normal, diffuse modulation, world-XZ animated bump UVs and projected screen reflection/refraction",
|
||
"Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Scene/Terrain/RuWorld_WaterBodyDescriptor.cpp: serialized descriptor effect and material branch behavior",
|
||
"Runewaker/SourceCodeVS2019/Libraries/Rune Engine/Rune/Renderer/Plugin/RuRendererPlugin_AquaFX.cpp: split-plane camera reflection and pre-water scene refraction",
|
||
],
|
||
nativeMaterialSemantics: {
|
||
renderState: "water material is two-sided, alpha blending enabled, alpha test disabled",
|
||
diffuseAndLighting: "POSITION|DIFFUSE vertex stream only; source RGB modulates native ambient/spherical-harmonic lighting and global diffuse; shader uses constant world-up normal",
|
||
opacity: "native vertex alpha is trunc((waterFlags & 0x3f) / 63 * 255) / 255; bit 0x80 separately controls visibility of each two-cell quad pair",
|
||
bump: "sample one animated normal-frame DDS four times at nativeWorldXZ * octaveScale + octaveScroll * elapsedSeconds; combine as sum(sample.rgb * octaveWeight) - 0.5, normalize, swap Y/Z, then lerp toward [0,1,0] by bumpFactor; no mesh UV stream",
|
||
gltfCompatibilityCoordinates: "TEXCOORD_0 is derived from source-world XZ to satisfy KHR_materials_variants normalTexture validation; the runtime shader does not use mesh UVs",
|
||
bumpSamplingMetadata: {
|
||
actualOriginScales: [...normalSequences.values()][0]?.report.octaveScales,
|
||
actualOriginWeights: [...normalSequences.values()][0]?.report.octaveWeights,
|
||
nativeShaderWeightBytes: [...normalSequences.values()][0]?.report.octaveWeights.map((weight) => Math.trunc(weight * 255) & 0xff),
|
||
nativeShaderWeights: [...normalSequences.values()][0]?.report.octaveWeights.map((weight) => (Math.trunc(weight * 255) & 0xff) / 255),
|
||
actualOriginScrolls: [...normalSequences.values()][0]?.report.octaveScrolls,
|
||
sourceFrameCount: [...normalSequences.values()][0]?.report.frameCount,
|
||
sourceFrameRate: [...normalSequences.values()][0]?.report.frameRate,
|
||
sourceFrameDimensions: [...new Set(frames.map((frame) => `${frame.width}x${frame.height}`))],
|
||
sourceMipCounts: [...new Set(frames.map((frame) => frame.mipCount))],
|
||
carrierTextureColorSpace: "linear data/no color-space conversion",
|
||
carrierPixels: "source DXT1 top mip decoded to RGBA and PNG round-trip byte-equal; native shader samples RGB only",
|
||
},
|
||
effectType: {
|
||
rawValue: effectTypes,
|
||
caveat: "Raw descriptor values are preserved. Effect type 4 is not declared in the checked-in RuWaterEffectType enum; its intended named semantics remain unknown and no behavior is inferred from that integer.",
|
||
},
|
||
viewportMaterial: "Two-sided BLEND material with native COLOR_0 and animated procedural bump sequence. The runtime uses source-enabled projected reflection/refraction, native .025 UV perturbation and Fresnel .02037/.97963 coefficients with Three.js lighting. Native SH and reflect-class filtering are not reproduced.",
|
||
},
|
||
limitations: [
|
||
"The output contains native water geometry only; the source-ground GLB, terrain collision, and terrain accessors are not modified or embedded.",
|
||
"The implementation preserves each water layer's own serialized TRS and source topology, but does not stitch adjacent water grids across WDB chunk boundaries.",
|
||
"Raw effectType values remain in the payload and report. In particular, effectType 4 is not assigned a guessed named meaning.",
|
||
"Browser water adapts the source full-enable material branch, split planes, bump perturbation and Fresnel reflection/refraction blend. It uses up to four 512px plane pairs per asset and at most ten two-pass captures per second. Native SH and reflect-class filtering remain unsupported; additional planes and below-plane views keep the lit animated fallback.",
|
||
"All source DDS bytes and mip-chain hashes are archived. GLB/runtime frame carriers contain exact decoded top-mip RGBA in lossless PNG form; the runtime GPU generates its own mip levels rather than consuming the archived compressed mip chain.",
|
||
"The placement report is pinned by SHA-256, but currently does not contain an authoritative source-WDB hash. The recipe translation selects the primary descriptor; per-layer water body GUIDs are joined directly against descriptors decoded from the hashed WDB.",
|
||
],
|
||
};
|
||
const reportFile = path.join(outputRoot, "conversion-report.json");
|
||
await writeFile(reportFile, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||
console.log(JSON.stringify({
|
||
report: path.relative(projectRoot, reportFile).replaceAll("\\", "/"),
|
||
chunksSelected: report.payloadDecode.chunksSelected,
|
||
chunksWithWater: report.payloadDecode.chunksWithWater,
|
||
layers: report.payloadDecode.serializedLayerCount,
|
||
layerVersionCounts: report.payloadDecode.layerVersionCounts,
|
||
descriptorJoinsExact: report.payloadDecode.allBodyGuidsUniquelyJoined,
|
||
allLayersBuilt: report.geometry.builtLayerCount,
|
||
triangles: report.geometry.layers.reduce((sum, row) => sum + row.emittedTriangles, 0),
|
||
waterGlb,
|
||
sourceDdsFrames: frames.length,
|
||
reportSha256: sha256(Buffer.from(`${JSON.stringify(report, null, 2)}\n`)),
|
||
}, null, 2));
|
||
}
|
||
|
||
main().catch((error) => {
|
||
console.error(error?.stack ?? error);
|
||
process.exitCode = 1;
|
||
});
|