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