Files
2026-10-06 22:25:34 -04:00

703 lines
38 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
});