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(); }