Files
healer-man/scripts/runewaker-pipeline/sand-vortex/extract-native-terrain.mjs
T
2026-10-06 22:25:34 -04:00

746 lines
34 KiB
JavaScript
Raw Blame History

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