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

177 lines
7.5 KiB
JavaScript

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