import { createHash } from "node:crypto"; const MAX_PARCEL_SAMPLES = 8_000_000; const MAX_OBJECT_PROPERTIES = 4096; function sha256(bytes) { return createHash("sha256").update(bytes).digest("hex"); } class Reader { constructor(buffer, label) { if (!Buffer.isBuffer(buffer)) throw new TypeError(`${label} must be a Buffer.`); this.buffer = buffer; this.offset = 0; this.label = label; } take(length, name) { if (!Number.isSafeInteger(length) || length < 0 || this.offset + length > this.buffer.length) { throw new Error(`${this.label} ${name} is truncated at ${this.offset}+${length}/${this.buffer.length}.`); } const value = this.buffer.subarray(this.offset, this.offset + length); this.offset += length; return value; } bytes(length, name) { return this.take(length, name); } i8(name) { return this.take(1, name).readInt8(0); } u8(name) { return this.take(1, name).readUInt8(0); } i16(name) { return this.take(2, name).readInt16LE(0); } u16(name) { return this.take(2, name).readUInt16LE(0); } i32(name) { return this.take(4, name).readInt32LE(0); } u32(name) { return this.take(4, name).readUInt32LE(0); } i64(name) { return this.take(8, name).readBigInt64LE(0).toString(); } u64(name) { return this.take(8, name).readBigUInt64LE(0).toString(); } f32(name) { const value = this.take(4, name).readFloatLE(0); if (!Number.isFinite(value)) throw new Error(`${this.label} ${name} is non-finite.`); return value; } cString(name) { const length = this.i32(`${name} length`); if (length < 0 || length > 1_000_000) throw new Error(`${this.label} ${name} length ${length} is invalid.`); return this.take(length, name).toString("utf8").replace(/\0+$/, ""); } finish(name = this.label) { if (this.offset !== this.buffer.length) { throw new Error(`${name} consumed ${this.offset} of ${this.buffer.length} bytes (${this.buffer.length - this.offset} trailing).`); } } } function parseObject(reader, depth = 0) { if (depth > 8) throw new Error("Rune object property nesting exceeds the supported depth of 8."); const classGuid = reader.bytes(16, "object class GUID").toString("hex"); const version = reader.i32("object version"); if (version !== 1 && version !== 2) throw new Error(`Unsupported Rune object version ${version}.`); const name = reader.cString("object name"); const properties = []; if (version >= 2) { const count = reader.i32("object property count"); if (count < 0 || count > MAX_OBJECT_PROPERTIES) throw new Error(`Invalid Rune object property count ${count}.`); for (let index = 0; index < count; index += 1) { const propertyName = reader.cString(`property[${index}] name`); const type = reader.i32(`property[${index}] type`); let value; switch (type) { case 0: value = reader.i8("int8 property"); break; case 1: value = reader.i16("int16 property"); break; case 2: value = reader.i32("int32 property"); break; case 3: value = reader.i64("int64 property"); break; case 4: value = reader.u8("uint8 property"); break; case 5: value = reader.u16("uint16 property"); break; case 6: value = reader.u32("uint32 property"); break; case 7: value = reader.u64("uint64 property"); break; case 8: value = reader.f32("float32 property"); break; case 9: value = null; break; case 10: value = Array.from({ length: 4 }, (_, component) => reader.f32(`color property[${component}]`)); break; case 11: value = parseObject(reader, depth + 1); break; case 12: value = reader.cString("string property"); break; default: throw new Error(`Unknown Rune object property type ${type} at ${propertyName}.`); } properties.push({ name: propertyName, type, value }); } } return { classGuid, version, name, properties }; } function parseParcelHeader(reader, label) { const origin = Array.from({ length: 3 }, (_, index) => reader.f32(`${label} origin[${index}]`)); const spacing = reader.f32(`${label} spacing`); const overlap = reader.i32(`${label} overlap`); const width = reader.i32(`${label} width`); const height = reader.i32(`${label} height`); const sampleCount = width * height; if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1 || overlap < 0 || overlap * 2 >= Math.min(width, height) || !Number.isSafeInteger(sampleCount) || sampleCount > MAX_PARCEL_SAMPLES || spacing <= 0) { throw new Error(`${label} parcel dimensions/origin/spacing are invalid (${width}x${height}, overlap ${overlap}, spacing ${spacing}).`); } return { origin, spacing, overlap, width, height, sampleCount }; } function parseHeightParcel(reader) { const header = parseParcelHeader(reader, "water height"); const minimum = reader.f32("water height minimum"); const maximum = reader.f32("water height maximum"); if (minimum > maximum) throw new Error("Water height minimum exceeds maximum."); const rawQuantized = new Uint16Array(header.sampleCount); const values = new Float32Array(header.sampleCount); // CRuWorld's REAL is float32; retain the engine's per-operation rounding in // Mojo_SerializeFrom rather than only rounding after the complete equation. const range = Math.fround(maximum - minimum); for (let index = 0; index < header.sampleCount; index += 1) { const quantized = reader.u16(`water height sample[${index}]`); rawQuantized[index] = quantized; const normalized = Math.fround(quantized / 60000); const scaled = Math.fround(normalized * range); values[index] = Math.fround(scaled + minimum); } return { ...header, minimum, maximum, quantizationStep: range / 60000, rawQuantized, values }; } function parseGenericParcel(reader, label, type) { const header = parseParcelHeader(reader, `water ${label}`); if (type === "color") { const raw = reader.bytes(header.sampleCount * 4, `water ${label} values`); const values = new Uint32Array(header.sampleCount); for (let index = 0; index < values.length; index += 1) values[index] = raw.readUInt32LE(index * 4); return { ...header, values }; } if (type === "flags") { return { ...header, values: Uint8Array.from(reader.bytes(header.sampleCount, `water ${label} values`)) }; } throw new Error(`Unsupported generic water parcel type ${type}.`); } function validateRenderableEntity(reader) { const entityClassGuid = reader.bytes(16, "renderable entity class GUID").toString("hex"); const version = reader.i32("renderable entity version"); if (version < 1 || version > 2) throw new Error(`Unsupported CRuEntity version ${version}.`); const baseObject = parseObject(reader); const scale = Array.from({ length: 3 }, (_, index) => reader.f32(`entity scale[${index}]`)); const rotation = Array.from({ length: 4 }, (_, index) => reader.f32(`entity rotation[${index}]`)); const translation = Array.from({ length: 3 }, (_, index) => reader.f32(`entity translation[${index}]`)); const optionFlags0 = reader.u32("entity option flags 0"); const optionFlags1 = version >= 2 ? reader.u32("entity option flags 1") : null; const collisionDataExists = reader.i32("entity collision data flag"); if (collisionDataExists !== 0) { throw new Error("Water entity carries embedded collision data; its payload format is not implemented, so exact layer decoding is unsafe."); } if (scale.some((value) => value === 0)) throw new Error("Water entity transform has a zero scale component."); const rotationLength = Math.hypot(...rotation); if (rotationLength < 1e-12) throw new Error("Water entity rotation quaternion is zero."); return { entity: { classGuid: entityClassGuid, version, baseObject }, transform: { scale, rotation, translation, optionFlags0, optionFlags1, collisionDataExists }, }; } function parseLayer(reader, layerIndex) { const start = reader.offset; const classGuid = reader.bytes(16, `water layer ${layerIndex} class GUID`).toString("hex"); const version = reader.i32(`water layer ${layerIndex} version`); if (version < 1 || version > 3) throw new Error(`Unsupported water layer version ${version} at layer ${layerIndex}.`); const waterBodyGuid = reader.bytes(16, `water layer ${layerIndex} body GUID`).toString("hex"); let entity = null; let transform = { scale: [1, 1, 1], rotation: [0, 0, 0, 1], translation: [0, 0, 0], legacy: version < 3 }; const objectOffset = reader.offset; if (version >= 3) { const parsedEntity = validateRenderableEntity(reader); entity = parsedEntity.entity; transform = parsedEntity.transform; } const height = parseHeightParcel(reader); const color = version === 1 ? null : parseGenericParcel(reader, "color", "color"); const flags = parseGenericParcel(reader, "flags", "flags"); if (version < 3) { // Native loader migration in CRuWorld_Water::SerializeFrom: color origin is // promoted to entity translation, then all old parcel origins are reset. const legacyTranslation = color?.origin ?? flags.origin; transform = { scale: [1, 1, 1], rotation: [0, 0, 0, 1], translation: [...legacyTranslation], legacy: true, migration: "v1/v2 parcel origin becomes entity translation; height origin resets to negative overlap spacing; color/flags origins reset to zero", }; height.origin = [-height.overlap * height.spacing, 0, -height.overlap * height.spacing]; if (color) color.origin = [0, 0, 0]; flags.origin = [0, 0, 0]; } if (!color) { // v1 allocates an uninitialized color parcel from dimensions only. The // bytes do not exist in the WDB and cannot be faithfully reconstructed. return { classGuid, version, waterBodyGuid, entity, transform, height, color: { present: false, reason: "v1 native loader allocates but does not initialize the water color parcel" }, flags, sourceByteRange: { start, end: reader.offset }, unbuildableReason: "native v1 water color is not serialized; refusing guessed vertex RGB", entityByteOffset: objectOffset, }; } const effective = (parcel) => [parcel.width - parcel.overlap * 2, parcel.height - parcel.overlap * 2]; if (effective(height).join("x") !== effective(color).join("x") || effective(height).join("x") !== effective(flags).join("x")) { throw new Error(`Water layer ${layerIndex} height/color/flags effective grids do not align.`); } return { classGuid, version, waterBodyGuid, entity, transform, height, color: { present: true, ...color }, flags, sourceByteRange: { start, end: reader.offset }, unbuildableReason: null, effectiveGrid: { width: effective(height)[0], height: effective(height)[1] }, nativeTopology: { visibilityFlag: "0x80", visibleTest: "for each 2-cell quad pair, test flag bytes at every source index referenced by the selected 12 native face indices; source helper reads this as a direct flat flags index", alphaBits: "flags & 0x3f; native converts to byte alpha by trunc((alphaBits/63)*255)", evenRowFaces: [0, 3, 4, 0, 4, 1, 1, 4, 2, 2, 4, 5], oddRowFaces: [0, 3, 1, 1, 3, 4, 1, 5, 2, 1, 4, 5], vertexFormat: "POSITION | DIFFUSE; no serialized NORMAL or TEXCOORD streams", }, }; } function parseDescriptorRecord({ key, payload }) { if (!Buffer.isBuffer(payload)) throw new TypeError(`Descriptor ${key} payload must be a Buffer.`); const reader = new Reader(payload, `water descriptor ${key}`); const classGuid = reader.bytes(16, "descriptor class GUID").toString("hex"); const version = reader.i32("descriptor version"); if (version < 1 || version > 5) throw new Error(`Unsupported water descriptor version ${version} at record ${key}.`); const baseObject = version >= 2 ? parseObject(reader) : null; const guid = reader.bytes(16, "water descriptor body GUID").toString("hex"); const bodyType = version >= 3 ? reader.i32("water descriptor body type") : null; const effectType = reader.i32("water descriptor effect type"); let legacy = null; let submerged = null; if (version <= 4) { const legacyFloatParameters = Array.from({ length: 6 }, (_, index) => reader.f32(`legacy water parameter[${index}]`)); const legacyFlags = reader.u32("legacy water flags"); const baseFrames = reader.i32("base-map frame count"); const normalFrames = reader.i32("normal-map frame count"); const cubeFrames = reader.i32("cube-map frame count"); const names = ["base", "normal", "cube"].map((name) => reader.cString(`${name} map name`)); legacy = { legacyFloatParameters, legacyFlags, baseFrames, normalFrames, cubeFrames, names }; if (version === 4) { submerged = { argb: reader.u32("submerged ARGB color"), constantFog: reader.f32("submerged constant fog"), fogStart: reader.f32("submerged fog start"), fogEnd: reader.f32("submerged fog end"), }; } } else { submerged = { argb: reader.u32("submerged ARGB color"), constantFog: reader.f32("submerged constant fog"), fogStart: reader.f32("submerged fog start"), fogEnd: reader.f32("submerged fog end"), }; } reader.finish(`water descriptor ${key}`); return { key: String(key), sha256: sha256(payload), classGuid, version, baseObject, guid, bodyType, effectType, legacy, submerged, sourcePayloadBytes: payload.length, }; } /** * Decode the serialized water objects following a native terrain chunk's * water-layer count. Supported native layer versions are v1–v3. Version 1 is * decoded but cannot produce faithful geometry because its native color parcel * has no serialized data. Descriptor rows are joined only by their 16-byte body * GUID, never by a display name or texture basename. */ export function decodeNativeWaterPayload(payload, { layerCount, descriptors = [] } = {}) { if (!Buffer.isBuffer(payload)) throw new TypeError("Native water payload must be a Buffer."); if (!Number.isInteger(layerCount) || layerCount < 0 || layerCount > 1024) { throw new Error(`Invalid native water layer count ${layerCount}.`); } if (!Array.isArray(descriptors)) throw new TypeError("Water descriptors must be an array of {key, payload} records."); const descriptorRows = descriptors.map(parseDescriptorRecord); const descriptorByGuid = new Map(); for (const row of descriptorRows) { const rows = descriptorByGuid.get(row.guid) ?? []; rows.push(row); descriptorByGuid.set(row.guid, rows); } const reader = new Reader(payload, "native terrain water payload"); const layers = []; for (let index = 0; index < layerCount; index += 1) { const layer = parseLayer(reader, index); const matches = descriptorByGuid.get(layer.waterBodyGuid) ?? []; layers.push({ ...layer, descriptorJoin: { strategy: "exact serialized body GUID bytes, hex normalized lowercase", status: matches.length === 1 ? "matched" : matches.length === 0 ? "missing" : "ambiguous", descriptorKeys: matches.map((row) => row.key), descriptors: matches, }, }); } reader.finish("native terrain water payload"); return { schemaVersion: 1, layerCount, sourcePayloadBytes: payload.length, sourcePayloadSha256: sha256(payload), consumedBytes: reader.offset, trailingBytes: 0, descriptorRowCount: descriptorRows.length, layers, }; }