Files
2026-10-06 22:25:34 -04:00

342 lines
15 KiB
JavaScript
Raw Permalink 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";
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,
};
}