import assert from "node:assert/strict"; import test from "node:test"; import { decodeNativeWaterPayload } from "./native-water-payload.mjs"; function putI32(value) { const out = Buffer.alloc(4); out.writeInt32LE(value); return out; } function putU32(value) { const out = Buffer.alloc(4); out.writeUInt32LE(value >>> 0); return out; } function putF32(value) { const out = Buffer.alloc(4); out.writeFloatLE(value); return out; } function putCString(value) { const bytes = Buffer.from(`${value}\0`, "utf8"); return Buffer.concat([putI32(bytes.length), bytes]); } function guidBytes(fill) { return Buffer.alloc(16, fill); } function objectBytes(properties = [], version = 2) { const prefix = [guidBytes(0x11), putI32(version), putCString("")]; if (version >= 2) { prefix.push(putI32(properties.length)); for (const property of properties) { prefix.push(putCString(property.name), putI32(property.type)); if (property.type === 2) prefix.push(putI32(property.value)); else if (property.type === 8) prefix.push(putF32(property.value)); else if (property.type === 12) prefix.push(putCString(property.value)); else throw new Error(`Fixture does not implement property type ${property.type}.`); } } return Buffer.concat(prefix); } function parcelHeader({ origin = [0, 0, 0], spacing = 1, overlap = 0, width = 3, height = 2 }) { return Buffer.concat([ ...origin.map(putF32), putF32(spacing), putI32(overlap), putI32(width), putI32(height), ]); } function heightParcel({ origin = [0, 0, 0], spacing = 1, overlap = 0, width = 3, height = 2, values = [100, 100, 100, 100, 100, 100] } = {}) { const quantized = Buffer.alloc(values.length * 2); values.forEach((value, index) => quantized.writeUInt16LE(value, index * 2)); return Buffer.concat([ parcelHeader({ origin, spacing, overlap, width, height }), putF32(0), putF32(10), quantized, ]); } function colorParcel({ origin = [0, 0, 0], spacing = 1, overlap = 0, width = 3, height = 2, values = [0xff112233, 0xff445566, 0xff778899, 0xffaabbcc, 0xffddeeff, 0xff010203] } = {}) { const data = Buffer.alloc(values.length * 4); values.forEach((value, index) => data.writeUInt32LE(value >>> 0, index * 4)); return Buffer.concat([parcelHeader({ origin, spacing, overlap, width, height }), data]); } function flagsParcel({ origin = [0, 0, 0], spacing = 1, overlap = 0, width = 3, height = 2, values = [0xff, 0xdf, 0x9f, 0xbf, 0x80, 0x00] } = {}) { return Buffer.concat([parcelHeader({ origin, spacing, overlap, width, height }), Buffer.from(values)]); } function layerBytes({ version = 3, bodyFill = 0x22, height = heightParcel(), color = colorParcel(), flags = flagsParcel(), translation = [0, 0, 0] } = {}) { const prefix = [guidBytes(0x33), putI32(version), guidBytes(bodyFill)]; if (version >= 3) { prefix.push( guidBytes(0x44), putI32(2), objectBytes(), ...[1, 1, 1].map(putF32), ...[0, 0, 0, 1].map(putF32), ...translation.map(putF32), putU32(0), putU32(0), putI32(0), ); } if (version === 1) return Buffer.concat([...prefix, height, flags]); return Buffer.concat([...prefix, height, color, flags]); } function descriptorBytes({ bodyFill = 0x22, version = 5, properties = [] } = {}) { const prefix = [guidBytes(0x55), putI32(version)]; if (version >= 2) prefix.push(objectBytes(properties)); prefix.push(guidBytes(bodyFill)); if (version >= 3) prefix.push(putI32(0)); prefix.push(putI32(4)); // Preserve undeclared source value, do not infer its effect. if (version <= 4) { prefix.push(...Array.from({ length: 6 }, () => putF32(0)), putU32(0), putI32(0), putI32(0), putI32(0)); prefix.push(putCString(""), putCString(""), putCString("")); } if (version === 4 || version === 5) prefix.push(putU32(0xff000000), putF32(0), putF32(0), putF32(1)); return Buffer.concat(prefix); } test("native water v3 payload decodes exact dimensions, vertex channels, transform and GUID descriptor", () => { const payload = layerBytes({ translation: [10, 20, 30] }); const descriptor = descriptorBytes({ properties: [{ name: "waterDesc:full:bump name", type: 12, value: "texture\\water\\wave8" }] }); const decoded = decodeNativeWaterPayload(payload, { layerCount: 1, descriptors: [{ key: "body-row", payload: descriptor }], }); const [layer] = decoded.layers; assert.equal(decoded.consumedBytes, payload.length); assert.equal(decoded.trailingBytes, 0); assert.equal(layer.version, 3); assert.deepEqual(layer.transform.translation, [10, 20, 30]); for (const value of layer.height.values) assert.ok(Math.abs(value - (100 / 60000 * 10)) < 1e-7); assert.equal(layer.color.values[0], 0xff112233); assert.equal(layer.flags.values[0], 0xff); assert.equal(layer.descriptorJoin.status, "matched"); assert.equal(layer.descriptorJoin.descriptors[0].effectType, 4); assert.equal(layer.descriptorJoin.descriptors[0].baseObject.properties[0].value, "texture\\water\\wave8"); }); test("legacy v2 origin migration uses color parcel origin as transform and resets parcel origins", () => { const payload = layerBytes({ version: 2, bodyFill: 0x23, height: heightParcel({ origin: [3, 7, 9], overlap: 1, width: 5, height: 4, values: new Array(20).fill(12) }), color: colorParcel({ origin: [50, 60, 70], width: 3, height: 2 }), flags: flagsParcel({ origin: [8, 9, 10], width: 3, height: 2 }), }); const decoded = decodeNativeWaterPayload(payload, { layerCount: 1 }); const [layer] = decoded.layers; assert.deepEqual(layer.transform.translation, [50, 60, 70]); assert.deepEqual(layer.height.origin, [-1, 0, -1]); assert.deepEqual(layer.color.origin, [0, 0, 0]); assert.deepEqual(layer.flags.origin, [0, 0, 0]); }); test("legacy v1 layer is decoded but explicitly unbuildable because color bytes are absent", () => { const payload = layerBytes({ version: 1, bodyFill: 0x24, height: heightParcel(), flags: flagsParcel({ origin: [40, 50, 60] }), }); const decoded = decodeNativeWaterPayload(payload, { layerCount: 1 }); const [layer] = decoded.layers; assert.equal(layer.color.present, false); assert.equal(layer.unbuildableReason, "native v1 water color is not serialized; refusing guessed vertex RGB"); assert.deepEqual(layer.transform.translation, [40, 50, 60]); }); test("descriptor matching is exact and refuses missing or duplicate GUID matches", () => { const payload = layerBytes(); const wrongDescriptor = descriptorBytes({ bodyFill: 0x21 }); const missing = decodeNativeWaterPayload(payload, { layerCount: 1, descriptors: [{ key: "wrong", payload: wrongDescriptor }] }); assert.equal(missing.layers[0].descriptorJoin.status, "missing"); const ambiguous = decodeNativeWaterPayload(payload, { layerCount: 1, descriptors: [ { key: "first", payload: descriptorBytes() }, { key: "second", payload: descriptorBytes() }, ], }); assert.equal(ambiguous.layers[0].descriptorJoin.status, "ambiguous"); assert.deepEqual(ambiguous.layers[0].descriptorJoin.descriptorKeys, ["first", "second"]); }); test("decoder requires exact payload and descriptor consumption", () => { assert.throws(() => decodeNativeWaterPayload(Buffer.concat([layerBytes(), Buffer.from([0])]), { layerCount: 1 }), /trailing/); assert.throws(() => decodeNativeWaterPayload(layerBytes().subarray(0, 100), { layerCount: 1 }), /truncated/); assert.throws(() => decodeNativeWaterPayload(layerBytes(), { layerCount: 1, descriptors: [{ key: "descriptor", payload: Buffer.concat([descriptorBytes(), Buffer.from([1])]) }], }), /trailing/); });