Update Healer Man server

This commit is contained in:
phenom committed 2026-10-06 22:25:34 -04:00
1 parent 6114a2295f
commit 69809f7228
593 files changed
+903643 -40774

No files matched your search

@@ -0,0 +1,472 @@
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import {
copyFile,
mkdir,
mkdtemp,
readFile,
rm,
stat,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
import { descriptorSourceMatrix, normalizeWdbResourcePath } from "../lib/environment-assembly.mjs";
import { loadEnvironmentRecipe, projectRoot } from "../lib/recipe.mjs";
import { convertNativeTerrainChunks, sourceTerrainToThreeMatrix } from "./terrain-geometry.mjs";
import { writeNativeTerrainGlbs } from "./native-terrain-glb.mjs";
let workDirectory;
const texconvDefault = path.resolve(process.env.TEXCONV_BIN ?? path.join(
process.env.LOCALAPPDATA ?? "",
"Microsoft",
"WinGet",
"Packages",
"Microsoft.DirectXTex.Texconv_Microsoft.Winget.Source_8wekyb3d8bbwe",
"texconv.exe",
));
const argv = process.argv.slice(2);
function option(name, fallback) {
const equals = argv.find((value) => value.startsWith(name + "="));
if (equals) return equals.slice(name.length + 1);
const index = argv.indexOf(name);
if (index >= 0 && argv[index + 1] && !argv[index + 1].startsWith("--")) return argv[index + 1];
return fallback;
}
function projectOption(name, fallback) {
const value = option(name, fallback);
return path.resolve(projectRoot, value);
}
function hashBytes(data) {
return createHash("sha256").update(data).digest("hex");
}
async function hashFile(file) {
return hashBytes(await readFile(file));
}
function ensureInside(parent, candidate, label) {
const relative = path.relative(path.resolve(parent), path.resolve(candidate));
if (!relative || (relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative))) {
return path.resolve(candidate);
}
throw new Error(`${label} resolves outside the allowed directory: ${candidate}`);
}
function sourceRelativeFile(sourceDirectory, relative, label) {
if (typeof relative !== "string" || !relative.trim()) throw new Error(`${label} is required.`);
return ensureInside(sourceDirectory, path.resolve(sourceDirectory, relative.replaceAll("/", path.sep)), label);
}
function typedArrayFromBuffer(buffer, Type, bytesPerElement, label) {
if (buffer.byteLength % bytesPerElement !== 0) {
throw new Error(`${label} has a byte length that is not aligned to ${bytesPerElement}.`);
}
const copy = new Uint8Array(buffer.byteLength);
copy.set(buffer);
return new Type(copy.buffer);
}
async function readJson(file, label) {
try {
return JSON.parse(await readFile(file, "utf8"));
} catch (error) {
throw new Error(`Could not read ${label} ${file}: ${error.message}`);
}
}
async function loadTerrainChunks(manifest, sourceDirectory) {
if (!Array.isArray(manifest.chunks) || manifest.chunks.length === 0) {
throw new Error("Native terrain manifest must contain chunks.");
}
const chunks = [];
for (const row of manifest.chunks) {
const files = row.geometryFiles;
if (!files) throw new Error(`${row.id} has no geometryFiles record.`);
const positionsFile = sourceRelativeFile(sourceDirectory, files.heightfieldVerts ?? files.positions, `${row.id} positions`);
const uvsFile = sourceRelativeFile(sourceDirectory, files.normalizedXZUvs ?? files.uvs, `${row.id} normalized XZ UVs`);
const indicesFile = sourceRelativeFile(sourceDirectory, files.exactIndices ?? files.indices, `${row.id} exact indices`);
const normalsFile = sourceRelativeFile(sourceDirectory, files.nativeNormals ?? files.normals, `${row.id} native normals`);
const triangleFlagsFile = sourceRelativeFile(sourceDirectory, files.triangleVisibilityFlags, `${row.id} triangle flags`);
const vertexFlagsFile = files.vertexFlags
? sourceRelativeFile(sourceDirectory, files.vertexFlags, `${row.id} vertex flags`)
: null;
const cellFlagsFile = files.cellVisibilityFlags
? sourceRelativeFile(sourceDirectory, files.cellVisibilityFlags, `${row.id} cell flags`)
: null;
const [positionBytes, uvBytes, indexBytes, normalBytes, triangleFlagsBytes] = await Promise.all([
readFile(positionsFile), readFile(uvsFile), readFile(indicesFile), readFile(normalsFile), readFile(triangleFlagsFile),
]);
const vertexFlagsBytes = vertexFlagsFile ? await readFile(vertexFlagsFile) : null;
const cellFlagsBytes = cellFlagsFile ? await readFile(cellFlagsFile) : null;
const triangleVisibilityFlags = new Uint8Array(triangleFlagsBytes);
chunks.push({
id: row.id,
coordinate: row.coordinate,
dimension: Number(row.dimension),
gridSize: Number(row.gridSize),
serializedOrigin: row.serializedOrigin,
positions: typedArrayFromBuffer(positionBytes, Float32Array, 4, `${row.id} positions`),
uvs: typedArrayFromBuffer(uvBytes, Float32Array, 4, `${row.id} UVs`),
indices: typedArrayFromBuffer(indexBytes, Uint16Array, 2, `${row.id} indices`),
normals: typedArrayFromBuffer(normalBytes, Float32Array, 4, `${row.id} native normals`),
triangleVisibilityFlags,
triangleVisibilitySha256: hashBytes(triangleFlagsBytes),
vertexFlagsSha256: vertexFlagsBytes ? hashBytes(vertexFlagsBytes) : null,
cellVisibilityFlagsSha256: cellFlagsBytes ? hashBytes(cellFlagsBytes) : null,
visibleSourceTriangles: Number(row.visibleSourceTriangleCount),
maskedSourceTriangles: Number(row.maskedSourceTriangleCount),
layers: row.layers,
splatMaps: row.splatMaps,
material: row.material,
});
}
return chunks;
}
function selectPrimaryDescriptor(wdbReport, recipe) {
const wanted = normalizeWdbResourcePath(recipe.source.model);
const matches = (wdbReport.descriptors ?? []).filter((row) => normalizeWdbResourcePath(row.resource) === wanted);
if (matches.length === 0) throw new Error(`WDB report has no descriptor for ${recipe.source.model}.`);
if (matches.length === 1) return matches[0];
const expected = recipe.source.primaryPlacementTranslation;
if (!Array.isArray(expected) || expected.length !== 3) {
throw new Error("Multiple primary-resource placements require recipe.source.primaryPlacementTranslation.");
}
const match = matches.find((row) => row.translation?.every((value, index) => (
Math.abs(Number(value) - Number(expected[index])) <= 0.0001
)));
if (!match) throw new Error("Configured primary WDB descriptor was not found in the WDB report.");
return match;
}
function run(executable, argumentsList, label) {
return new Promise((resolve, reject) => {
console.log(`[runewaker-terrain] ${label}`);
const child = spawn(executable, argumentsList, {
cwd: projectRoot,
stdio: "inherit",
windowsHide: true,
});
child.once("error", reject);
child.once("exit", (code) => {
if (code === 0) resolve();
else reject(new Error(`${label} exited with code ${code}.`));
});
});
}
async function verifyExists(file, label) {
try {
await stat(file);
} catch {
throw new Error(`${label} does not exist: ${file}`);
}
}
async function copyIfIdenticalOrMissing(source, target) {
try {
const [sourceHash, targetHash] = await Promise.all([hashFile(source), hashFile(target)]);
if (sourceHash !== targetHash) {
throw new Error(`Refusing to overwrite different texture-cache content at ${target}.`);
}
return targetHash;
} catch (error) {
if (error.code !== "ENOENT") throw error;
await mkdir(path.dirname(target), { recursive: true });
await copyFile(source, target);
return hashFile(target);
}
}
function basenameForPng(sourceDDS) {
return path.basename(sourceDDS).replace(/\.dds$/i, ".png");
}
async function decodeTerrainImages(chunks, sourceDirectory, tempDirectory, texconv, rawTextureDirectory, preparedTextureDirectory) {
await mkdir(tempDirectory, { recursive: true });
await mkdir(rawTextureDirectory, { recursive: true });
await mkdir(preparedTextureDirectory, { recursive: true });
const uniqueTiles = new Map();
const uniqueWeights = new Map();
for (const chunk of chunks) {
for (const layer of chunk.layers ?? []) {
if (!uniqueTiles.has(layer.sourceDDS)) uniqueTiles.set(layer.sourceDDS, layer);
}
for (const weight of chunk.splatMaps ?? []) {
if (!uniqueWeights.has(weight.linearPngFile)) uniqueWeights.set(weight.linearPngFile, weight);
}
}
const textureBySource = new Map();
const textureReport = [];
let textureIndex = 0;
for (const [sourceDDS, layer] of uniqueTiles) {
const ddsFile = sourceRelativeFile(sourceDirectory, sourceDDS, `terrain tile ${sourceDDS}`);
await verifyExists(ddsFile, `Terrain source tile ${sourceDDS}`);
const rawTarget = path.join(rawTextureDirectory, path.basename(sourceDDS));
const ddsHash = await copyIfIdenticalOrMissing(ddsFile, rawTarget);
const destination = path.join(tempDirectory, `tile-${String(textureIndex).padStart(2, "0")}`);
await mkdir(destination, { recursive: true });
await run(texconv, ["-ft", "PNG", "-o", destination, "-y", "--permissive", ddsFile], `decode source terrain tile ${sourceDDS}`);
const decodedPng = path.join(destination, basenameForPng(sourceDDS));
await verifyExists(decodedPng, `Converted terrain tile ${sourceDDS}`);
const opaquePng = await sharp(decodedPng)
.removeAlpha()
.ensureAlpha(1)
.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
.toBuffer();
const image = await sharp(opaquePng).raw().toBuffer({ resolveWithObject: true });
if (image.info.channels !== 4) throw new Error(`${sourceDDS} did not convert to RGBA PNG.`);
for (let alphaOffset = 3; alphaOffset < image.data.length; alphaOffset += 4) {
if (image.data[alphaOffset] !== 255) throw new Error(`${sourceDDS} terrain alpha was not forced opaque.`);
}
if (Number(layer.width) > 0 && image.info.width !== Number(layer.width)) {
throw new Error(`${sourceDDS} converted width ${image.info.width} does not match source ${layer.width}.`);
}
if (Number(layer.height) > 0 && image.info.height !== Number(layer.height)) {
throw new Error(`${sourceDDS} converted height ${image.info.height} does not match source ${layer.height}.`);
}
const imageName = basenameForPng(sourceDDS);
const preparedTarget = path.join(preparedTextureDirectory, imageName);
const opaqueTemp = path.join(destination, "opaque-" + imageName);
await writeFile(opaqueTemp, opaquePng);
const pngHash = await copyIfIdenticalOrMissing(opaqueTemp, preparedTarget);
textureBySource.set(sourceDDS, {
kind: "tile",
image: opaquePng,
imageName,
sourceDDS,
ddsSha256: ddsHash,
pngSha256: pngHash,
width: image.info.width,
height: image.info.height,
alphaPolicy: "source tile alpha ignored; export PNG alpha forced to 255; material OPAQUE",
});
textureReport.push({
kind: "tile",
sourceDDS,
sourceClientPath: layer.sourceClientPath ?? null,
imageName,
width: image.info.width,
height: image.info.height,
sourceDdsSha256: ddsHash,
preparedPngSha256: pngHash,
alphaPolicy: "source tile alpha ignored; export PNG alpha forced to 255",
});
textureIndex += 1;
}
for (const [linearPngFile, weight] of uniqueWeights) {
const sourceDDS = sourceRelativeFile(sourceDirectory, weight.splatDDS, `splat DDS ${weight.splatDDS}`);
const sourcePng = sourceRelativeFile(sourceDirectory, linearPngFile, `linear splat PNG ${linearPngFile}`);
await verifyExists(sourceDDS, `Weight map DDS ${weight.splatDDS}`);
await verifyExists(sourcePng, `Linear weight PNG ${linearPngFile}`);
const weightImageName = path.basename(linearPngFile);
const rawTarget = path.join(rawTextureDirectory, weightImageName.replace(/\.png$/i, ".dds"));
const ddsHash = await copyIfIdenticalOrMissing(sourceDDS, rawTarget);
const preparedTarget = path.join(preparedTextureDirectory, path.basename(linearPngFile));
const pngHash = await copyIfIdenticalOrMissing(sourcePng, preparedTarget);
const bytes = await readFile(sourcePng);
const image = await sharp(bytes).metadata();
if (Number(weight.width) > 0 && image.width !== Number(weight.width)) {
throw new Error(`${linearPngFile} width ${image.width} does not match source ${weight.width}.`);
}
if (Number(weight.height) > 0 && image.height !== Number(weight.height)) {
throw new Error(`${linearPngFile} height ${image.height} does not match source ${weight.height}.`);
}
textureBySource.set(linearPngFile, {
kind: "weight",
image: bytes,
imageName: path.basename(linearPngFile),
sourceDDS: weight.splatDDS,
sourceCompressedFile: weight.sourceCompressedFile,
ddsSha256: ddsHash,
pngSha256: pngHash,
width: image.width,
height: image.height,
channelLayerIds: weight.channelLayerIds,
colorSpace: "linear RGB; referenced only by metallicRoughnessTexture and occlusionTexture",
});
textureReport.push({
kind: "weight",
sourceDDS: weight.splatDDS,
sourceCompressedFile: weight.sourceCompressedFile,
preparedLinearPng: linearPngFile,
imageName: path.basename(linearPngFile),
width: image.width,
height: image.height,
channelLayerIds: weight.channelLayerIds,
sourceDdsSha256: ddsHash,
preparedPngSha256: pngHash,
colorSpace: "linear RGB; source DXT1 alpha ignored",
});
}
return { textureBySource, textureReport };
}
async function main() {
const recipeFallback = path.join(projectRoot, "scripts", "runewaker-pipeline", "recipes", "sand-vortex.json");
const { file: recipeFile, recipe } = await loadEnvironmentRecipe(argv, recipeFallback);
const slug = recipe.slug;
const title = recipe.title ?? slug;
if (recipe.source?.nativeTerrain?.kind !== "runewaker-wdb-terrain") {
throw new Error(`${title} recipe source.nativeTerrain.kind must be 'runewaker-wdb-terrain'.`);
}
workDirectory = path.join(projectRoot, "runewaker-export-work", slug);
const defaultManifest = path.join(workDirectory, "native-terrain-source", "terrain-native-source.json");
const defaultWdbReport = path.join(workDirectory, "raw", "wdb-report.json");
const defaultOutputDirectory = path.join(workDirectory, "converted", "terrain");
const manifestFile = projectOption("--manifest", defaultManifest);
const wdbReportFile = projectOption("--wdb-report", defaultWdbReport);
const outputDirectory = projectOption("--output-directory", defaultOutputDirectory);
const texconv = path.resolve(option("--texconv", texconvDefault));
const sourceDirectory = path.dirname(manifestFile);
const stageDirectory = await mkdtemp(path.join(workDirectory, ".native-terrain-stage-"));
const stageVisual = path.join(stageDirectory, `${slug}-terrain-visual.glb`);
const stageCollision = path.join(stageDirectory, `${slug}-terrain-collision.glb`);
const stageTextures = path.join(stageDirectory, "texconv");
const rawTextureDirectory = path.join(workDirectory, "raw", "textures");
const preparedTextureDirectory = path.join(workDirectory, "prepared", "textures");
try {
await Promise.all([
verifyExists(manifestFile, "Native terrain manifest"),
verifyExists(wdbReportFile, `${title} WDB report`),
verifyExists(texconv, "DirectXTex texconv"),
]);
const [manifest, wdbReport] = await Promise.all([
readJson(manifestFile, "native terrain manifest"),
readJson(wdbReportFile, `${title} WDB report`),
]);
if (manifest.schemaVersion !== 1 || !manifest.source?.terrainHeader) {
throw new Error("Unsupported native terrain manifest schema or missing source terrain header.");
}
const descriptor = selectPrimaryDescriptor(wdbReport, recipe);
// Validate the primary descriptor before deriving its inverse transform.
descriptorSourceMatrix(descriptor);
const metersPerSourceUnit = Number(recipe.coordinateSystem.metersPerSourceUnit);
const sourceToThree = sourceTerrainToThreeMatrix(descriptor, metersPerSourceUnit);
const sourceChunks = await loadTerrainChunks(manifest, sourceDirectory);
const converted = convertNativeTerrainChunks(sourceChunks, sourceToThree);
const textures = await decodeTerrainImages(
sourceChunks,
sourceDirectory,
stageTextures,
texconv,
rawTextureDirectory,
preparedTextureDirectory,
);
const outputDirectoryResolved = ensureInside(projectRoot, outputDirectory, "Terrain output directory");
await mkdir(outputDirectoryResolved, { recursive: true });
const visualFile = path.join(outputDirectoryResolved, `${slug}-terrain-visual.glb`);
const collisionFile = path.join(outputDirectoryResolved, `${slug}-terrain-collision.glb`);
await writeNativeTerrainGlbs({
converted,
textureSources: textures,
provenance: {
dungeonId: slug,
dungeonTitle: title,
sourceManifestSha256: await hashFile(manifestFile),
sourceWdbSha256: manifest.source.wdbSha256,
primaryPlacementGuid: descriptor.guid,
metersPerSourceUnit,
partitionVisualChunks: recipe.source.nativeTerrain.partitionVisualChunks === true,
...(recipe.source.nativeTerrain?.cliffProjection?.kind === "source-scaled-side-projection" ? { cliffProjection: {
sourceFromAsset: sourceToThree.clone().invert().toArray(),
chunkSizeSourceUnits: manifest.source.terrainHeader.chunkDimension * manifest.source.terrainHeader.gridSize,
} } : {}),
textureSourceDDS: textures.textureReport.map((texture) => texture.sourceDDS),
},
visualFile: stageVisual,
collisionFile: stageCollision,
});
await mkdir(path.dirname(visualFile), { recursive: true });
await Promise.all([
copyFile(stageVisual, visualFile),
copyFile(stageCollision, collisionFile),
]);
const visualBytes = await readFile(visualFile);
const collisionBytes = await readFile(collisionFile);
const report = {
schemaVersion: 1,
dungeonId: recipe.slug,
generatedAt: new Date().toISOString(),
source: {
manifest: path.relative(projectRoot, manifestFile).split(path.sep).join("/"),
manifestSha256: await hashFile(manifestFile),
wdbReport: path.relative(projectRoot, wdbReportFile).split(path.sep).join("/"),
wdbSha256: manifest.source.wdbSha256,
terrainChunkCount: sourceChunks.length,
primaryPlacementGuid: descriptor.guid,
primaryPlacement: descriptor,
geometry: "native WDB heightfield; exact source grid topology; no resampling",
waterPayloadPolicy: manifest.source.waterPayloadPolicy,
normals: "source terrain shader vertexData RGB decoded, inverse-transpose transformed, and preserved per chunk",
sourceTriangleVisibility: "retained as per-primitive base64 Uint8Array in visual GLB extras and summarized below",
materialProjection: recipe.source.nativeTerrain?.cliffProjection ?? { kind: "native-xz" },
textures: textures.textureReport,
},
coordinateSystem: {
source: "Runewaker left-handed Y-up world coordinates",
transform: "inverse primary WDB descriptor TRS, then reflected-Z meter bridge",
metersPerSourceUnit,
},
geometry: converted.report,
visual: {
file: path.basename(visualFile),
fileRelativeToReport: "same directory",
sha256: hashBytes(visualBytes),
bytes: visualBytes.byteLength,
triangles: converted.report.sourceTriangleSlots,
meshObjects: recipe.source.nativeTerrain.partitionVisualChunks === true ? converted.chunks.length : 1,
partition: recipe.source.nativeTerrain.partitionVisualChunks === true ? "native-chunk-meshes" : "shared-grid-mesh",
vertices: converted.vertexCount,
primitives: converted.chunks.length,
sourceVisibleTriangles: converted.report.visibleSourceTriangles,
sourceMaskedTriangleSlotsRetained: converted.report.maskedSourceTriangles,
},
collision: {
file: path.basename(collisionFile),
fileRelativeToReport: "same directory",
sha256: hashBytes(collisionBytes),
bytes: collisionBytes.byteLength,
triangles: converted.report.collisionTriangles,
meshObjects: 1,
excludedSourceMaskedOrDegenerateTriangles: converted.report.excludedCollisionTriangles,
},
standardTextureCache: {
rawDirectory: path.relative(projectRoot, rawTextureDirectory).split(path.sep).join("/"),
preparedDirectory: path.relative(projectRoot, preparedTextureDirectory).split(path.sep).join("/"),
imageNamesMatchGlbTextures: true,
},
};
const reportFile = path.join(outputDirectoryResolved, "conversion-report.json");
await writeFile(reportFile, JSON.stringify(report, null, 2) + "\n", "utf8");
console.log(`[${slug}-terrain] visual: ${path.relative(projectRoot, visualFile).split(path.sep).join("/")} (${report.visual.triangles} source slots)`);
console.log(`[${slug}-terrain] collision: ${path.relative(projectRoot, collisionFile).split(path.sep).join("/")} (${report.collision.triangles} visible triangles)`);
console.log(`[${slug}-terrain] report: ${path.relative(projectRoot, reportFile).split(path.sep).join("/")}`);
} finally {
const absoluteStage = path.resolve(stageDirectory);
const absoluteWork = path.resolve(workDirectory);
const relativeStage = path.relative(absoluteWork, absoluteStage);
if (relativeStage && !relativeStage.startsWith("..") && !path.isAbsolute(relativeStage)) {
await rm(absoluteStage, { recursive: true, force: true });
}
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(`[sand-vortex-terrain] ${error.stack ?? error.message}`);
process.exitCode = 1;
});
}
@@ -0,0 +1,745 @@
import { createHash } from "node:crypto";
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { constants as zlibConstants, deflateSync, inflateSync } from "node:zlib";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDirectory, "../../..");
const argv = process.argv.slice(2);
function option(name, fallback) {
const index = argv.indexOf(name);
return index >= 0 ? argv[index + 1] : fallback;
}
const sourceRoot = path.resolve(
process.env.RUNEWAKER_ROOT
?? path.join(projectRoot, "../rom-pvt-server-backups/Runewaker"),
);
const wdbOverride = option("--wdb", null);
const recipeFile = path.resolve(option(
"--recipe",
path.join(projectRoot, "scripts/runewaker-pipeline/recipes/sand-vortex.json"),
));
const outputOverride = option("--output", null);
function int32(buffer, offset) {
return buffer.readInt32LE(offset);
}
function uint32(buffer, offset) {
return buffer.readUInt32LE(offset);
}
function float32(buffer, offset) {
return buffer.readFloatLE(offset);
}
function safeSlice(buffer, start, length, label) {
if (start < 0 || length < 0 || start + length > buffer.length) {
throw new Error(`${label} is out of bounds (${start}+${length}/${buffer.length}).`);
}
return buffer.subarray(start, start + length);
}
export function readDataStore(buffer) {
if (buffer.length < 28) throw new Error("WDB is too short to contain a Rune datastore header.");
const tableListPosition = int32(buffer, 16);
const tableCount = int32(buffer, 20);
const tableAllocation = int32(buffer, 24);
if (tableCount < 0 || tableCount > 4096 || tableAllocation < tableCount) {
throw new Error(`Invalid datastore table counts (${tableCount}/${tableAllocation}).`);
}
const tables = new Map();
for (let index = 0; index < tableCount; index += 1) {
const offset = tableListPosition + index * 272;
safeSlice(buffer, offset, 272, "table header");
const rawName = buffer.subarray(offset, offset + 256);
const name = rawName.toString("utf8").split("\0", 1)[0];
const keyLength = int32(buffer, offset + 256);
const tablePosition = int32(buffer, offset + 260);
const recordCount = int32(buffer, offset + 264);
const allocationSize = int32(buffer, offset + 268);
if (!name || keyLength < 0 || keyLength > 32 || recordCount < 0 || recordCount > 2_000_000) {
throw new Error(`Invalid table header at index ${index}.`);
}
const records = new Map();
let cursor = tablePosition;
for (let recordIndex = 0; recordIndex < recordCount; recordIndex += 1) {
const key = safeSlice(buffer, cursor, keyLength, `${name} record key`);
cursor += keyLength;
const streamPosition = int32(safeSlice(buffer, cursor, 12, `${name} stream header`), 0);
const streamSize = int32(buffer, cursor + 4);
const streamAllocationSize = int32(buffer, cursor + 8);
cursor += 12;
safeSlice(buffer, streamPosition, streamSize, `${name} record payload`);
const keyString = keyLength === 4 ? String(uint32(key, 0)) : key.toString("hex");
records.set(keyString, {
key: Buffer.from(key),
position: streamPosition,
size: streamSize,
allocationSize: streamAllocationSize,
});
}
tables.set(name, {
name,
keyLength,
position: tablePosition,
count: recordCount,
allocationSize,
records,
});
}
return { tableListPosition, tableCount, tableAllocation, tables };
}
function recordPayload(wdb, table, key) {
const record = table.records.get(String(key));
if (!record) return null;
return wdb.subarray(record.position, record.position + record.size);
}
function decompressRuneStream(compressed, label) {
try {
return inflateSync(compressed, { finishFlush: zlibConstants.Z_SYNC_FLUSH });
} catch (error) {
throw new Error(`Could not decompress ${label}: ${error.message}`);
}
}
function parseTextureListEntry(payload, layerId) {
if (!payload || payload.length < 28) return null;
const version = int32(payload, 16);
const stringLength = int32(payload, 20);
if (version < 1 || stringLength < 1 || 24 + stringLength + 4 > payload.length) return null;
const resource = payload.subarray(24, 24 + stringLength).toString("utf8").split("\0", 1)[0];
const tileScale = float32(payload, 24 + stringLength);
if (!resource || !Number.isFinite(tileScale) || tileScale <= 0) return null;
return { layerId, resource, tileScale, version };
}
class ByteReader {
constructor(buffer, label) {
this.buffer = buffer;
this.offset = 0;
this.label = label;
}
take(length, label) {
const result = safeSlice(this.buffer, this.offset, length, `${this.label} ${label}`);
this.offset += length;
return result;
}
i32(label) { return this.take(4, label).readInt32LE(0); }
u32(label) { return this.take(4, label).readUInt32LE(0); }
f32(label) { return this.take(4, label).readFloatLE(0); }
}
export function dxt1ToRgba(data, width, height) {
if (width % 4 !== 0 || height % 4 !== 0) {
throw new Error(`DXT1 dimensions must be multiples of four (${width}×${height}).`);
}
const output = Buffer.alloc(width * height * 4);
let blockOffset = 0;
for (let blockY = 0; blockY < height; blockY += 4) {
for (let blockX = 0; blockX < width; blockX += 4) {
const color0 = data.readUInt16LE(blockOffset);
const color1 = data.readUInt16LE(blockOffset + 2);
const packedIndices = data.readUInt32LE(blockOffset + 4);
blockOffset += 8;
const decode565 = (value) => [
Math.round(((value >>> 11) & 31) * 255 / 31),
Math.round(((value >>> 5) & 63) * 255 / 63),
Math.round((value & 31) * 255 / 31),
];
const c0 = decode565(color0);
const c1 = decode565(color1);
const colors = [
[...c0, 255],
[...c1, 255],
];
if (color0 > color1) {
colors.push(
[0, 1, 2].map((channel) => Math.round((2 * c0[channel] + c1[channel]) / 3)).concat(255),
[0, 1, 2].map((channel) => Math.round((c0[channel] + 2 * c1[channel]) / 3)).concat(255),
);
} else {
colors.push(
[0, 1, 2].map((channel) => Math.round((c0[channel] + c1[channel]) / 2)).concat(255),
[0, 0, 0, 0],
);
}
for (let pixel = 0; pixel < 16; pixel += 1) {
const x = blockX + (pixel & 3);
const y = blockY + (pixel >>> 2);
const color = colors[(packedIndices >>> (pixel * 2)) & 3];
const outputOffset = (y * width + x) * 4;
output[outputOffset] = color[0];
output[outputOffset + 1] = color[1];
output[outputOffset + 2] = color[2];
output[outputOffset + 3] = color[3];
}
}
}
return output;
}
const crcTable = (() => {
const table = new Uint32Array(256);
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = (value & 1) ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1);
}
table[index] = value >>> 0;
}
return table;
})();
function crc32(buffer) {
let crc = 0xffffffff;
for (const byte of buffer) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type, data) {
const typeBytes = Buffer.from(type, "ascii");
const result = Buffer.alloc(data.length + 12);
result.writeUInt32BE(data.length, 0);
typeBytes.copy(result, 4);
data.copy(result, 8);
result.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])), data.length + 8);
return result;
}
function rgbaPng(width, height, rgba) {
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8;
ihdr[9] = 6;
const scanlines = Buffer.alloc(height * (width * 4 + 1));
for (let row = 0; row < height; row += 1) {
const rowOffset = row * (width * 4 + 1);
scanlines[rowOffset] = 0;
rgba.copy(scanlines, rowOffset + 1, row * width * 4, (row + 1) * width * 4);
}
return Buffer.concat([
signature,
pngChunk("IHDR", ihdr),
pngChunk("IDAT", deflateSync(scanlines)),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
export function parseTerrainChunk(compressed, chunkKey = "fixture", { allowUnparsedWaterPayload = false } = {}) {
const decompressed = decompressRuneStream(compressed, `terrain chunk ${chunkKey}`);
const reader = new ByteReader(decompressed, `terrain chunk ${chunkKey}`);
reader.take(16, "class GUID");
const iteration = reader.i32("iteration");
const dimension = reader.i32("dimension");
const gridSize = reader.f32("grid spacing");
const serializedLodLevels = reader.i32("LOD level count");
const bounds = {
min: [reader.f32("bounds min x"), reader.f32("bounds min y"), reader.f32("bounds min z")],
max: [reader.f32("bounds max x"), reader.f32("bounds max y"), reader.f32("bounds max z")],
};
const serializedOrigin = [reader.f32("chunk origin x"), reader.f32("chunk origin z")];
const skirtRows = dimension + 1;
const heightCount = skirtRows * skirtRows + skirtRows * 4;
const heights = new Float32Array(heightCount);
for (let index = 0; index < heightCount; index += 1) heights[index] = reader.f32(`height[${index}]`);
const vertexData = new Uint32Array(heightCount);
for (let index = 0; index < heightCount; index += 1) vertexData[index] = reader.u32(`vertex data[${index}]`);
const splatResolution = reader.i32("splat resolution");
reader.take(16, "splat map class GUID");
const mapType = reader.i32("splat map type");
if (mapType !== 1) throw new Error(`Terrain chunk ${chunkKey} uses unsupported splat map type ${mapType}.`);
const splatDimension = reader.i32("splat map dimension");
const layerCount = reader.i32("splat layer count");
if (layerCount < 0 || layerCount > 6) throw new Error(`Terrain chunk ${chunkKey} has invalid layer count ${layerCount}.`);
const layerIds = Array.from({ length: layerCount }, (_, index) => reader.take(2, `layer id[${index}]`).readUInt16LE(0));
const mapCount = reader.i32("alpha map count");
if (mapCount < 0 || mapCount > 2) throw new Error(`Terrain chunk ${chunkKey} has invalid alpha map count ${mapCount}.`);
const splatMaps = [];
for (let mapIndex = 0; mapIndex < mapCount; mapIndex += 1) {
const mipCount = reader.i32(`alpha map ${mapIndex} mip count`);
let width = reader.u32(`alpha map ${mapIndex} width`);
let height = reader.u32(`alpha map ${mapIndex} height`);
if (mipCount < 1 || mipCount > 16) throw new Error(`Terrain chunk ${chunkKey} has invalid alpha mip count ${mipCount}.`);
const mipmaps = [];
for (let mip = 0; mip < mipCount; mip += 1) {
const payloadSize = Math.max(1, width >> 2) * Math.max(1, height >> 2) * 8;
mipmaps.push({ width, height, bytes: Buffer.from(reader.take(payloadSize, `alpha map ${mapIndex} mip ${mip}`)) });
width = Math.max(1, width >> 1);
height = Math.max(1, height >> 1);
}
splatMaps.push({ mapIndex, mipmaps });
}
const doodadClusterCount = reader.i32("doodad cluster count");
if (doodadClusterCount < 0 || doodadClusterCount > 4096) {
throw new Error(`Terrain chunk ${chunkKey} has invalid doodad cluster count ${doodadClusterCount}.`);
}
let doodadCount = 0;
for (let cluster = 0; cluster < doodadClusterCount; cluster += 1) {
reader.take(16, `doodad cluster ${cluster} GUID`);
const clusterDoodadCount = reader.i32(`doodad cluster ${cluster} count`);
if (clusterDoodadCount < 0 || clusterDoodadCount > 1_000_000) {
throw new Error(`Terrain chunk ${chunkKey} has invalid doodad count ${clusterDoodadCount}.`);
}
doodadCount += clusterDoodadCount;
reader.take(clusterDoodadCount * 40, `doodad cluster ${cluster} descriptors`);
}
const waterLayerCount = reader.i32("water layer count");
if (waterLayerCount < 0 || waterLayerCount > 1024) {
throw new Error(`Terrain chunk ${chunkKey} has invalid water layer count ${waterLayerCount}.`);
}
if (waterLayerCount > 0 && !allowUnparsedWaterPayload) {
throw new Error(`Terrain chunk ${chunkKey} has ${waterLayerCount} water layer(s); set source.nativeTerrain.allowUnparsedWaterPayload to export only its ground heightfield.`);
}
const vertexCount = (dimension + 1) * (dimension + 1);
const localPositions = new Float32Array(vertexCount * 3);
const nativeNormals = new Float32Array(vertexCount * 3);
const normalizedUvs = new Float32Array(vertexCount * 2);
const topVertexFlags = new Uint8Array(vertexCount);
const nativeVertexFlags = new Uint8Array(heightCount);
let minHeight = Number.POSITIVE_INFINITY;
let maxHeight = Number.NEGATIVE_INFINITY;
for (let z = 0; z <= dimension; z += 1) {
for (let x = 0; x <= dimension; x += 1) {
const vertexIndex = z * (dimension + 1) + x;
const positionOffset = vertexIndex * 3;
const uvOffset = vertexIndex * 2;
const height = heights[vertexIndex];
localPositions[positionOffset] = x * gridSize;
localPositions[positionOffset + 1] = height;
localPositions[positionOffset + 2] = z * gridSize;
normalizedUvs[uvOffset] = x / dimension;
normalizedUvs[uvOffset + 1] = z / dimension;
const diffuse = vertexData[vertexIndex];
nativeNormals[positionOffset] = ((((diffuse >>> 16) & 0xff) / 255) - 0.5) * 2;
nativeNormals[positionOffset + 1] = ((((diffuse >>> 8) & 0xff) / 255) - 0.5) * 2;
nativeNormals[positionOffset + 2] = (((diffuse & 0xff) / 255) - 0.5) * 2;
topVertexFlags[vertexIndex] = vertexData[vertexIndex] >>> 24;
minHeight = Math.min(minHeight, height);
maxHeight = Math.max(maxHeight, height);
}
}
for (let index = 0; index < heightCount; index += 1) nativeVertexFlags[index] = vertexData[index] >>> 24;
const indices = [];
const triangleVisibility = [];
const cellVisibility = new Uint8Array(dimension * dimension);
const evenFaces = [0, 3, 4, 0, 4, 1, 1, 4, 2, 2, 4, 5];
const oddFaces = [0, 3, 1, 1, 3, 4, 1, 5, 2, 1, 4, 5];
for (let z = 0; z < dimension; z += 1) {
for (let x = 0; x < dimension; x += 2) {
const rowOffset = z * (dimension + 1);
const indexSet = [rowOffset + x, rowOffset + x + 1, rowOffset + x + 2,
rowOffset + x + dimension + 1, rowOffset + x + dimension + 2, rowOffset + x + dimension + 3];
const faces = z % 2 === 0 ? evenFaces : oddFaces;
for (let face = 0; face < faces.length; face += 3) {
const triangle = [faces[face], faces[face + 1], faces[face + 2]].map((index) => indexSet[index]);
const visible = triangle.some((index) => topVertexFlags[index] !== 0);
triangleVisibility.push(visible ? 1 : 0);
indices.push(...(visible ? triangle : [0, 0, 0]));
if (visible) {
const centerX = triangle.reduce((sum, index) => sum + index % (dimension + 1), 0) / 3;
const centerZ = triangle.reduce((sum, index) => sum + Math.floor(index / (dimension + 1)), 0) / 3;
const cellX = Math.min(dimension - 1, Math.floor(centerX));
const cellZ = Math.min(dimension - 1, Math.floor(centerZ));
cellVisibility[cellZ * dimension + cellX] = 1;
}
}
}
}
return {
iteration,
dimension,
gridSize,
serializedLodLevels,
bounds,
serializedOrigin,
splatResolution,
splatDimension,
layerIds,
splatMaps,
doodadClusterCount,
doodadCount,
waterLayerCount,
heights,
vertexData,
localPositions,
nativeNormals,
normalizedUvs,
topVertexFlags,
nativeVertexFlags,
triangleVisibility: Uint8Array.from(triangleVisibility),
cellVisibility,
indices: Uint16Array.from(indices),
minHeight,
maxHeight,
unconsumedBytes: decompressed.length - reader.offset,
};
}
export function intersectingChunkIndices(header, sourceBounds) {
const chunkWorldSize = header.chunkDimension * header.gridSize;
const result = [];
for (let x = 0; x < header.numXChunks; x += 1) {
const minX = header.origin[0] + x * chunkWorldSize;
const maxX = minX + chunkWorldSize;
if (maxX < sourceBounds.min[0] || minX > sourceBounds.max[0]) continue;
for (let z = 0; z < header.numZChunks; z += 1) {
const minZ = header.origin[2] + z * chunkWorldSize;
const maxZ = minZ + chunkWorldSize;
if (maxZ < sourceBounds.min[2] || minZ > sourceBounds.max[2]) continue;
result.push({ x, z, bounds: { min: [minX, minZ], max: [maxX, maxZ] } });
}
}
return result;
}
export function encodeChunkKey(x, z) {
return ((x << 16) | z) >>> 0;
}
function f32Buffer(values) {
const buffer = Buffer.alloc(values.length * 4);
values.forEach((value, index) => buffer.writeFloatLE(value, index * 4));
return buffer;
}
function u32Buffer(values) {
const buffer = Buffer.alloc(values.length * 4);
values.forEach((value, index) => buffer.writeUInt32LE(value, index * 4));
return buffer;
}
function u16Buffer(values) {
const buffer = Buffer.alloc(values.length * 2);
values.forEach((value, index) => buffer.writeUInt16LE(value, index * 2));
return buffer;
}
function u8Buffer(values) {
return Buffer.from(values);
}
export function wrapDxt1AsDds(width, height, payload) {
const header = Buffer.alloc(128);
header.write("DDS ", 0, "ascii");
header.writeUInt32LE(124, 4);
header.writeUInt32LE(0x00081007, 8); // caps, height, width, pixel format, linear size
header.writeUInt32LE(height, 12);
header.writeUInt32LE(width, 16);
header.writeUInt32LE(payload.length, 20);
header.writeUInt32LE(1, 28);
header.writeUInt32LE(32, 76);
header.writeUInt32LE(0x4, 80); // DDPF_FOURCC
header.write("DXT1", 84, "ascii");
header.writeUInt32LE(0x1000, 108); // DDSCAPS_TEXTURE
return Buffer.concat([header, payload]);
}
function ddsInfo(buffer) {
if (buffer.toString("ascii", 0, 4) !== "DDS ") throw new Error("Source tile is not a DDS file.");
return {
height: uint32(buffer, 12),
width: uint32(buffer, 16),
mipCount: Math.max(1, uint32(buffer, 28)),
fourCC: buffer.toString("ascii", 84, 88),
};
}
async function main() {
const recipeText = await readFile(recipeFile, "utf8");
const recipe = JSON.parse(recipeText);
const wdbFile = path.resolve(wdbOverride ?? path.join(sourceRoot, recipe.source?.wdb ?? "Resource/wdb/dungeon/dgn_sand_vortex.wdb"));
const wdbBuffer = await readFile(wdbFile);
const datastore = readDataStore(wdbBuffer);
const requiredTables = ["header", "chunks", "texture_list"];
for (const name of requiredTables) {
if (!datastore.tables.has(name)) throw new Error(`WDB does not contain required terrain table '${name}'.`);
}
const headerRecord = recordPayload(wdbBuffer, datastore.tables.get("header"), 0);
if (!headerRecord || headerRecord.length < 36) throw new Error("Terrain header record is missing or incomplete.");
const header = {
origin: [float32(headerRecord, 0), float32(headerRecord, 4), float32(headerRecord, 8)],
chunkDimension: int32(headerRecord, 12),
numXChunks: int32(headerRecord, 16),
numZChunks: int32(headerRecord, 20),
splatResolution: int32(headerRecord, 24),
gridSize: float32(headerRecord, 28),
lodLevels: int32(headerRecord, 32),
};
const slug = recipe.slug;
if (typeof slug !== "string" || !/^[a-z0-9-]+$/.test(slug)) 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 outputRoot = path.resolve(outputOverride ?? path.join(projectRoot, "runewaker-export-work", slug, "native-terrain-source"));
const sourceBounds = recipe.source?.nativeTerrain?.sourceBounds
?? recipe.source?.proceduralTerrainFallback?.sourceBounds;
if (!sourceBounds?.min || !sourceBounds?.max) throw new Error(`${recipe.title ?? slug} recipe has no source bounds to select terrain chunks.`);
const chunksTable = datastore.tables.get("chunks");
const textureTable = datastore.tables.get("texture_list");
const candidates = intersectingChunkIndices(header, sourceBounds);
if (candidates.length === 0) throw new Error("Recipe source bounds do not intersect any native terrain chunks.");
const texturesRoot = path.join(outputRoot, "textures");
const weightsRoot = path.join(outputRoot, "weights");
const geometryRoot = path.join(outputRoot, "geometry");
await Promise.all([
mkdir(texturesRoot, { recursive: true }),
mkdir(weightsRoot, { recursive: true }),
mkdir(geometryRoot, { recursive: true }),
]);
const layerResourceById = new Map();
for (const [key] of textureTable.records) {
const layerId = Number(key);
if (!Number.isInteger(layerId) || layerId < 0 || layerId >= 2048) continue;
const entry = parseTextureListEntry(recordPayload(wdbBuffer, textureTable, layerId), layerId);
if (entry) layerResourceById.set(layerId, entry);
}
const copiedTextures = new Map();
const chunks = [];
for (const selected of candidates) {
const chunkKey = encodeChunkKey(selected.x, selected.z);
const compressed = recordPayload(wdbBuffer, chunksTable, chunkKey);
if (!compressed) throw new Error(`Terrain chunk ${selected.x},${selected.z} is missing from the chunks table.`);
const parsed = parseTerrainChunk(compressed, `${selected.x},${selected.z}`, {
allowUnparsedWaterPayload: recipe.source.nativeTerrain.allowUnparsedWaterPayload === true,
});
const chunkSlug = `chunk-x${selected.x}-z${selected.z}`;
const chunkBase = path.join(geometryRoot, chunkSlug);
const flatPositions = [];
for (let index = 0; index < parsed.localPositions.length; index += 3) {
flatPositions.push(parsed.localPositions[index] + parsed.serializedOrigin[0]);
flatPositions.push(parsed.localPositions[index + 1]);
flatPositions.push(parsed.localPositions[index + 2] + parsed.serializedOrigin[1]);
}
const geometryFiles = {
positions: `${path.relative(outputRoot, `${chunkBase}.positions.f32`).replaceAll("\\", "/")}`,
localPositions: `${path.relative(outputRoot, `${chunkBase}.local-positions.f32`).replaceAll("\\", "/")}`,
uvs: `${path.relative(outputRoot, `${chunkBase}.normalized-xz-uvs.f32`).replaceAll("\\", "/")}`,
indices: `${path.relative(outputRoot, `${chunkBase}.indices.u16`).replaceAll("\\", "/")}`,
normals: `${path.relative(outputRoot, `${chunkBase}.source-normals.f32`).replaceAll("\\", "/")}`,
heights: `${path.relative(outputRoot, `${chunkBase}.heightfield.f32`).replaceAll("\\", "/")}`,
vertexData: `${path.relative(outputRoot, `${chunkBase}.vertex-data.u32`).replaceAll("\\", "/")}`,
vertexFlags: `${path.relative(outputRoot, `${chunkBase}.vertex-flags.u8`).replaceAll("\\", "/")}`,
nativeVertexFlags: `${path.relative(outputRoot, `${chunkBase}.native-vertex-flags.u8`).replaceAll("\\", "/")}`,
cellVisibilityFlags: `${path.relative(outputRoot, `${chunkBase}.cell-visibility-flags.u8`).replaceAll("\\", "/")}`,
triangleVisibilityFlags: `${path.relative(outputRoot, `${chunkBase}.triangle-visibility-flags.u8`).replaceAll("\\", "/")}`,
};
const outputs = [
writeFile(path.join(outputRoot, geometryFiles.positions), f32Buffer(flatPositions)),
writeFile(path.join(outputRoot, geometryFiles.localPositions), f32Buffer(parsed.localPositions)),
writeFile(path.join(outputRoot, geometryFiles.uvs), f32Buffer(parsed.normalizedUvs)),
writeFile(path.join(outputRoot, geometryFiles.indices), u16Buffer(parsed.indices)),
writeFile(path.join(outputRoot, geometryFiles.normals), f32Buffer(parsed.nativeNormals)),
writeFile(path.join(outputRoot, geometryFiles.heights), f32Buffer(parsed.heights)),
writeFile(path.join(outputRoot, geometryFiles.vertexData), u32Buffer(parsed.vertexData)),
writeFile(path.join(outputRoot, geometryFiles.vertexFlags), u8Buffer(parsed.topVertexFlags)),
writeFile(path.join(outputRoot, geometryFiles.nativeVertexFlags), u8Buffer(parsed.nativeVertexFlags)),
writeFile(path.join(outputRoot, geometryFiles.cellVisibilityFlags), u8Buffer(parsed.cellVisibility)),
writeFile(path.join(outputRoot, geometryFiles.triangleVisibilityFlags), u8Buffer(parsed.triangleVisibility)),
];
const layers = [];
for (const layerId of parsed.layerIds) {
const texture = layerResourceById.get(layerId);
if (!texture) throw new Error(`Chunk ${selected.x},${selected.z} references missing texture_list ID ${layerId}.`);
const ddsRelativeResource = `${texture.resource.replaceAll("\\", "/")}.dds`;
const sourceDdsFile = path.resolve(sourceRoot, "Resource", ...ddsRelativeResource.split("/"));
const textureSlug = path.basename(ddsRelativeResource).toLowerCase();
const destination = path.join(texturesRoot, textureSlug);
if (!copiedTextures.has(layerId)) {
const sourceDdsBuffer = await readFile(sourceDdsFile);
const imageInfo = ddsInfo(sourceDdsBuffer);
if (!["DXT1", "DXT3", "DXT5"].includes(imageInfo.fourCC)) {
throw new Error(`Terrain texture ${ddsRelativeResource} uses unsupported DDS format ${imageInfo.fourCC}.`);
}
await copyFile(sourceDdsFile, destination);
copiedTextures.set(layerId, {
...texture,
sourceResource: ddsRelativeResource,
sourceDDS: path.relative(outputRoot, destination).replaceAll("\\", "/"),
sourceClientPath: path.relative(sourceRoot, sourceDdsFile).replaceAll("\\", "/"),
copiedFile: path.relative(outputRoot, destination).replaceAll("\\", "/"),
width: imageInfo.width,
height: imageInfo.height,
mipCount: imageInfo.mipCount,
format: imageInfo.fourCC,
bytes: sourceDdsBuffer.length,
sha256: createHash("sha256").update(sourceDdsBuffer).digest("hex"),
});
}
layers.push(copiedTextures.get(layerId));
}
const splatMaps = [];
for (const splat of parsed.splatMaps) {
const firstMip = splat.mipmaps[0];
const channelLayerIds = parsed.layerIds.slice(splat.mapIndex * 3, splat.mapIndex * 3 + 3);
const mapStem = `${chunkSlug}-alpha${splat.mapIndex}`;
const dxtPath = path.join(weightsRoot, `${mapStem}.dxt1.bin`);
const splatDdsPath = path.join(weightsRoot, `${mapStem}.dds`);
const rgbaPath = path.join(weightsRoot, `${mapStem}.rgba.bin`);
const pngPath = path.join(weightsRoot, `${mapStem}.linear-rgb.png`);
const rgba = dxt1ToRgba(firstMip.bytes, firstMip.width, firstMip.height);
outputs.push(
writeFile(dxtPath, firstMip.bytes),
writeFile(splatDdsPath, wrapDxt1AsDds(firstMip.width, firstMip.height, firstMip.bytes)),
writeFile(rgbaPath, rgba),
writeFile(pngPath, rgbaPng(firstMip.width, firstMip.height, rgba)),
);
splatMaps.push({
mapIndex: splat.mapIndex,
format: "DXT1",
width: firstMip.width,
height: firstMip.height,
mipCount: splat.mipmaps.length,
channelOrder: ["R", "G", "B"],
channelLayerIds,
sourceCompressedFile: path.relative(outputRoot, dxtPath).replaceAll("\\", "/"),
splatDDS: path.relative(outputRoot, splatDdsPath).replaceAll("\\", "/"),
linearRgbaFile: path.relative(outputRoot, rgbaPath).replaceAll("\\", "/"),
linearPngFile: path.relative(outputRoot, pngPath).replaceAll("\\", "/"),
sampling: "normalized-local-xz; clamp; decoded color channels are linear weights; ignore source DXT1 alpha",
mipmaps: splat.mipmaps.map((mip) => ({ width: mip.width, height: mip.height, bytes: mip.bytes.length })),
});
}
await Promise.all(outputs);
const recipeChunkBounds = selected.bounds;
const serializedOrigin = parsed.serializedOrigin;
const expectedOrigin = [recipeChunkBounds.min[0], recipeChunkBounds.min[1]];
chunks.push({
id: chunkSlug,
key: chunkKey,
coordinate: { x: selected.x, z: selected.z },
dimension: parsed.dimension,
gridSize: parsed.gridSize,
chunkWorldSize: parsed.dimension * parsed.gridSize,
headerGridSize: header.gridSize,
serializedOrigin,
headerDerivedOrigin: expectedOrigin,
originMatchesHeaderGrid: serializedOrigin.every((value, index) => Math.abs(value - expectedOrigin[index]) < 0.01),
bounds: parsed.bounds,
expectedChunkBoundsXZ: recipeChunkBounds,
heightRange: [parsed.minHeight, parsed.maxHeight],
sourceVertexCount: (parsed.dimension + 1) * (parsed.dimension + 1),
nativeVertexDataCount: parsed.heights.length,
visibleSourceVertexCount: parsed.topVertexFlags.reduce((count, value) => count + (value !== 0 ? 1 : 0), 0),
maskedSourceVertexCount: parsed.topVertexFlags.reduce((count, value) => count + (value === 0 ? 1 : 0), 0),
sourceCellCount: parsed.dimension * parsed.dimension,
visibleSourceCellCount: parsed.cellVisibility.reduce((count, value) => count + value, 0),
sourceTriangleCount: parsed.indices.length / 3,
visibleSourceTriangleCount: parsed.triangleVisibility.reduce((count, value) => count + value, 0),
maskedSourceTriangleCount: parsed.triangleVisibility.reduce((count, value) => count + (value === 0 ? 1 : 0), 0),
triangleCount: parsed.indices.length / 3,
unconsumedDecompressedBytes: parsed.unconsumedBytes,
splatResolution: parsed.splatResolution,
splatMapDimension: parsed.splatDimension,
doodadClusterCount: parsed.doodadClusterCount,
doodadCount: parsed.doodadCount,
waterLayerCount: parsed.waterLayerCount,
layers,
splatMaps,
geometryFiles: {
...geometryFiles,
heightfieldVerts: geometryFiles.positions,
normalizedXZUvs: geometryFiles.uvs,
exactIndices: geometryFiles.indices,
nativeNormals: geometryFiles.normals,
},
topology: {
source: "RuTerrainUtility_BuildGridIndicesEx(dimension, stride=1, pitch=dimension+1, vertexOffset=0, vertexFlags)",
evenRowPairPattern: [[0, 3, 4], [0, 4, 1], [1, 4, 2], [2, 4, 5]],
oddRowPairPattern: [[0, 3, 1], [1, 3, 4], [1, 5, 2], [1, 4, 5]],
maskedTriangleBehavior: "preserve source index slot and emit [0,0,0] when all three referenced source vertex flags are zero",
cellVisibilityEncoding: "one byte per cell; 1 when a visible triangle centroid falls inside the cell, otherwise 0",
triangleVisibilityEncoding: "one byte per emitted source triangle, 1 visible / 0 degenerate-masked",
vertexVisibilityEncoding: "top-surface high byte of native DWORD vertex data; 0 masked, nonzero visible",
},
nativeNormals: {
file: geometryFiles.normals,
encoding: "three Float32 components per top-surface vertex, decoded from source DWORD vertexData RGB bytes with (channel/255 - 0.5) * 2",
sourceShaderEvidence: "RuShader_SM20EZ_Terrain unpacks diffuse.xyz to a signed normal; CRuOptimalTerrain_Chunk copies the serialized vertexData DWORD into diffuse",
sourceSpace: "left-handed Y-up; negate z when bridging to Three.js; normalize after transform if needed",
},
uv: {
encoding: "Float32 x,z pairs; normalized local chunk coordinates [0,1]",
sourceEngineFormula: "uv = localPos.xz * inverseChunkExtent; terrain UV = uv * [tileScale,-tileScale]",
},
material: {
variantNames: parsed.layerIds.map((layerId) => `${slug}-${chunkSlug}-layer-${layerId}`),
weightVariantNames: parsed.splatMaps.map((splat) => `${slug}-${chunkSlug}-weights-${splat.mapIndex}`),
weightCarrierSemantics: "each linear RGB PNG is used as occlusionTexture (R channel) and metallicRoughnessTexture (G/B channels); ignore alpha",
blend: "normalize the summed R/G/B weights from all alpha maps across the chunk layer list",
},
});
}
const terrainMinX = header.origin[0];
const terrainMaxX = terrainMinX + header.numXChunks * header.chunkDimension * header.gridSize;
const terrainMinZ = header.origin[2];
const terrainMaxZ = terrainMinZ + header.numZChunks * header.chunkDimension * header.gridSize;
const manifest = {
schemaVersion: 1,
source: {
wdb: path.relative(projectRoot, wdbFile).replaceAll("\\", "/"),
wdbSha256: createHash("sha256").update(wdbBuffer).digest("hex"),
engineTables: Object.fromEntries(requiredTables.map((name) => [name, {
recordCount: datastore.tables.get(name).count,
keyLength: datastore.tables.get(name).keyLength,
}])),
terrainHeader: header,
fullTerrainBoundsXZ: { min: [terrainMinX, terrainMinZ], max: [terrainMaxX, terrainMaxZ] },
sourceBounds,
selectionRule: "include each native WDB heightfield chunk whose XZ bounds intersect the recipe source bounds; no terrain resampling or texture painting",
waterPayloadPolicy: recipe.source.nativeTerrain.allowUnparsedWaterPayload === true
? "water layer payload left unparsed; native ground heightfield/topology is extracted unchanged"
: "water payloads rejected",
sourceCoordinateSystem: "Runewaker left-handed Y-up; source coordinates retained in all geometry files",
projectCoordinateBridge: "Three.js uses (x,y,z)=(Runewaker.x,Runewaker.y,-Runewaker.z), scale 0.1 meters per source unit",
originalTerrainShader: {
layerUv: "normalizedLocalXZ * [tileScale,-tileScale]",
weightUv: "normalizedLocalXZ",
weightChannels: "each DXT1 alpha map RGB channels map to consecutive triples in layerIDs; normalized by the total across all maps",
outputAlpha: 1,
},
},
anchor: {
positionSource: recipe.source?.entryCoordinates?.position ?? null,
directionSource: recipe.source?.entryCoordinates?.direction ?? null,
terrainOrigin: header.origin,
sourceCoordinateSystem: "left-handed Y-up",
},
textures: [...copiedTextures.values()],
chunks,
};
const manifestFile = path.join(outputRoot, "terrain-native-source.json");
await writeFile(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
console.log(`[runewaker] extracted ${chunks.length} native ${recipe.title ?? slug} terrain chunks (${chunks.reduce((sum, chunk) => sum + chunk.triangleCount, 0)} source triangles, ${chunks.reduce((sum, chunk) => sum + chunk.maskedSourceTriangleCount, 0)} masked)`);
console.log(`[runewaker] native terrain manifest: ${path.relative(projectRoot, manifestFile).replaceAll("\\", "/")}`);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await main();
}
@@ -0,0 +1,188 @@
import assert from "node:assert/strict";
import { deflateSync } from "node:zlib";
import test from "node:test";
import {
dxt1ToRgba,
encodeChunkKey,
intersectingChunkIndices,
parseTerrainChunk,
readDataStore,
wrapDxt1AsDds,
} from "./extract-native-terrain.mjs";
function i32(value) {
const buffer = Buffer.alloc(4);
buffer.writeInt32LE(value);
return buffer;
}
function u32(value) {
const buffer = Buffer.alloc(4);
buffer.writeUInt32LE(value);
return buffer;
}
function f32(value) {
const buffer = Buffer.alloc(4);
buffer.writeFloatLE(value);
return buffer;
}
function makeChunk({ dimension = 2, visibleVertices = null, waterLayerCount = 0, waterPayload = Buffer.alloc(0) } = {}) {
const count = (dimension + 1) ** 2 + (dimension + 1) * 4;
const classGuid = Buffer.alloc(16);
const heights = Buffer.concat(Array.from({ length: count }, (_, index) => f32(index / 10)));
const vertexData = Buffer.concat(Array.from({ length: count }, (_, index) => {
const visible = visibleVertices === null || visibleVertices.has(index);
const alpha = visible ? 255 : 0;
return u32(((alpha << 24) | (191 << 16) | (128 << 8) | 255) >>> 0);
}));
const bounds = Buffer.concat([
f32(-960), f32(-2), f32(-960),
f32(-945), f32(2), f32(-945),
]);
const mapDxt1 = Buffer.alloc(8);
mapDxt1.writeUInt16LE(0xffff, 0);
mapDxt1.writeUInt16LE(0x0000, 2);
const layerIds = Buffer.concat([u32(1).subarray(0, 2), u32(3).subarray(0, 2), u32(9).subarray(0, 2)]);
const bytes = Buffer.concat([
classGuid,
i32(4), // chunk iteration
i32(dimension),
f32(7.5),
i32(4),
bounds,
f32(-960), f32(-960),
heights,
vertexData,
i32(128), // splat resolution
classGuid,
i32(1), // compressed-alpha map type
i32(4), // splat map dimension
i32(3),
layerIds,
i32(1), // alpha maps
i32(1), // mip count
u32(4), u32(4),
mapDxt1,
i32(0), // doodad clusters
i32(waterLayerCount), // water layers
waterPayload,
]);
return deflateSync(bytes);
}
function makeDataStoreFixture() {
const tablePosition = 28;
const recordHeaderPosition = tablePosition + 272;
const recordPayloadPosition = recordHeaderPosition + 16;
const payload = Buffer.from([1, 2, 3, 4]);
const buffer = Buffer.alloc(recordPayloadPosition + payload.length);
buffer.writeInt32LE(tablePosition, 16);
buffer.writeInt32LE(1, 20);
buffer.writeInt32LE(32, 24);
buffer.write("header", tablePosition, "ascii");
buffer.writeInt32LE(4, tablePosition + 256);
buffer.writeInt32LE(recordHeaderPosition, tablePosition + 260);
buffer.writeInt32LE(1, tablePosition + 264);
buffer.writeInt32LE(64, tablePosition + 268);
buffer.writeUInt32LE(0, recordHeaderPosition);
buffer.writeInt32LE(recordPayloadPosition, recordHeaderPosition + 4);
buffer.writeInt32LE(payload.length, recordHeaderPosition + 8);
buffer.writeInt32LE(64, recordHeaderPosition + 12);
payload.copy(buffer, recordPayloadPosition);
return buffer;
}
test("terrain chunk parser preserves source geometry, visibility, normals, and splat layer IDs", () => {
const parsed = parseTerrainChunk(makeChunk({ visibleVertices: new Set([0]) }), "test");
assert.equal(parsed.dimension, 2);
assert.equal(parsed.localPositions.length, 27);
assert.deepEqual([...parsed.normalizedUvs.slice(0, 6)], [0, 0, 0.5, 0, 1, 0]);
assert.deepEqual([...parsed.layerIds], [1, 3, 9]);
assert.deepEqual([...parsed.triangleVisibility], [1, 1, 0, 0, 0, 0, 0, 0]);
assert.deepEqual([...parsed.indices], [0, 3, 4, 0, 4, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
assert.deepEqual([...parsed.cellVisibility], [1, 0, 0, 0]);
const expectedNormal = [
(191 / 255 - 0.5) * 2,
(128 / 255 - 0.5) * 2,
1,
];
for (let axis = 0; axis < 3; axis += 1) {
assert.ok(Math.abs(parsed.nativeNormals[axis] - expectedNormal[axis]) < 1e-6);
}
assert.equal(parsed.splatMaps[0].mipmaps[0].bytes.length, 8);
assert.equal(parsed.doodadClusterCount, 0);
assert.equal(parsed.waterLayerCount, 0);
assert.equal(parsed.unconsumedBytes, 0);
});
test("terrain topology retains every face slot and degenerates fully masked triangles", () => {
const parsed = parseTerrainChunk(makeChunk({ visibleVertices: new Set() }), "masked-test");
assert.equal(parsed.indices.length / 3, 8);
assert.equal(parsed.triangleVisibility.reduce((sum, value) => sum + value, 0), 0);
assert.ok(parsed.indices.every((value) => value === 0));
assert.ok(parsed.cellVisibility.every((value) => value === 0));
});
test("water-bearing WDB chunks can export their unchanged ground heightfield when explicitly enabled", () => {
const source = makeChunk({ waterLayerCount: 1, waterPayload: Buffer.from([0x51, 0x52, 0x53, 0x54]) });
assert.throws(() => parseTerrainChunk(source, "wet-fixture"), /allowUnparsedWaterPayload/);
const parsed = parseTerrainChunk(source, "wet-fixture", { allowUnparsedWaterPayload: true });
assert.equal(parsed.waterLayerCount, 1);
assert.equal(parsed.heights[0], 0);
assert.deepEqual([...parsed.triangleVisibility], [1, 1, 1, 1, 1, 1, 1, 1]);
assert.equal(parsed.unconsumedBytes, 4);
});
test("Rune disk datastore table and record headers are read using native offsets", () => {
const datastore = readDataStore(makeDataStoreFixture());
const header = datastore.tables.get("header");
const record = header.records.get("0");
assert.equal(datastore.tableCount, 1);
assert.equal(header.keyLength, 4);
assert.equal(header.count, 1);
assert.equal(record.position, 316);
assert.deepEqual([...record.key], [0, 0, 0, 0]);
});
test("native terrain chunk selection and DXT1 standalone DDS wrapper are deterministic", () => {
const selected = intersectingChunkIndices({
origin: [-7680, 0, -7680],
chunkDimension: 64,
gridSize: 7.5,
numXChunks: 32,
numZChunks: 32,
}, { min: [-664.98, 0, -716.26], max: [180.76, 0, 117.06] });
const dxt1 = Buffer.alloc(8);
dxt1.writeUInt16LE(0xffff, 0);
const rgba = dxt1ToRgba(dxt1, 4, 4);
const dds = wrapDxt1AsDds(4, 4, dxt1);
assert.equal(selected.length, 9);
assert.equal(selected[0].x, 14);
assert.equal(selected[0].z, 14);
assert.equal(encodeChunkKey(14, 14), 917518);
assert.deepEqual([...rgba.subarray(0, 4)], [255, 255, 255, 255]);
assert.equal(dds.toString("ascii", 0, 4), "DDS ");
assert.equal(dds.readUInt32LE(12), 4);
assert.equal(dds.readUInt32LE(16), 4);
assert.equal(dds.toString("ascii", 84, 88), "DXT1");
assert.deepEqual(dds.subarray(128), dxt1);
const rowPattern = Buffer.alloc(8);
rowPattern.writeUInt16LE(0xf800, 0); // red endpoint
rowPattern.writeUInt16LE(0x001f, 2); // blue endpoint
let packed = 0;
for (let pixel = 0; pixel < 16; pixel += 1) {
packed = (packed | ((pixel >>> 2) << (pixel * 2))) >>> 0;
}
rowPattern.writeUInt32LE(packed, 4);
const rowPixels = dxt1ToRgba(rowPattern, 4, 4);
assert.deepEqual([...rowPixels.subarray(0, 4)], [255, 0, 0, 255]);
assert.deepEqual([...rowPixels.subarray(4 * 4, 4 * 4 + 4)], [0, 0, 255, 255]);
});
@@ -0,0 +1,276 @@
import {
Document,
NodeIO,
} from "@gltf-transform/core";
import {
ALL_EXTENSIONS,
KHRMaterialsVariants,
KHRTextureTransform,
} from "@gltf-transform/extensions";
const REPEAT = 10497;
const CLAMP_TO_EDGE = 33071;
function addAccessor(document, buffer, name, type, array) {
return document.createAccessor(name, buffer).setType(type).setArray(array);
}
function createTerrainMaterial(document, textureTransformExtension, name, texture, tileScale, extras = null) {
const material = document.createMaterial(name)
.setBaseColorTexture(texture)
.setBaseColorFactor([1, 1, 1, 1])
.setMetallicFactor(0)
.setRoughnessFactor(1)
.setAlphaMode("OPAQUE");
const textureInfo = material.getBaseColorTextureInfo();
textureInfo.setWrapS(REPEAT).setWrapT(REPEAT);
textureInfo.setExtension(KHRTextureTransform.EXTENSION_NAME, textureTransformExtension.createTransform()
.setScale([Number(tileScale), -Number(tileScale)]));
if (extras) material.setExtras(extras);
return material;
}
function addVariantMapping(document, variantExtension, mappingList, material, name) {
const variant = variantExtension.createVariant(name);
mappingList.addMapping(variantExtension.createMapping().setMaterial(material).addVariant(variant));
return variant;
}
function setTextureProvenance(texture, sourceDDS, extra = {}) {
texture.setExtras({
...texture.getExtras(),
runewakerSourceDDS: sourceDDS,
...extra,
});
}
function createVisualDocument(converted, textureSources, provenance) {
const slug = provenance.dungeonId ?? "sand-vortex";
const title = provenance.dungeonTitle ?? "Sand Vortex";
const document = new Document();
const root = document.getRoot();
const buffer = document.createBuffer(`${slug}-native-terrain-buffer`);
const scene = document.createScene(`${title} native terrain`);
const partitionChunks = provenance.partitionVisualChunks === true;
const mesh = partitionChunks ? null : document.createMesh(`${title} terrain tiles`);
const chunkNodes = [];
const variants = document.createExtension(KHRMaterialsVariants).setRequired(false);
const textureTransform = document.createExtension(KHRTextureTransform).setRequired(false);
const textureBySource = new Map();
for (const [sourceKey, details] of textureSources.textureBySource) {
const texture = document.createTexture(details.imageName)
.setImage(details.image)
.setMimeType("image/png")
.setExtras({
runewakerSourceDDS: details.sourceDDS,
...(details.sourceCompressedFile ? { runewakerSourceCompressed: details.sourceCompressedFile } : {}),
...(details.channelLayerIds ? { runewakerWeightChannels: details.channelLayerIds } : {}),
runewakerColorPolicy: details.alphaPolicy ?? details.colorSpace,
});
textureBySource.set(sourceKey, texture);
}
const positions = new Float32Array(converted.vertexCount * 3);
const normals = new Float32Array(converted.vertexCount * 3);
const uvs = new Float32Array(converted.vertexCount * 2);
for (const chunk of converted.chunks) {
positions.set(chunk.positions, chunk.vertexOffset * 3);
normals.set(chunk.normals, chunk.vertexOffset * 3);
uvs.set(chunk.uvs, chunk.vertexOffset * 2);
}
const positionAccessor = partitionChunks ? null : addAccessor(document, buffer, `${slug}-native-terrain-positions`, "VEC3", positions);
const normalAccessor = partitionChunks ? null : addAccessor(document, buffer, `${slug}-native-terrain-normals`, "VEC3", normals);
const uvAccessor = partitionChunks ? null : addAccessor(document, buffer, `${slug}-native-terrain-normalized-local-xz`, "VEC2", uvs);
for (const chunk of converted.chunks) {
const layers = chunk.layers;
const weights = chunk.splatMaps;
if (!Array.isArray(layers) || layers.length < 1 || layers.length > 6) {
throw new Error(`${chunk.id} must contain 1–6 source terrain layers.`);
}
if (!Array.isArray(weights) || weights.length !== Math.ceil(layers.length / 3)) {
throw new Error(`${chunk.id} weight-map count does not match its terrain-layer count.`);
}
const metadata = {
version: 1,
layerVariants: chunk.material.variantNames,
weightVariants: chunk.material.weightVariantNames,
tileScales: layers.map((layer) => Number(layer.tileScale)),
...(provenance.cliffProjection ? { cliffProjection: provenance.cliffProjection } : {}),
};
if (metadata.layerVariants.length !== layers.length || metadata.weightVariants.length !== weights.length) {
throw new Error(`${chunk.id} variant name counts do not match its source terrain data.`);
}
const firstLayer = layers[0];
const firstTexture = textureBySource.get(firstLayer.sourceDDS);
if (!firstTexture) throw new Error(`${chunk.id} default tile image is missing: ${firstLayer.sourceDDS}`);
const defaultMaterial = createTerrainMaterial(
document,
textureTransform,
`${chunk.id} default layer ${firstLayer.layerId}`,
firstTexture,
firstLayer.tileScale,
{ runewakerTerrain: metadata },
);
const mappingList = variants.createMappingList();
addVariantMapping(document, variants, mappingList, defaultMaterial, metadata.layerVariants[0]);
for (let layerIndex = 1; layerIndex < layers.length; layerIndex += 1) {
const layer = layers[layerIndex];
const texture = textureBySource.get(layer.sourceDDS);
if (!texture) throw new Error(`${chunk.id} layer image is missing: ${layer.sourceDDS}`);
const material = createTerrainMaterial(
document,
textureTransform,
`${chunk.id} layer ${layer.layerId}`,
texture,
layer.tileScale,
);
addVariantMapping(document, variants, mappingList, material, metadata.layerVariants[layerIndex]);
}
for (let weightIndex = 0; weightIndex < weights.length; weightIndex += 1) {
const weight = weights[weightIndex];
const texture = textureBySource.get(weight.linearPngFile);
if (!texture) throw new Error(`${chunk.id} linear splat image is missing: ${weight.linearPngFile}`);
const material = document.createMaterial(`${chunk.id} linear weights ${weight.mapIndex}`)
.setMetallicFactor(0)
.setRoughnessFactor(1)
.setAlphaMode("OPAQUE")
.setMetallicRoughnessTexture(texture)
.setOcclusionTexture(texture)
.setExtras({
runewakerTerrainWeightMap: {
mapIndex: weight.mapIndex,
channelLayerIds: weight.channelLayerIds,
sourceDDS: weight.splatDDS,
linearPngFile: weight.linearPngFile,
channels: "linear RGB; source alpha ignored",
},
});
material.getMetallicRoughnessTextureInfo().setWrapS(CLAMP_TO_EDGE).setWrapT(CLAMP_TO_EDGE);
material.getOcclusionTextureInfo().setWrapS(CLAMP_TO_EDGE).setWrapT(CLAMP_TO_EDGE);
addVariantMapping(document, variants, mappingList, material, metadata.weightVariants[weightIndex]);
}
const indices = addAccessor(
document,
buffer,
`${chunk.id}-source-triangle-indices-with-mask-slots`,
"SCALAR",
partitionChunks ? Uint16Array.from(chunk.visualIndices, index => index - chunk.vertexOffset) : chunk.visualIndices,
);
const primitive = document.createPrimitive()
.setAttribute("POSITION", positionAccessor ?? addAccessor(document, buffer, `${chunk.id}-positions`, "VEC3", chunk.positions))
.setAttribute("NORMAL", normalAccessor ?? addAccessor(document, buffer, `${chunk.id}-normals`, "VEC3", chunk.normals))
.setAttribute("TEXCOORD_0", uvAccessor ?? addAccessor(document, buffer, `${chunk.id}-uvs`, "VEC2", chunk.uvs))
.setIndices(indices)
.setMaterial(defaultMaterial)
.setExtension(KHRMaterialsVariants.EXTENSION_NAME, mappingList)
.setExtras({
runewakerTerrainChunk: {
id: chunk.id,
coordinate: chunk.coordinate,
sourceVertexFlagsSha256: chunk.vertexFlagsSha256 ?? null,
sourceCellVisibilityFlagsSha256: chunk.cellVisibilityFlagsSha256 ?? null,
sourceTriangleVisibilityFlags: Buffer.from(chunk.triangleVisibilityFlags).toString("base64"),
sourceTriangleVisibilityFlagsEncoding: "base64 encoded Uint8Array; one value per source triangle; 1 visible / 0 masked",
sourceTriangleVisibilitySha256: chunk.triangleVisibilitySha256 ?? null,
sourceTriangleSlots: chunk.triangleVisibilityFlags.length,
visibleSourceTriangles: chunk.visibleSourceTriangles,
maskedSourceTriangles: chunk.maskedSourceTriangles,
},
});
if (mesh) mesh.addPrimitive(primitive);
else chunkNodes.push(document.createNode(chunk.id)
.setMesh(document.createMesh(`${chunk.id} native tile`).addPrimitive(primitive)));
}
const node = document.createNode(`${title} native terrain`)
.setExtras({
runewakerTerrain: {
version: 1,
source: "native WDB terrain chunks; no resampling or layer baking",
chunkCount: converted.chunks.length,
partition: partitionChunks ? "native-chunk-meshes" : "shared-grid-mesh",
sourceManifestSha256: provenance.sourceManifestSha256,
primaryPlacementGuid: provenance.primaryPlacementGuid,
},
});
if (mesh) node.setMesh(mesh);
for (const chunkNode of chunkNodes) node.addChild(chunkNode);
scene.addChild(node);
root.setExtras({
runewakerTerrain: {
version: 1,
source: "Runewaker native WDB heightfield and texture list",
sourceManifestSha256: provenance.sourceManifestSha256,
sourceWdbSha256: provenance.sourceWdbSha256,
primaryPlacementGuid: provenance.primaryPlacementGuid,
metersPerSourceUnit: provenance.metersPerSourceUnit,
coordinateBridge: "inverse primary descriptor TRS, then (x,y,z)=(x,y,-z)*metersPerSourceUnit",
vertexNormalSource: "decoded native vertexData RGB; native terrain shader normal",
textureSourceDDs: provenance.textureSourceDDS,
},
});
root.setDefaultScene(scene);
return document;
}
function createCollisionDocument(converted, provenance) {
const slug = provenance.dungeonId ?? "sand-vortex";
const title = provenance.dungeonTitle ?? "Sand Vortex";
const document = new Document();
const root = document.getRoot();
const buffer = document.createBuffer(`${slug}-native-terrain-collision-buffer`);
const scene = document.createScene(`${title} native terrain collision`);
const mesh = document.createMesh(`${title} native terrain collision surfaces`);
const partitionChunks = provenance.partitionVisualChunks === true;
const positions = new Float32Array(partitionChunks ? 0 : converted.vertexCount * 3);
if (!partitionChunks) for (const chunk of converted.chunks) positions.set(chunk.positions, chunk.vertexOffset * 3);
const positionAccessor = partitionChunks ? null : addAccessor(document, buffer, `${slug}-native-terrain-collision-positions`, "VEC3", positions);
for (const chunk of converted.chunks) {
const indices = addAccessor(
document,
buffer,
`${chunk.id}-visible-collision-triangles`,
"SCALAR",
partitionChunks ? Uint16Array.from(chunk.collisionIndices, index => index - chunk.vertexOffset) : chunk.collisionIndices,
);
mesh.addPrimitive(document.createPrimitive()
.setAttribute("POSITION", positionAccessor ?? addAccessor(document, buffer, `${chunk.id}-collision-positions`, "VEC3", chunk.positions))
.setIndices(indices)
.setExtras({
runewakerTerrainCollisionChunk: {
id: chunk.id,
coordinate: chunk.coordinate,
sourceTriangleSlots: chunk.triangleVisibilityFlags.length,
sourceVisibleTriangles: chunk.visibleSourceTriangles,
includedCollisionTriangles: chunk.collisionIndices.length / 3,
maskedTrianglesExcluded: chunk.maskedSourceTriangles,
},
}));
}
scene.addChild(document.createNode(`${title} native terrain collision`).setMesh(mesh));
root.setExtras({
runewakerTerrainCollision: {
version: 1,
sourceManifestSha256: provenance.sourceManifestSha256,
sourceTriangleSlots: converted.report.sourceTriangleSlots,
includedVisibleTriangles: converted.report.collisionTriangles,
maskedOrDegenerateTrianglesExcluded: converted.report.excludedCollisionTriangles,
},
});
root.setDefaultScene(scene);
return document;
}
export async function writeNativeTerrainGlbs({ converted, textureSources, provenance, visualFile, collisionFile }) {
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
await io.write(visualFile, createVisualDocument(converted, textureSources, provenance));
await io.write(collisionFile, createCollisionDocument(converted, provenance));
}
@@ -0,0 +1,132 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import sharp from "sharp";
import { Matrix4 } from "three";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { convertNativeTerrainChunks } from "./terrain-geometry.mjs";
import { writeNativeTerrainGlbs } from "./native-terrain-glb.mjs";
function makeChunk(id, coordinateX, xOffset) {
return {
id,
coordinate: { x: coordinateX, z: 0 },
dimension: 1,
positions: Float32Array.from([
xOffset, 0, 0, xOffset + 1, 0, 0,
xOffset, 0, 1, xOffset + 1, 0, 1,
]),
normals: Float32Array.from(Array.from({ length: 4 }, () => [0, 1, 0]).flat()),
uvs: Float32Array.from([0, 0, 1, 0, 0, 1, 1, 1]),
indices: Uint16Array.from([0, 2, 3, 0, 0, 0]),
triangleVisibilityFlags: Uint8Array.from([1, 0]),
visibleSourceTriangles: 1,
maskedSourceTriangles: 1,
layers: [{ layerId: 4, tileScale: 12, sourceDDS: "textures/tile.dds" }],
splatMaps: [{ mapIndex: 0, splatDDS: "weights/a.dds", linearPngFile: "weights/a.png", channelLayerIds: [4] }],
material: { variantNames: [`${id}-layer-4`], weightVariantNames: [`${id}-weight-0`] },
};
}
function bounds3(array) {
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (let offset = 0; offset < array.length; offset += 3) {
for (let axis = 0; axis < 3; axis += 1) {
min[axis] = Math.min(min[axis], array[offset + axis]);
max[axis] = Math.max(max[axis], array[offset + axis]);
}
}
return { min, max };
}
test("partitioned native terrain GLBs serialize local chunk bounds and indices for visuals and collision", async () => {
const converted = convertNativeTerrainChunks(
[makeChunk("left", 0, 0), makeChunk("right", 1, 1)],
// Source Z reflection and meter scale match the serializer's production path.
new Matrix4().makeScale(0.1, 0.1, -0.1),
);
const png = await sharp(Buffer.from([
255, 0, 0, 255, 0, 255, 0, 255,
0, 0, 255, 255, 255, 255, 255, 255,
]), { raw: { width: 2, height: 2, channels: 4 } }).png().toBuffer();
const textureBySource = new Map([
["textures/tile.dds", { imageName: "tile.png", image: png, sourceDDS: "textures/tile.dds", colorSpace: "srgb" }],
["weights/a.png", { imageName: "a.png", image: png, sourceDDS: "weights/a.dds", colorSpace: "linear", channelLayerIds: [4] }],
]);
const directory = await mkdtemp(path.join(os.tmpdir(), "native-terrain-partition-"));
const visualFile = path.join(directory, "terrain-visual.glb");
const collisionFile = path.join(directory, "terrain-collision.glb");
const provenance = {
dungeonId: "fixture",
dungeonTitle: "Fixture",
partitionVisualChunks: true,
sourceManifestSha256: "a".repeat(64),
sourceWdbSha256: "b".repeat(64),
primaryPlacementGuid: "fixture-primary",
metersPerSourceUnit: 0.1,
textureSourceDDS: ["textures/tile.dds", "weights/a.dds"],
};
try {
await writeNativeTerrainGlbs({
converted,
textureSources: { textureBySource },
provenance,
visualFile,
collisionFile,
});
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const visual = await io.read(visualFile);
const collision = await io.read(collisionFile);
const visualPrimitives = visual.getRoot().listMeshes().flatMap((mesh) => mesh.listPrimitives())
.filter((primitive) => primitive.getExtras()?.runewakerTerrainChunk);
const collisionPrimitives = collision.getRoot().listMeshes().flatMap((mesh) => mesh.listPrimitives())
.filter((primitive) => primitive.getExtras()?.runewakerTerrainCollisionChunk);
assert.equal(visualPrimitives.length, 2);
assert.equal(collisionPrimitives.length, 2);
for (const [primitives, extraKey, indexKey] of [
[visualPrimitives, "runewakerTerrainChunk", "visualIndices"],
[collisionPrimitives, "runewakerTerrainCollisionChunk", "collisionIndices"],
]) {
for (const primitive of primitives) {
const metadata = primitive.getExtras()[extraKey];
const chunk = converted.chunks.find((row) => row.id === metadata.id);
assert.ok(chunk, `chunk metadata ${metadata.id} resolves to source geometry`);
const positionAccessor = primitive.getAttribute("POSITION");
const indexAccessor = primitive.getIndices();
assert.ok(positionAccessor);
assert.ok(indexAccessor);
assert.deepEqual([...positionAccessor.getArray()], [...chunk.positions]);
assert.deepEqual([...indexAccessor.getArray()], [...chunk[indexKey]].map((index) => index - chunk.vertexOffset));
assert.deepEqual(bounds3(positionAccessor.getArray()), bounds3(chunk.positions));
}
}
const visualByChunk = new Map(visualPrimitives.map((primitive) => [
primitive.getExtras().runewakerTerrainChunk.id,
[...primitive.getIndices().getArray()],
]));
const collisionByChunk = new Map(collisionPrimitives.map((primitive) => [
primitive.getExtras().runewakerTerrainCollisionChunk.id,
[...primitive.getIndices().getArray()],
]));
assert.deepEqual(visualByChunk.get("left"), [0, 3, 2, 0, 0, 0]);
assert.deepEqual(visualByChunk.get("right"), [0, 3, 2, 0, 0, 0]);
assert.deepEqual(collisionByChunk.get("left"), [0, 3, 2]);
assert.deepEqual(collisionByChunk.get("right"), [0, 3, 2]);
const close = (actual, expected) => assert.ok(
actual.every((value, index) => Math.abs(value - expected[index]) < 1e-6),
`${actual} should be within 1e-6 of ${expected}`,
);
close(bounds3(converted.chunks[0].positions).max, [0.1, 0, 0]);
close(bounds3(converted.chunks[1].positions).min, [0.1, 0, -0.1]);
close(bounds3(converted.chunks[1].positions).max, [0.2, 0, 0]);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
@@ -0,0 +1,324 @@
import { Matrix3, Matrix4, Vector3 } from "three";
import { descriptorSourceMatrix } from "../lib/environment-assembly.mjs";
const NORMAL_EPSILON = 1e-12;
export function sourceTerrainToThreeMatrix(primaryPlacement, metersPerSourceUnit) {
const meters = Number(metersPerSourceUnit);
if (!Number.isFinite(meters) || meters <= 0) {
throw new Error("metersPerSourceUnit must be a positive finite number.");
}
const sourceToThree = new Matrix4().makeScale(meters, meters, -meters);
return sourceToThree.multiply(descriptorSourceMatrix(primaryPlacement).invert());
}
function checkArrayLength(array, expectedLength, label) {
if (!ArrayBuffer.isView(array) || array.length !== expectedLength) {
throw new Error(`${label} has ${array?.length ?? "no"} values; expected ${expectedLength}.`);
}
}
function transformPosition(sourcePosition, matrix) {
const position = new Vector3().fromArray(sourcePosition).applyMatrix4(matrix);
if (![position.x, position.y, position.z].every(Number.isFinite)) {
throw new Error("Terrain position transform produced a non-finite coordinate.");
}
return position.toArray();
}
function transformNormal(sourceNormal, normalMatrix) {
const normal = new Vector3().fromArray(sourceNormal).applyMatrix3(normalMatrix);
const lengthSq = normal.lengthSq();
if (!Number.isFinite(lengthSq) || lengthSq < NORMAL_EPSILON) {
throw new Error("Terrain source contains an invalid native normal.");
}
return normal.normalize().toArray();
}
function triangleAreaSquared(positions, a, b, c) {
const ax = positions[a * 3];
const ay = positions[a * 3 + 1];
const az = positions[a * 3 + 2];
const abx = positions[b * 3] - ax;
const aby = positions[b * 3 + 1] - ay;
const abz = positions[b * 3 + 2] - az;
const acx = positions[c * 3] - ax;
const acy = positions[c * 3 + 1] - ay;
const acz = positions[c * 3 + 2] - az;
const nx = aby * acz - abz * acy;
const ny = abz * acx - abx * acz;
const nz = abx * acy - aby * acx;
return nx * nx + ny * ny + nz * nz;
}
function faceNormalDot(positions, normals, a, b, c) {
const ax = positions[a * 3];
const ay = positions[a * 3 + 1];
const az = positions[a * 3 + 2];
const abx = positions[b * 3] - ax;
const aby = positions[b * 3 + 1] - ay;
const abz = positions[b * 3 + 2] - az;
const acx = positions[c * 3] - ax;
const acy = positions[c * 3 + 1] - ay;
const acz = positions[c * 3 + 2] - az;
let nx = aby * acz - abz * acy;
let ny = abz * acx - abx * acz;
let nz = abx * acy - aby * acx;
const length = Math.hypot(nx, ny, nz);
if (length < NORMAL_EPSILON) return null;
nx /= length;
ny /= length;
nz /= length;
const sx = normals[a * 3] + normals[b * 3] + normals[c * 3];
const sy = normals[a * 3 + 1] + normals[b * 3 + 1] + normals[c * 3 + 1];
const sz = normals[a * 3 + 2] + normals[b * 3 + 2] + normals[c * 3 + 2];
const normalLength = Math.hypot(sx, sy, sz);
if (normalLength < NORMAL_EPSILON) return null;
return (nx * sx + ny * sy + nz * sz) / normalLength;
}
/**
* Converts the extracted native grid arrays without changing their vertex grid,
* texture coordinates, or source per-vertex lighting normals. Winding is reversed
* once because the Runewaker-to-Three bridge reflects Z.
*/
export function convertNativeTerrainChunks(chunks, sourceToThree, options = {}) {
if (!Array.isArray(chunks) || chunks.length === 0) {
throw new Error("Native terrain conversion requires at least one chunk.");
}
const positionMatrix = sourceToThree.clone();
const normalMatrix = new Matrix3().getNormalMatrix(positionMatrix);
const convertedChunks = [];
const perChunkSummaries = [];
const edgeVertices = new Map();
const seamPositionToleranceMeters = Number(options.seamPositionToleranceMeters ?? 0.02);
let vertexOffset = 0;
let sourceNormalLengthMin = Number.POSITIVE_INFINITY;
let sourceNormalLengthMax = 0;
let sourceNormalLengthSum = 0;
let sourceNormalLengthCount = 0;
let faceAlignedCount = 0;
let faceOpposedCount = 0;
let faceDegenerateCount = 0;
let sourceTriangleSlots = 0;
let visibleSourceTriangles = 0;
let maskedSourceTriangles = 0;
let collisionTriangles = 0;
let excludedCollisionTriangles = 0;
let maxSeamPositionDeltaMeters = 0;
let maxSeamNormalDelta = 0;
let seamNormalPairs = 0;
let seamNormalPairsWithinOneChannelStep = 0;
const dimensions = new Set(chunks.map((chunk) => Number(chunk.dimension)));
if (dimensions.size !== 1) throw new Error("All native terrain chunks must have the same grid dimension.");
const dimension = [...dimensions][0];
if (!Number.isInteger(dimension) || dimension < 1) throw new Error("Terrain chunk dimension is invalid.");
const side = dimension + 1;
const expectedVertices = side * side;
if (expectedVertices > 0x10000) {
throw new Error("A native terrain chunk exceeds its 16-bit source index contract.");
}
// Each source chunk uses 16-bit local indices. The combined GLB uses global
// offsets, which need 32 bits when more than fifteen 65×65 grids are present.
const IndexArray = expectedVertices * chunks.length > 0x10000 ? Uint32Array : Uint16Array;
for (const chunk of chunks) {
const chunkDimension = Number(chunk.dimension);
const vertexCount = (chunkDimension + 1) ** 2;
checkArrayLength(chunk.positions, vertexCount * 3, `${chunk.id} positions`);
checkArrayLength(chunk.uvs, vertexCount * 2, `${chunk.id} UVs`);
checkArrayLength(chunk.normals, vertexCount * 3, `${chunk.id} native normals`);
if (!Number.isInteger(Number(chunk.coordinate?.x)) || !Number.isInteger(Number(chunk.coordinate?.z))) {
throw new Error(`${chunk.id} requires integer chunk coordinates.`);
}
if (chunk.indices.length % 3 !== 0) throw new Error(`${chunk.id} indices are not triangular.`);
const triangleCount = chunk.indices.length / 3;
checkArrayLength(chunk.triangleVisibilityFlags, triangleCount, `${chunk.id} triangle visibility`);
const convertedPositions = new Float32Array(vertexCount * 3);
const convertedNormals = new Float32Array(vertexCount * 3);
const collisionIndices = [];
const visualIndices = new IndexArray(chunk.indices.length);
let chunkVisibleTriangles = 0;
let chunkMaskedTriangles = 0;
let chunkAlignedFaces = 0;
let chunkOpposedFaces = 0;
let chunkDegenerateFaces = 0;
for (let vertex = 0; vertex < vertexCount; vertex += 1) {
const positionOffset = vertex * 3;
const uvOffset = vertex * 2;
const sourcePosition = [
Number(chunk.positions[positionOffset]),
Number(chunk.positions[positionOffset + 1]),
Number(chunk.positions[positionOffset + 2]),
];
const sourceNormal = [
Number(chunk.normals[positionOffset]),
Number(chunk.normals[positionOffset + 1]),
Number(chunk.normals[positionOffset + 2]),
];
const outputPosition = transformPosition(sourcePosition, positionMatrix);
const outputNormal = transformNormal(sourceNormal, normalMatrix);
convertedPositions.set(outputPosition, positionOffset);
convertedNormals.set(outputNormal, positionOffset);
const sourceLength = Math.hypot(...sourceNormal);
sourceNormalLengthMin = Math.min(sourceNormalLengthMin, sourceLength);
sourceNormalLengthMax = Math.max(sourceNormalLengthMax, sourceLength);
sourceNormalLengthSum += sourceLength;
sourceNormalLengthCount += 1;
const localX = vertex % side;
const localZ = Math.floor(vertex / side);
const globalGridKey = `${Number(chunk.coordinate.x) * dimension + localX},${Number(chunk.coordinate.z) * dimension + localZ}`;
const previous = edgeVertices.get(globalGridKey);
if (previous) {
const dx = previous.position[0] - outputPosition[0];
const dy = previous.position[1] - outputPosition[1];
const dz = previous.position[2] - outputPosition[2];
const positionDelta = Math.hypot(dx, dy, dz);
maxSeamPositionDeltaMeters = Math.max(maxSeamPositionDeltaMeters, positionDelta);
if (positionDelta > seamPositionToleranceMeters) {
throw new Error(`${chunk.id} disagrees with a neighbor at terrain grid vertex ${globalGridKey} by ${positionDelta}m.`);
}
const normalDelta = Math.hypot(
previous.normal[0] - outputNormal[0],
previous.normal[1] - outputNormal[1],
previous.normal[2] - outputNormal[2],
);
seamNormalPairs += 1;
maxSeamNormalDelta = Math.max(maxSeamNormalDelta, normalDelta);
if (normalDelta <= (2 * Math.sqrt(3) / 255) + 1e-6) seamNormalPairsWithinOneChannelStep += 1;
} else {
edgeVertices.set(globalGridKey, { position: outputPosition, normal: outputNormal });
}
if (!Number.isFinite(chunk.uvs[uvOffset]) || !Number.isFinite(chunk.uvs[uvOffset + 1])) {
throw new Error(`${chunk.id} contains non-finite texture coordinates.`);
}
}
for (let triangle = 0; triangle < triangleCount; triangle += 1) {
const offset = triangle * 3;
const a = Number(chunk.indices[offset]);
const b = Number(chunk.indices[offset + 1]);
const c = Number(chunk.indices[offset + 2]);
if ([a, b, c].some((index) => !Number.isInteger(index) || index < 0 || index >= vertexCount)) {
throw new Error(`${chunk.id} triangle ${triangle} has an invalid source vertex index.`);
}
const visible = Number(chunk.triangleVisibilityFlags[triangle]);
if (visible !== 0 && visible !== 1) throw new Error(`${chunk.id} triangle ${triangle} has invalid visibility flag ${visible}.`);
const outA = vertexOffset + a;
const outB = vertexOffset + b;
const outC = vertexOffset + c;
// Keep the original masked degenerate slots, including [0,0,0], in visual geometry.
visualIndices[offset] = outA;
visualIndices[offset + 1] = outC;
visualIndices[offset + 2] = outB;
sourceTriangleSlots += 1;
if (visible === 0) {
chunkMaskedTriangles += 1;
maskedSourceTriangles += 1;
excludedCollisionTriangles += 1;
continue;
}
chunkVisibleTriangles += 1;
visibleSourceTriangles += 1;
if (a === b || b === c || c === a
|| triangleAreaSquared(convertedPositions, a, b, c) < 1e-24) {
excludedCollisionTriangles += 1;
chunkDegenerateFaces += 1;
continue;
}
collisionIndices.push(outA, outC, outB);
collisionTriangles += 1;
const dot = faceNormalDot(convertedPositions, convertedNormals, a, c, b);
if (dot === null) {
faceDegenerateCount += 1;
chunkDegenerateFaces += 1;
} else if (dot >= 0) {
faceAlignedCount += 1;
chunkAlignedFaces += 1;
} else {
faceOpposedCount += 1;
chunkOpposedFaces += 1;
}
}
convertedChunks.push({
...chunk,
vertexOffset,
positions: convertedPositions,
normals: convertedNormals,
uvs: new Float32Array(chunk.uvs),
visualIndices,
collisionIndices: IndexArray.from(collisionIndices),
});
perChunkSummaries.push({
id: chunk.id,
coordinate: chunk.coordinate,
vertexCount,
sourceTriangleSlots: triangleCount,
visibleSourceTriangles: chunkVisibleTriangles,
maskedSourceTriangles: chunkMaskedTriangles,
collisionTriangles: collisionIndices.length / 3,
excludedCollisionTriangles: chunkMaskedTriangles + chunkDegenerateFaces,
sourceTriangleVisibilitySha256: chunk.triangleVisibilitySha256 ?? null,
sourceLayers: chunk.layers?.map((layer) => ({
layerId: layer.layerId,
tileScale: layer.tileScale,
sourceDDS: layer.sourceDDS,
sourceClientPath: layer.sourceClientPath,
})) ?? [],
sourceWeights: chunk.splatMaps?.map((weight) => ({
mapIndex: weight.mapIndex,
sourceDDS: weight.splatDDS,
linearPngFile: weight.linearPngFile,
channelLayerIds: weight.channelLayerIds,
})) ?? [],
nativeNormalWinding: {
alignedTriangles: chunkAlignedFaces,
opposedTriangles: chunkOpposedFaces,
degenerateTriangles: chunkDegenerateFaces,
},
});
vertexOffset += expectedVertices;
}
return {
chunks: convertedChunks,
vertexCount: vertexOffset,
report: {
chunkCount: chunks.length,
verticesPerChunk: expectedVertices,
sourceVertices: vertexOffset,
sourceTriangleSlots,
visibleSourceTriangles,
maskedSourceTriangles,
collisionTriangles,
excludedCollisionTriangles,
positionWelding: {
performed: false,
reason: "Native normal records can differ at chunk boundaries; retain independent source vertices and normals.",
sharedGridVertexPairs: seamNormalPairs,
seamPositionToleranceMeters,
maxSharedPositionDeltaMeters: maxSeamPositionDeltaMeters,
maxSharedNativeNormalDelta: maxSeamNormalDelta,
normalPairsWithinOneEightBitChannelStep: seamNormalPairsWithinOneChannelStep,
normalPairCount: seamNormalPairs,
},
sourceNativeNormals: {
minLength: sourceNormalLengthMin,
meanLength: sourceNormalLengthSum / Math.max(1, sourceNormalLengthCount),
maxLength: sourceNormalLengthMax,
transform: "inverse-transpose of primary descriptor TRS and reflected-Z meter bridge; normalized",
},
windingValidation: {
rule: "swap triangle indices 1 and 2 after reflected-Z coordinate bridge",
trianglesWithNativeNormalAgreement: faceAlignedCount,
trianglesAgainstNativeNormal: faceOpposedCount,
degenerateTriangles: faceDegenerateCount,
},
chunks: perChunkSummaries,
},
};
}
@@ -0,0 +1,106 @@
import test from "node:test";
import assert from "node:assert/strict";
import { Matrix3, Matrix4, Vector3 } from "three";
import { descriptorSourceMatrix } from "../lib/environment-assembly.mjs";
import { convertNativeTerrainChunks, sourceTerrainToThreeMatrix } from "./terrain-geometry.mjs";
function makeChunk(id, coordinateX, xOffset, normalX = 0) {
const vertices = [
[xOffset, 0, 0], [xOffset + 1, 0, 0],
[xOffset, 0, 1], [xOffset + 1, 0, 1],
];
const normals = Array.from({ length: 4 }, () => [normalX, 1, 0]).flat();
return {
id,
coordinate: { x: coordinateX, z: 0 },
dimension: 1,
positions: Float32Array.from(vertices.flat()),
normals: Float32Array.from(normals),
uvs: Float32Array.from([0, 0, 1, 0, 0, 1, 1, 1]),
// Source topology is upward facing in left-handed Runewaker coordinates.
indices: Uint16Array.from([0, 2, 3, 0, 0, 0]),
triangleVisibilityFlags: Uint8Array.from([1, 0]),
visibleSourceTriangles: 1,
maskedSourceTriangles: 1,
layers: [{ layerId: 4, tileScale: 12, sourceDDS: "textures/tile.dds" }],
splatMaps: [{ mapIndex: 0, splatDDS: "weights/a.dds", linearPngFile: "weights/a.png", channelLayerIds: [4] }],
material: { variantNames: [`${id}-layer-4`], weightVariantNames: [`${id}-weight-0`] },
};
}
test("native terrain conversion applies primary inverse and reflected meter bridge", () => {
const primary = {
translation: [10, 5, -2],
rotation: [0, Math.sin(Math.PI / 8), 0, Math.cos(Math.PI / 8)],
scale: [2, 1, 3],
};
const transform = sourceTerrainToThreeMatrix(primary, 0.1);
const sourceWorldPoint = new Vector3(10, 5, -2).applyMatrix4(descriptorSourceMatrix(primary));
const expected = new Vector3(1, 0.5, 0.2);
const actual = sourceWorldPoint.clone().applyMatrix4(transform);
assert.ok(actual.distanceTo(expected) < 1e-6);
const normalMatrix = new Matrix3().getNormalMatrix(transform);
const normal = new Vector3(1, 1, 0).applyMatrix3(normalMatrix).normalize();
assert.ok(normal.x > 0 && normal.y > 0);
});
test("visual indices preserve each source slot with reflected-Z winding; collision omits masked faces", () => {
const transform = new Matrix4().makeScale(0.1, 0.1, -0.1);
const chunks = [makeChunk("left", 0, 0), makeChunk("right", 1, 1, 0.01)];
const converted = convertNativeTerrainChunks(chunks, transform);
assert.equal(converted.vertexCount, 8, "chunk grid vertices remain distinct at borders to retain native normals");
assert.deepEqual([...converted.chunks[0].visualIndices], [0, 3, 2, 0, 0, 0]);
assert.deepEqual([...converted.chunks[1].visualIndices], [4, 7, 6, 4, 4, 4]);
assert.deepEqual([...converted.chunks[0].collisionIndices], [0, 3, 2]);
assert.deepEqual([...converted.chunks[1].collisionIndices], [4, 7, 6]);
assert.deepEqual([...converted.chunks[0].uvs], [0, 0, 1, 0, 0, 1, 1, 1]);
assert.equal(converted.report.sourceTriangleSlots, 4);
assert.equal(converted.report.visibleSourceTriangles, 2);
assert.equal(converted.report.maskedSourceTriangles, 2);
assert.equal(converted.report.collisionTriangles, 2);
assert.equal(converted.report.positionWelding.performed, false);
assert.equal(converted.report.positionWelding.sharedGridVertexPairs, 2);
assert.ok(converted.report.positionWelding.maxSharedNativeNormalDelta > 0);
assert.equal(converted.report.windingValidation.trianglesAgainstNativeNormal, 0);
assert.equal(converted.report.windingValidation.trianglesWithNativeNormalAgreement, 2);
});
test("chunk borders that disagree in transformed position fail instead of silently welding", () => {
const left = makeChunk("left", 0, 0);
const right = makeChunk("right", 1, 1);
right.positions[0] += 1;
assert.throws(
() => convertNativeTerrainChunks([left, right], new Matrix4().makeScale(0.1, 0.1, -0.1)),
/disagrees with a neighbor at terrain grid vertex/,
);
});
test("combined native grids retain indices beyond 65535 without wrapping or dropping masked slots", () => {
const chunks = Array.from({ length: 16 }, (_, x) => {
const dimension = 64;
const positions = [];
const normals = [];
const uvs = [];
for (let z = 0; z <= dimension; z++) for (let column = 0; column <= dimension; column++) {
positions.push(x * dimension + column, 0, z);
normals.push(0, 1, 0);
uvs.push(column / dimension, z / dimension);
}
return {
...makeChunk(`chunk-${x}`, x, x * dimension), dimension,
positions: Float32Array.from(positions), normals: Float32Array.from(normals),
uvs: Float32Array.from(uvs), indices: Uint16Array.from([0, 4160, 4224, 0, 0, 0]),
};
});
const converted = convertNativeTerrainChunks(chunks, new Matrix4().makeScale(0.1, 0.1, -0.1));
const last = converted.chunks.at(-1);
assert.equal(converted.vertexCount, 67600);
assert.ok(last.visualIndices instanceof Uint32Array);
assert.ok(last.collisionIndices instanceof Uint32Array);
assert.deepEqual([...last.visualIndices], [63375, 67599, 67535, 63375, 63375, 63375]);
assert.deepEqual([...last.collisionIndices], [63375, 67599, 67535]);
assert.equal(converted.report.maskedSourceTriangles, 16);
assert.equal(converted.report.collisionTriangles, 16);
});