forked from phenom/healer-man
746 lines
34 KiB
JavaScript
746 lines
34 KiB
JavaScript
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();
|
||
}
|