Update Healer Man server

This commit is contained in:
phenom committed 2026-10-06 22:25:34 -04:00
1 parent 6114a2295f
commit 69809f7228
593 files changed
+903643 -40774

No files matched your search

@@ -0,0 +1,60 @@
import { Document, NodeIO } from "@gltf-transform/core";
import { Matrix4, Vector3 } from "three";
import path from "node:path";
import { mkdir } from "node:fs/promises";
/** Preserve world-space source triangles while bounding each physical GLB. */
export async function writeBoundedCollisionGlbs(sourceFile, outputDirectory, slug, {
maximumTriangles = 90_000,
sourceSha256,
} = {}) {
if (!Number.isSafeInteger(maximumTriangles) || maximumTriangles < 1) {
throw new Error("Collision chunk limit must be a positive integer.");
}
const io = new NodeIO();
const source = await io.read(sourceFile);
const scene = source.getRoot().getDefaultScene() ?? source.getRoot().listScenes()[0];
if (!scene) throw new Error("Native collision source has no scene.");
const positions = [];
scene.traverse((node) => {
const mesh = node.getMesh();
if (!mesh) return;
const matrix = new Matrix4().fromArray(node.getWorldMatrix());
const reverse = matrix.determinant() < 0;
for (const primitive of mesh.listPrimitives()) {
if (primitive.getMode() !== 4) throw new Error("Collision must contain triangle primitives.");
const position = primitive.getAttribute("POSITION");
const index = primitive.getIndices();
const count = index?.getCount() ?? position?.getCount() ?? 0;
if (!position || count % 3) throw new Error("Invalid collision triangle positions.");
for (let triangle = 0; triangle < count; triangle += 3) {
for (const corner of reverse ? [0, 2, 1] : [0, 1, 2]) {
const vertex = index ? index.getScalar(triangle + corner) : triangle + corner;
const point = new Vector3(...position.getElement(vertex, [])).applyMatrix4(matrix);
if (!point.toArray().every(Number.isFinite)) throw new Error("Non-finite collision coordinate.");
positions.push(...point.toArray());
}
}
}
});
if (!positions.length) throw new Error("Native collision source is empty.");
await mkdir(outputDirectory, { recursive: true });
const records = [];
for (let begin = 0; begin < positions.length; begin += maximumTriangles * 9) {
const document = new Document();
const buffer = document.createBuffer();
const targetScene = document.createScene("native-options-and-source-supports");
document.getRoot().setDefaultScene(targetScene);
const array = new Float32Array(positions.slice(begin, begin + maximumTriangles * 9));
const primitive = document.createPrimitive().setAttribute("POSITION", document.createAccessor("collision-positions", buffer).setType("VEC3").setArray(array));
targetScene.addChild(document.createNode("collision").setMesh(document.createMesh().addPrimitive(primitive)));
document.getRoot().setExtras({
collisionMode: "respect-options", sourceCandidateSha256: sourceSha256 ?? null,
triangleRange: [begin / 9, begin / 9 + array.length / 9],
});
const file = `${slug}-collision-${String(records.length).padStart(3, "0")}.glb`;
await io.write(path.join(outputDirectory, file), document);
records.push({ file, triangles: array.length / 9, nativeSerializedCollision: true, derivedSourceAnchorSupport: true });
}
return records;
}
@@ -0,0 +1,28 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Document, NodeIO } from "@gltf-transform/core";
import { writeBoundedCollisionGlbs } from "./bounded-collision-glb.mjs";
test("collision chunking preserves transformed triangles, reflected winding and hard caps", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "rom-bounded-collision-"));
try {
const io = new NodeIO(), document = new Document(), buffer = document.createBuffer();
const position = document.createAccessor("position", buffer).setType("VEC3").setArray(new Float32Array([0,0,0, 1,0,0, 0,0,1]));
const indices = document.createAccessor("indices", buffer).setType("SCALAR").setArray(new Uint16Array([0,1,2, 0,1,2, 0,1,2]));
const scene = document.createScene(); document.getRoot().setDefaultScene(scene);
scene.addChild(document.createNode().setTranslation([10,2,3]).setScale([-1,1,1]).setMesh(document.createMesh().addPrimitive(document.createPrimitive().setAttribute("POSITION", position).setIndices(indices))));
const input = path.join(directory, "source.glb"); await io.write(input, document);
const records = await writeBoundedCollisionGlbs(input, directory, "fixture", { maximumTriangles: 2 });
assert.deepEqual(records.map(row => row.triangles), [2,1]);
const arrays = [];
for (const row of records) arrays.push(...(await io.read(path.join(directory, row.file))).getRoot().listAccessors()[0].getArray());
assert.deepEqual(arrays, Array(3).fill([10,2,3, 10,2,4, 9,2,3]).flat());
} finally {
assert.ok(path.resolve(directory).startsWith(path.resolve(os.tmpdir()) + path.sep)
&& path.basename(directory).startsWith("rom-bounded-collision-"));
await rm(directory, { recursive: true, force: true });
}
});
@@ -81,6 +81,31 @@ export function transformSourcePointToPrimaryThree(
return [point.x * meters, point.y * meters, -point.z * meters];
}
export function transformSourceHeadingToPrimaryThree(sourceYawRadians, primary) {
const yaw = Number(sourceYawRadians);
if (!Number.isFinite(yaw)) throw new Error("sourceYawRadians must be finite.");
const rotation = new Quaternion(...finiteTuple(primary?.rotation, 4, "primary.rotation"));
if (rotation.lengthSq() < 1e-12) throw new Error("primary.rotation cannot be zero.");
rotation.normalize().invert();
const scale = finiteTuple(primary?.scale, 3, "primary.scale");
if (scale.some((value) => Math.abs(value) < 1e-12)) {
throw new Error("primary.scale cannot contain zero.");
}
// RuneWaker's left-handed forward is -Z at yaw zero. Rotate in the source
// basis first, apply inverse primary scale, then reflect Z into Three.js and
// normalize the planar vector.
const forward = new Vector3(Math.sin(yaw), 0, -Math.cos(yaw)).applyQuaternion(rotation);
forward.set(forward.x / scale[0], forward.y / scale[1], forward.z / scale[2]);
forward.y = 0;
forward.z *= -1;
const length = Math.hypot(forward.x, forward.z);
if (!Number.isFinite(length) || length < 1e-8) {
throw new Error("Source heading collapsed when transformed into the primary frame.");
}
return [forward.x / length, 0, forward.z / length];
}
function cloneNodeTree(document, node) {
const clone = document.createNode(node.getName())
.setMatrix(node.getMatrix())
@@ -0,0 +1,25 @@
/**
* Preserve source topology because simplification damages the atlas UV seams
* used by RoM environment meshes. Keep optimization focused on mesh size and
* repeated placements. Texture work is disabled here because the CLI's
* default auto-compressor resizes images to 2048px; a separate KTX2 pass encodes
* the full-size source images without changing their dimensions.
*/
export function environmentOptimizationArguments(sourceFile, outputFile, {
preserveNativeWaterLayers = false,
preserveMaterialVariantCarriers = false,
} = {}) {
return [
"optimize", sourceFile, outputFile,
"--compress", "meshopt", "--meshopt-level", "high",
"--instance", "true", "--instance-min", "3",
"--simplify", "false",
"--texture-compress", "false",
// KHR_materials_variants can carry native water maps or browser-adapted
// surface-detail maps. Joining/pruning sees only the default material and
// can discard a carrier, its UVs, or its mapping before runtime selection.
...((preserveNativeWaterLayers || preserveMaterialVariantCarriers)
? ["--join", "false", "--prune", "false", "--prune-attributes", "false"]
: []),
];
}
@@ -0,0 +1,22 @@
export function preparedAlphaTextureFilename(imageName) {
const name = String(imageName ?? "");
return /\.png$/iu.test(name) ? name : `${name}.png`;
}
function normalizedRole(role) {
if (role === "cutout" || role === "mask") return "mask";
if (role === "blend" || role === "additive") return "blend";
return role;
}
export function alphaSemanticRolesMatch(expectedRole, actualRole) {
return normalizedRole(expectedRole) === normalizedRole(actualRole);
}
export function effectiveAlphaMaterialRole(semanticRole, alphaMode) {
return semanticRole ?? (alphaMode === "OPAQUE" ? "opaque" : "missing");
}
export function alphaMaterialMatches(expectedAlphaMode, expectedRole, actualAlphaMode, actualRole) {
return expectedAlphaMode === actualAlphaMode && alphaSemanticRolesMatch(expectedRole, actualRole);
}
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
alphaMaterialMatches,
alphaSemanticRolesMatch,
effectiveAlphaMaterialRole,
preparedAlphaTextureFilename,
} from "./native-alpha-audit-helpers.mjs";
test("prepared texture lookup does not append .png to an image name that already has it", () => {
assert.equal(preparedAlphaTextureFilename("ae07_grass03"), "ae07_grass03.png");
assert.equal(preparedAlphaTextureFilename("ae07_grass03.png"), "ae07_grass03.png");
assert.equal(preparedAlphaTextureFilename("ae07_grass03.PNG"), "ae07_grass03.PNG");
});
test("alpha audit treats source cutout/mask and blend/additive labels as equivalent while checking alpha mode", () => {
assert.equal(alphaSemanticRolesMatch("cutout", "mask"), true);
assert.equal(alphaSemanticRolesMatch("blend", "additive"), true);
assert.equal(alphaMaterialMatches("MASK", "cutout", "MASK", "mask"), true);
assert.equal(alphaMaterialMatches("BLEND", "blend", "BLEND", "additive"), true);
assert.equal(alphaMaterialMatches("BLEND", "blend", "OPAQUE", "additive"), false);
assert.equal(alphaSemanticRolesMatch("opaque", "mask"), false);
assert.equal(effectiveAlphaMaterialRole(undefined, "OPAQUE"), "opaque");
assert.equal(effectiveAlphaMaterialRole(undefined, "BLEND"), "missing");
});
@@ -0,0 +1,98 @@
const SHA256 = /^[\da-f]{64}$/i;
const REQUIRED_ALPHA_PARAMETERS = ["alphaTestMode", "alphaMode", "translucent", "alpha", "alphaModifier"];
function normalizeResource(value) {
return String(value ?? "").replaceAll("\\", "/").replace(/^\/+/, "").toLowerCase();
}
function reportErrors(report, label, expectedResource, expectedSourceSha256) {
const errors = [];
if (!report || typeof report !== "object") return [`${label} report is missing`];
if (normalizeResource(report.sourceModel) !== normalizeResource(expectedResource)) {
errors.push(`${label} source model does not match the cached resource`);
}
if (!SHA256.test(String(report.sourceResourceSha256 ?? ""))
|| report.sourceResourceSha256.toLowerCase() !== expectedSourceSha256.toLowerCase()) {
errors.push(`${label} source resource SHA-256 is missing or stale`);
}
if (!Array.isArray(report.materials) || report.materials.length === 0) {
errors.push(`${label} native material rows are missing`);
return errors;
}
for (const material of report.materials) {
const identity = String(material?.name ?? "");
if (!/^runewaker_material_\d+$/i.test(identity)) {
errors.push(`${label} has a material row without a native material index`);
continue;
}
if (!Array.isArray(material.sourceTextureChannels)) {
errors.push(`${label} ${identity} has no source texture channel inventory`);
}
const parameters = new Set((material.alphaBlendParameters ?? [])
.filter((parameter) => parameter && typeof parameter.name === "string")
.map((parameter) => parameter.name));
for (const required of REQUIRED_ALPHA_PARAMETERS) {
if (!parameters.has(required)) {
errors.push(`${label} ${identity} lacks native alpha parameter ${required}`);
}
}
const alphaValues = Object.fromEntries((material.alphaBlendParameters ?? [])
.filter(parameter => parameter && typeof parameter.name === "string")
.map(parameter => [parameter.name, parameter.value]));
if (alphaValues.alphaTestMode === 1
&& (!Number.isInteger(alphaValues.alphaTestValue) || alphaValues.alphaTestValue < 0 || alphaValues.alphaTestValue > 255)) {
errors.push(`${label} ${identity} lacks a valid native alpha-test cutoff`);
}
if (material.sourceTexture) {
const diffuse = (material.sourceTextureChannels ?? [])
.filter((channel) => String(channel?.name ?? "").toLowerCase() === "diffuse")
.map((channel) => normalizeResource(channel.texture));
if (!diffuse.includes(normalizeResource(material.sourceTexture))) {
errors.push(`${label} ${identity} diffuse channel does not match its source texture`);
}
}
}
return errors;
}
/** Fail closed when a resource cache predates exact ROS material-alpha provenance. */
export function validateNativeAlphaResourceCache({
manifest,
rawReport,
preparedReport,
expectedResource,
expectedSourceSha256,
expectedToolchain,
}) {
const errors = [];
if (!manifest || typeof manifest !== "object") {
errors.push("cache manifest is missing");
} else {
if (normalizeResource(manifest.source?.resource) !== normalizeResource(expectedResource)) {
errors.push("cache manifest source resource does not match");
}
if (!SHA256.test(String(expectedSourceSha256 ?? ""))
|| manifest.source?.sha256?.toLowerCase() !== expectedSourceSha256.toLowerCase()) {
errors.push("cache manifest source SHA-256 does not match");
}
for (const name of ["converterSha256", "exporterSha256", "nativeAlphaPolicySha256"]) {
const expected = expectedToolchain?.[name];
const actual = manifest.toolchain?.[name];
if (!SHA256.test(String(expected ?? "")) || actual?.toLowerCase() !== expected.toLowerCase()) {
errors.push(`cache toolchain ${name} is missing or stale`);
}
}
}
errors.push(...reportErrors(rawReport, "raw", expectedResource, expectedSourceSha256));
errors.push(...reportErrors(preparedReport, "prepared", expectedResource, expectedSourceSha256));
if (rawReport && preparedReport
&& JSON.stringify(rawReport.materials) !== JSON.stringify(preparedReport.materials)) {
errors.push("prepared material rows differ from the raw native exporter report");
}
return {
valid: errors.length === 0,
errors,
nativeMaterialCount: Array.isArray(rawReport?.materials) ? rawReport.materials.length : 0,
};
}
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import test from "node:test";
import { validateNativeAlphaResourceCache } from "./native-alpha-cache-validation.mjs";
const sha = "a".repeat(64);
const toolchain = {
converterSha256: "b".repeat(64),
exporterSha256: "c".repeat(64),
nativeAlphaPolicySha256: "d".repeat(64),
};
function report() {
return {
sourceModel: "model\\element\\dungeon\\waterfall.ros",
sourceResourceSha256: sha,
materials: [{
name: "runewaker_material_9",
sourceTexture: "texture\\dungeon\\gate_waterfall",
sourceTextureChannels: [{ name: "diffuse", texture: "texture\\dungeon\\gate_waterfall" }],
alphaBlendParameters: [
{ name: "alphaTestMode", value: 0 },
{ name: "alphaMode", value: 0 },
{ name: "translucent", value: true },
{ name: "alpha", value: 1 },
{ name: "alphaModifier", value: 1 },
],
}],
};
}
function input(overrides = {}) {
const rawReport = report();
return {
manifest: {
source: { resource: "model/element/dungeon/waterfall.ros", sha256: sha },
toolchain,
},
rawReport,
preparedReport: structuredClone(rawReport),
expectedResource: "model/element/dungeon/waterfall.ros",
expectedSourceSha256: sha,
expectedToolchain: toolchain,
...overrides,
};
}
test("resource cache accepts matching raw/prepared native alpha provenance", () => {
const result = validateNativeAlphaResourceCache(input());
assert.deepEqual(result, { valid: true, errors: [], nativeMaterialCount: 1 });
});
test("resource cache rejects old missing toolchain and source hash stamps", () => {
const options = input();
delete options.manifest.toolchain;
delete options.rawReport.sourceResourceSha256;
delete options.preparedReport.sourceResourceSha256;
const result = validateNativeAlphaResourceCache(options);
assert.equal(result.valid, false);
assert.ok(result.errors.some((error) => error.includes("toolchain exporterSha256")));
assert.ok(result.errors.some((error) => error.includes("raw source resource SHA-256")));
assert.ok(result.errors.some((error) => error.includes("prepared source resource SHA-256")));
});
test("resource cache rejects native material rows missing alpha flags or source-channel joins", () => {
const options = input();
options.rawReport.materials[0].alphaBlendParameters = [];
options.preparedReport.materials[0].alphaBlendParameters = [];
options.rawReport.materials[0].sourceTextureChannels = [];
options.preparedReport.materials[0].sourceTextureChannels = [];
const result = validateNativeAlphaResourceCache(options);
assert.equal(result.valid, false);
assert.ok(result.errors.some((error) => error.includes("lacks native alpha parameter alphaMode")));
assert.ok(result.errors.some((error) => error.includes("diffuse channel does not match")));
});
test("resource cache rejects raw/prepared report drift even when stamps match", () => {
const options = input();
options.preparedReport.materials[0].alphaBlendParameters[2].value = false;
const result = validateNativeAlphaResourceCache(options);
assert.equal(result.valid, false);
assert.ok(result.errors.includes("prepared material rows differ from the raw native exporter report"));
});
test("resource cache cannot accept cutout material flags without the source cutoff", () => {
const options = input();
for (const report of [options.rawReport, options.preparedReport]) {
report.materials[0].alphaBlendParameters[0].value = 1;
}
assert.equal(validateNativeAlphaResourceCache(options).valid, false);
for (const report of [options.rawReport, options.preparedReport]) {
report.materials[0].alphaBlendParameters.push({ name: "alphaTestValue", value: 192 });
}
assert.equal(validateNativeAlphaResourceCache(options).valid, true);
});
@@ -0,0 +1,244 @@
import { createHash } from "node:crypto";
const SHA256 = /^[\da-f]{64}$/i;
const JSON_CHUNK = 0x4e4f534a;
const BIN_CHUNK = 0x004e4942;
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
const normalizePath = (value) => String(value ?? "").replaceAll("/", "\\").replace(/^\\+/, "").toLowerCase();
const basename = (value) => String(value ?? "").replaceAll("\\", "/").split("/").at(-1).replace(/\.dds$/i, "").replace(/\.png$/i, "");
function parseGlb(buffer) {
if (buffer.length < 20 || buffer.toString("utf8", 0, 4) !== "glTF" || buffer.readUInt32LE(4) !== 2) {
throw new Error("Input is not a glTF 2 GLB.");
}
const chunks = [];
let json = null;
let bin = null;
for (let offset = 12; offset + 8 <= buffer.length;) {
const length = buffer.readUInt32LE(offset);
const type = buffer.readUInt32LE(offset + 4);
const start = offset + 8;
const end = start + length;
if (end > buffer.length) throw new Error("GLB chunk exceeds the file size.");
const data = buffer.subarray(start, end);
chunks.push({ type, data });
if (type === JSON_CHUNK) json = JSON.parse(data.toString("utf8").replace(/\0+$/u, "").trim());
if (type === BIN_CHUNK) bin = data;
offset = end;
}
if (!json || !bin) throw new Error("GLB must contain JSON and BIN chunks.");
return { json, bin, chunks };
}
function serializeGlb(source, json, chunks) {
const encoded = Buffer.from(JSON.stringify(json));
const padded = Buffer.alloc(Math.ceil(encoded.length / 4) * 4, 0x20);
encoded.copy(padded);
const rewritten = chunks.map((chunk) => chunk.type === JSON_CHUNK ? { ...chunk, data: padded } : chunk);
const length = 12 + rewritten.reduce((sum, chunk) => sum + 8 + chunk.data.length, 0);
const output = Buffer.alloc(length);
source.copy(output, 0, 0, 12);
output.writeUInt32LE(length, 8);
let offset = 12;
for (const chunk of rewritten) {
output.writeUInt32LE(chunk.data.length, offset);
output.writeUInt32LE(chunk.type, offset + 4);
chunk.data.copy(output, offset + 8);
offset += 8 + chunk.data.length;
}
return output;
}
function embeddedImageBytes(json, bin, image) {
const view = json.bufferViews?.[image?.bufferView];
if (!view || (view.buffer !== undefined && view.buffer !== 0)) throw new Error("Target GLB image is not embedded in BIN.");
const start = view.byteOffset ?? 0;
return bin.subarray(start, start + view.byteLength);
}
function nativeAction(row, alphaRole) {
const params = Object.fromEntries((row.alphaBlendParameters ?? [])
.filter((entry) => entry && typeof entry.name === "string")
.map((entry) => [entry.name, entry.value]));
for (const key of ["alphaTestMode", "alphaMode", "translucent", "alpha", "alphaModifier"]) {
if (!Object.hasOwn(params, key)) throw new Error(`${row.name} is missing native alpha parameter ${key}.`);
}
if (Number(params.alpha) !== 1 || Number(params.alphaModifier) !== 1) {
throw new Error(`${row.name} has a non-unit native global alpha factor.`);
}
const testMode = Number(params.alphaTestMode);
const alphaMode = Number(params.alphaMode);
const translucent = Boolean(params.translucent);
if (testMode === 0 && alphaMode === 1 && translucent) return { action: "additive", alphaMode: "BLEND", reason: "native-srcalpha-one-additive" };
if (testMode === 0 && alphaMode === 0 && translucent) {
return alphaRole === "opaque"
? { action: "opaque", alphaMode: "OPAQUE", reason: "native-normal-translucent-texture-alpha-opaque" }
: { action: "blend", alphaMode: "BLEND", reason: "native-normal-translucent-alpha" };
}
if (testMode === 0 && alphaMode === 0 && !translucent) return { action: "opaque", alphaMode: "OPAQUE", reason: "native-normal-opaque-material" };
if (testMode === 1 && alphaMode === 0 && !translucent) {
const threshold = Math.max(0, Math.min(255, Number(params.alphaTestValue))) / 255;
if (!Number.isFinite(threshold)) throw new Error(`${row.name} is missing a valid native alpha-test threshold.`);
return alphaRole === "opaque"
? { action: "opaque", alphaMode: "OPAQUE", reason: "native-alpha-test-texture-alpha-opaque" }
: { action: "mask", alphaMode: "MASK", alphaCutoff: threshold, reason: "native-greater-alpha-test" };
}
throw new Error(`${row.name} has an unsupported native alpha combination.`);
}
/** Apply exact source-report + source-image guarded material actions to a review GLB. */
export function patchNativeAlphaBindings(sourceBuffer, overrides) {
if (!Array.isArray(overrides) || overrides.length === 0) throw new Error("At least one pinned native alpha override is required.");
const { json, bin, chunks } = parseGlb(sourceBuffer);
json.materials ??= [];
json.meshes ??= [];
json.nodes ??= [];
json.images ??= [];
const beforeBinSha256 = sha256(bin);
const bindings = [];
const materialCloneCache = new Map();
for (const override of overrides) {
const { sourceModel, sourceResourceSha256, nativeMaterialRow: row, groupNames, imageName,
sourcePngBytes, sourcePngSha256, sourceTextureSha256, sourceAlphaRole, sourceAlphaStats } = override;
if (!String(sourceModel ?? "").toLowerCase().endsWith(".ros") || !SHA256.test(String(sourceResourceSha256 ?? ""))) {
throw new Error("Native alpha override lacks a pinned ROS path or SHA-256.");
}
if (!row || !/^runewaker_material_\d+$/i.test(String(row.name ?? ""))) throw new Error("Native alpha override lacks a native material index.");
if (!SHA256.test(String(sourcePngSha256 ?? "")) || sha256(sourcePngBytes) !== sourcePngSha256.toLowerCase()) {
throw new Error(`${row.name} source PNG hash does not match its supplied bytes.`);
}
if (!SHA256.test(String(sourceTextureSha256 ?? ""))) throw new Error(`${row.name} source DDS hash is missing.`);
if (normalizePath(row.sourceTexture) !== normalizePath(override.sourceTexture)) {
throw new Error(`${row.name} native diffuse source path differs from the pinned source texture.`);
}
const diffuse = (row.sourceTextureChannels ?? [])
.filter((channel) => String(channel?.name ?? "").toLowerCase() === "diffuse")
.map((channel) => normalizePath(channel.texture));
if (!diffuse.includes(normalizePath(override.sourceTexture))) {
throw new Error(`${row.name} native diffuse channel does not match the pinned source texture.`);
}
if (basename(row.stagedTexture) !== basename(imageName)) throw new Error(`${row.name} staged texture alias does not match the GLB image.`);
if (!Array.isArray(groupNames) || groupNames.length === 0) throw new Error(`${row.name} has no native OBJ groups to bind.`);
const alphaDecision = nativeAction(row, sourceAlphaRole);
const rowIndex = Number(row.name.match(/(\d+)$/)?.[1]);
if (!Number.isInteger(rowIndex)) throw new Error(`${row.name} has an invalid native index.`);
const imageIndexForMaterial = (materialIndex) => {
const material = json.materials[materialIndex];
const textureIndex = material?.pbrMetallicRoughness?.baseColorTexture?.index;
const texture = json.textures?.[textureIndex];
const imageIndex = texture?.extensions?.KHR_texture_basisu?.source ?? texture?.source;
return imageIndex;
};
const imageIndexes = json.images.map((image, index) => basename(image.name) === basename(imageName) ? index : -1).filter((index) => index >= 0);
const exactImageIndexes = imageIndexes.filter((index) => {
const image = json.images[index];
if (image.mimeType !== "image/png") return false;
return embeddedImageBytes(json, bin, image).equals(Buffer.from(sourcePngBytes));
});
if (exactImageIndexes.length === 0) {
throw new Error(`${row.name} found no embedded PNG byte-identical to its source texture.`);
}
const exactImageIndexSet = new Set(exactImageIndexes);
const matchingNodes = json.nodes.map((node, index) => ({ node, index }))
.filter(({ node }) => groupNames.includes(node.name));
if (matchingNodes.length === 0) throw new Error(`${row.name} native OBJ groups are absent from the target GLB.`);
const expectedNodeNames = new Set(groupNames);
const meshUsers = new Map();
for (const [index, node] of json.nodes.entries()) {
if (node.mesh === undefined) continue;
const users = meshUsers.get(node.mesh) ?? [];
users.push(index);
meshUsers.set(node.mesh, users);
}
for (const { node, index: nodeIndex } of matchingNodes) {
const originalMeshIndex = node.mesh;
const originalMesh = json.meshes[originalMeshIndex];
if (!originalMesh) throw new Error(`${row.name} native OBJ group ${node.name} has no GLB mesh.`);
let meshIndex = originalMeshIndex;
const users = meshUsers.get(originalMeshIndex) ?? [];
if (users.some((userIndex) => !expectedNodeNames.has(json.nodes[userIndex]?.name))) {
meshIndex = json.meshes.length;
json.meshes.push(structuredClone(originalMesh));
node.mesh = meshIndex;
}
const mesh = json.meshes[meshIndex];
let matchedPrimitiveCount = 0;
for (const primitive of mesh.primitives ?? []) {
const sourceMaterialIndex = primitive.material;
const imageIndex = imageIndexForMaterial(sourceMaterialIndex);
if (!exactImageIndexSet.has(imageIndex)) continue;
const oldMaterial = json.materials[sourceMaterialIndex];
if (!oldMaterial) throw new Error(`${row.name} target GLB primitive has a missing material.`);
const identityKey = `${sourceMaterialIndex}:${sourceModel}:${sourceResourceSha256}:${rowIndex}:${alphaDecision.action}`;
let targetMaterialIndex = materialCloneCache.get(identityKey);
if (targetMaterialIndex === undefined) {
const clone = structuredClone(oldMaterial);
clone.alphaMode = alphaDecision.alphaMode;
if (alphaDecision.alphaMode === "MASK") clone.alphaCutoff = alphaDecision.alphaCutoff;
else delete clone.alphaCutoff;
clone.extras = {
...(clone.extras ?? {}),
semanticRole: alphaDecision.action,
nativeSourceModel: sourceModel,
nativeSourceResourceSha256: sourceResourceSha256.toLowerCase(),
nativeMaterialIndex: rowIndex,
nativeSourceTexture: normalizePath(override.sourceTexture),
nativeAlphaPolicy: alphaDecision.reason,
nativeAlphaParameters: Object.fromEntries((row.alphaBlendParameters ?? []).map((parameter) => [parameter.name, parameter.value])),
nativeSourceTextureSha256: sourceTextureSha256.toLowerCase(),
nativeSourcePngSha256: sourcePngSha256.toLowerCase(),
nativeSourceAlphaRange: sourceAlphaStats?.range ?? null,
};
if (row.lightingType?.name) clone.extras.nativeLightingType = String(row.lightingType.name).toLowerCase();
if (alphaDecision.action === "additive") clone.extras.nativeBlendMode = "additive";
else delete clone.extras.nativeBlendMode;
targetMaterialIndex = json.materials.length;
json.materials.push(clone);
materialCloneCache.set(identityKey, targetMaterialIndex);
}
primitive.material = targetMaterialIndex;
matchedPrimitiveCount += 1;
}
if (matchedPrimitiveCount === 0) throw new Error(`${row.name} native OBJ group ${node.name} has no matching source-texture primitive.`);
bindings.push({
sourceModel,
sourceResourceSha256: sourceResourceSha256.toLowerCase(),
nativeMaterialName: row.name,
nativeMaterialIndex: rowIndex,
nativeSourceTexture: normalizePath(override.sourceTexture),
sourceTextureSha256: sourceTextureSha256.toLowerCase(),
sourcePngSha256: sourcePngSha256.toLowerCase(),
imageName,
sourceAlphaRange: sourceAlphaStats?.range ?? null,
action: alphaDecision.action,
gltfAlphaMode: alphaDecision.alphaMode,
reason: alphaDecision.reason,
groupName: node.name,
gltfMeshIndex: meshIndex,
targetPrimitiveCount: matchedPrimitiveCount,
});
}
}
if (bindings.length === 0) throw new Error("No source-proven alpha bindings were staged.");
const outputBuffer = serializeGlb(sourceBuffer, json, chunks);
const { bin: outputBin } = parseGlb(outputBuffer);
if (sha256(outputBin) !== beforeBinSha256) throw new Error("Review alpha patch unexpectedly changed GLB BIN data.");
return {
buffer: outputBuffer,
report: {
schemaVersion: 1,
beforeBinSha256,
afterBinSha256: sha256(outputBin),
binBytesUnchanged: true,
bindingCount: bindings.length,
bindings,
},
};
}
@@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import test from "node:test";
import { patchNativeAlphaBindings } from "./native-alpha-glb-staging.mjs";
const hash = (value) => createHash("sha256").update(value).digest("hex");
function makeGlb() {
const imageBytes = Buffer.from([11, 22, 33, 44]);
const json = {
asset: { version: "2.0" },
buffers: [{ byteLength: 4 }],
bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 4 }],
images: [{ name: "gate_waterfall", mimeType: "image/png", bufferView: 0 }],
textures: [{ source: 0 }],
materials: [{ name: "old-deduplicated-material", pbrMetallicRoughness: { baseColorTexture: { index: 0 } }, extras: { old: true } }],
meshes: [{ name: "waterfall", primitives: [{ attributes: {}, material: 0 }] }],
nodes: [{ name: "waterfall", mesh: 0 }],
scenes: [{ nodes: [0] }],
scene: 0,
};
const jsonBytes = Buffer.from(JSON.stringify(json));
const jsonPadding = Buffer.alloc((4 - jsonBytes.length % 4) % 4, 0x20);
const binPadding = Buffer.alloc((4 - imageBytes.length % 4) % 4);
const jsonChunk = Buffer.concat([jsonBytes, jsonPadding]);
const binChunk = Buffer.concat([imageBytes, binPadding]);
const length = 12 + 8 + jsonChunk.length + 8 + binChunk.length;
const glb = Buffer.alloc(length);
glb.write("glTF", 0);
glb.writeUInt32LE(2, 4);
glb.writeUInt32LE(length, 8);
glb.writeUInt32LE(jsonChunk.length, 12);
glb.writeUInt32LE(0x4e4f534a, 16);
jsonChunk.copy(glb, 20);
const binHeader = 20 + jsonChunk.length;
glb.writeUInt32LE(binChunk.length, binHeader);
glb.writeUInt32LE(0x004e4942, binHeader + 4);
binChunk.copy(glb, binHeader + 8);
return { glb, imageBytes };
}
function makeOverride(imageBytes, nativeAlphaParameters) {
return {
sourceModel: "model\\element\\test\\waterfall.ros",
sourceResourceSha256: "a".repeat(64),
nativeMaterialRow: {
name: "runewaker_material_9",
sourceTexture: "texture\\dungeon\\gate_waterfall",
stagedTexture: "textures/gate_waterfall.dds",
sourceTextureChannels: [{ name: "diffuse", texture: "texture\\dungeon\\gate_waterfall" }],
alphaBlendParameters: nativeAlphaParameters,
lightingType: { name: "standard" },
},
sourceTexture: "texture\\dungeon\\gate_waterfall",
groupNames: ["waterfall"],
imageName: "gate_waterfall",
sourcePngBytes: imageBytes,
sourcePngSha256: hash(imageBytes),
sourceTextureSha256: "b".repeat(64),
sourceAlphaRole: "blend",
sourceAlphaStats: { range: [0, 173] },
};
}
const normalTranslucent = [
{ name: "alphaTestMode", value: 0 },
{ name: "alphaMode", value: 0 },
{ name: "translucent", value: true },
{ name: "alpha", value: 1 },
{ name: "alphaModifier", value: 1 },
];
test("source-pinned native alpha patch changes only material JSON and keeps texture BIN bytes", () => {
const { glb, imageBytes } = makeGlb();
const result = patchNativeAlphaBindings(glb, [makeOverride(imageBytes, normalTranslucent)]);
assert.equal(result.report.bindingCount, 1);
assert.equal(result.report.binBytesUnchanged, true);
assert.deepEqual(result.report.bindings[0].sourceAlphaRange, [0, 173]);
const offset = 12;
const jsonLength = result.buffer.readUInt32LE(offset);
const document = JSON.parse(result.buffer.subarray(offset + 8, offset + 8 + jsonLength).toString().trim());
const target = document.materials[document.meshes[0].primitives[0].material];
assert.equal(target.alphaMode, "BLEND");
assert.equal(target.extras.nativeAlphaPolicy, "native-normal-translucent-alpha");
assert.equal(target.extras.nativeSourceResourceSha256, "a".repeat(64));
assert.equal(document.materials[0].alphaMode, undefined, "the source material remains intact for non-target uses");
});
test("source alpha override preserves native additive precedence", () => {
const { glb, imageBytes } = makeGlb();
const additive = [
{ name: "alphaTestMode", value: 0 },
{ name: "alphaMode", value: 1 },
{ name: "translucent", value: true },
{ name: "alpha", value: 1 },
{ name: "alphaModifier", value: 1 },
];
const result = patchNativeAlphaBindings(glb, [makeOverride(imageBytes, additive)]);
const jsonLength = result.buffer.readUInt32LE(12);
const document = JSON.parse(result.buffer.subarray(20, 20 + jsonLength).toString().trim());
const target = document.materials[document.meshes[0].primitives[0].material];
assert.equal(target.alphaMode, "BLEND");
assert.equal(target.extras.semanticRole, "additive");
assert.equal(target.extras.nativeBlendMode, "additive");
});
test("source alpha override fails closed when diffuse texture bytes differ", () => {
const { glb } = makeGlb();
const wrongTexture = Buffer.from([99, 99, 99, 99]);
assert.throws(
() => patchNativeAlphaBindings(glb, [makeOverride(wrongTexture, normalTranslucent)]),
/no embedded PNG byte-identical/,
);
});
@@ -0,0 +1,146 @@
import path from "node:path";
export const TREKKER_WATER_FOLIAGE_EXCLUSION_MASK = 0x00a00000;
export function normalizeNativeResourcePath(resource) {
if (typeof resource !== "string" || !resource.trim()) {
throw new Error("Native collision resource path must be a non-empty string.");
}
const normalized = resource.replaceAll("/", "\\").replace(/^\\+/, "");
const parts = normalized.split("\\");
if (parts.some((part) => !part || part === "." || part === "..")) {
throw new Error(`Unsafe native collision resource path: ${resource}`);
}
return parts.join("\\");
}
export function nativeResourceFile(resourceRoot, resource) {
const normalized = normalizeNativeResourcePath(resource);
const file = path.resolve(resourceRoot, ...normalized.split("\\"));
const relative = path.relative(path.resolve(resourceRoot), file);
if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) {
throw new Error(`Native resource escaped the resource root: ${resource}`);
}
return file;
}
export function transformNativeCollisionPoint(source, columnMajorMatrix, metersPerSourceUnit) {
if (!Array.isArray(source) && !ArrayBuffer.isView(source)) throw new Error("Source point must contain three coordinates.");
if (source.length !== 3 || !Array.isArray(columnMajorMatrix) || columnMajorMatrix.length !== 16) {
throw new Error("Native collision transform requires a three-value point and 16-value matrix.");
}
if (!Number.isFinite(metersPerSourceUnit) || metersPerSourceUnit <= 0) {
throw new Error("Meters per source unit must be a finite positive number.");
}
const x = Number(source[0]) * metersPerSourceUnit;
const y = Number(source[1]) * metersPerSourceUnit;
const z = -Number(source[2]) * metersPerSourceUnit;
const point = [
columnMajorMatrix[0] * x + columnMajorMatrix[4] * y + columnMajorMatrix[8] * z + columnMajorMatrix[12],
columnMajorMatrix[1] * x + columnMajorMatrix[5] * y + columnMajorMatrix[9] * z + columnMajorMatrix[13],
columnMajorMatrix[2] * x + columnMajorMatrix[6] * y + columnMajorMatrix[10] * z + columnMajorMatrix[14],
];
if (point.some((value) => !Number.isFinite(value))) throw new Error("Native collision placement produced a non-finite point.");
return point;
}
export function transformNativeCollisionTriangle(sourcePoints, columnMajorMatrix, metersPerSourceUnit) {
if (!Array.isArray(sourcePoints) || sourcePoints.length !== 3) {
throw new Error("Native collision triangle must contain exactly three points.");
}
const [a, b, c] = sourcePoints.map((point) => (
transformNativeCollisionPoint(point, columnMajorMatrix, metersPerSourceUnit)
));
// The source-to-Three bridge reflects Z. Reverse winding exactly once here.
return [a, c, b];
}
export function triangleTileKey(points, tileSizeMeters) {
if (!Number.isFinite(tileSizeMeters) || tileSizeMeters <= 0) throw new Error("Tile size must be finite and positive.");
const centerX = (points[0][0] + points[1][0] + points[2][0]) / 3;
const centerZ = (points[0][2] + points[1][2] + points[2][2]) / 3;
return `${Math.floor(centerX / tileSizeMeters)}:${Math.floor(centerZ / tileSizeMeters)}`;
}
export function isTrekkerPathEligible(triangleFlag) {
return ((Number(triangleFlag) >>> 0) & TREKKER_WATER_FOLIAGE_EXCLUSION_MASK) === 0;
}
export class NativeCollisionPartAccumulator {
constructor(maxTriangles) {
if (!Number.isSafeInteger(maxTriangles) || maxTriangles < 1 || maxTriangles > 90_000) {
throw new Error("Native collision GLB part size must be an integer from 1 to 90,000 triangles.");
}
this.maxTriangles = maxTriangles;
this.positions = new Float32Array(maxTriangles * 9);
this.flags = [];
this.flagCounts = new Map();
}
get triangleCount() {
return this.flags.length;
}
add(points, flag) {
if (this.triangleCount >= this.maxTriangles) throw new Error("Flush the full native collision part before adding another triangle.");
if (!Array.isArray(points) || points.length !== 3 || points.some((point) => point.length !== 3 || [...point].some((value) => !Number.isFinite(Number(value))))) {
throw new Error("Native collision part requires a finite 3D triangle.");
}
const offset = this.triangleCount * 9;
this.positions.set([...points[0], ...points[1], ...points[2]], offset);
const normalizedFlag = Number(flag) >>> 0;
this.flags.push(normalizedFlag);
this.flagCounts.set(normalizedFlag, (this.flagCounts.get(normalizedFlag) ?? 0) + 1);
return this.triangleCount === this.maxTriangles ? this.finish() : null;
}
finish() {
if (this.triangleCount === 0) return null;
const result = {
triangleCount: this.triangleCount,
positions: this.positions.slice(0, this.triangleCount * 9),
sourceTriangleFlags: this.flags.slice(),
flagCounts: Object.fromEntries([...this.flagCounts.entries()].sort((a, b) => a[0] - b[0]).map(([flag, count]) => [String(flag), count])),
};
this.positions = new Float32Array(this.maxTriangles * 9);
this.flags = [];
this.flagCounts = new Map();
return result;
}
}
export function validateNativeCollisionCounts({ triangleCount, flagBytes, positionVertexCount }) {
if (!Number.isSafeInteger(triangleCount) || triangleCount < 0) throw new Error("Native collision report has an invalid triangle count.");
if (flagBytes !== triangleCount * 4) {
throw new Error(`Native triangle flags contain ${flagBytes} bytes, expected ${triangleCount * 4}.`);
}
if (positionVertexCount !== triangleCount * 3) {
throw new Error(`Native collision OBJ contains ${positionVertexCount} vertices, expected ${triangleCount * 3}.`);
}
}
export function renderMeshOmissions(report) {
const omitted = [];
for (const entity of report.entityNodes ?? []) {
for (const [meshIndex, kind] of (entity.meshCollisionTypes ?? []).entries()) {
if (kind === "unsupported mesh class") {
omitted.push({
entity: entity.name,
entityType: entity.type,
meshIndex,
reason: "unsupported mesh class in native collision traversal",
entityCollisionObjectReplacesChildren: entity.hasCollisionObject,
});
} else if (String(kind).startsWith("none(serialized)")) {
omitted.push({
entity: entity.name,
entityType: entity.type,
meshIndex,
reason: "no serialized mesh collision structure; render-mesh fallback is excluded",
entityCollisionObjectReplacesChildren: entity.hasCollisionObject,
});
}
}
}
return omitted;
}
@@ -0,0 +1,167 @@
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { normalizeNativeResourcePath, nativeResourceFile } from "./native-collision-assembly.mjs";
export async function sha256File(file) {
const bytes = await readFile(file);
return createHash("sha256").update(bytes).digest("hex");
}
export function normalizedResourceKey(value) {
return normalizeNativeResourcePath(value).toLowerCase();
}
function resolveInventoryFile(inventoryRoot, relativePath) {
if (typeof relativePath !== "string" || !relativePath.trim()) throw new Error("Inventory file path is missing.");
const resolved = path.resolve(inventoryRoot, relativePath);
const relative = path.relative(path.resolve(inventoryRoot), resolved);
if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) {
throw new Error(`Inventory file escaped its root: ${relativePath}`);
}
return resolved;
}
export async function writeNativeCollisionInventoryManifest({
inventoryRoot,
placementManifestSha256,
sourceWdbSha256,
nativeExtractor,
collisionMode = "complete",
resources,
sourceResourceRoot,
}) {
if (!new Set(["complete", "respect-options"]).has(collisionMode)) throw new Error(`Unsupported native collision mode: ${collisionMode}`);
const rows = [];
for (const resource of [...resources].sort((a, b) => normalizedResourceKey(a.resource).localeCompare(normalizedResourceKey(b.resource)))) {
const sourcePath = nativeResourceFile(sourceResourceRoot, resource.resource);
const sourceBefore = await sha256File(sourcePath);
if (resource.sourceResourceSha256 && resource.sourceResourceSha256 !== sourceBefore) {
throw new Error(`Source ROS changed after native extraction: ${resource.resource}`);
}
const reportSha256 = await sha256File(resource.reportFile);
const objSha256 = await sha256File(resource.objFile);
const flagsSha256 = await sha256File(resource.flagsFile);
const sourceAfter = await sha256File(sourcePath);
if (sourceBefore !== sourceAfter) throw new Error(`Source ROS changed while indexing collision extraction: ${resource.resource}`);
const relative = (file) => {
const rel = path.relative(inventoryRoot, file);
if (path.isAbsolute(rel) || rel === ".." || rel.startsWith(`..${path.sep}`)) {
throw new Error(`Collision extraction output is outside inventory root: ${file}`);
}
return rel.split(path.sep).join("/");
};
rows.push({
resource: resource.resource,
sourceResourceSha256: resource.sourceResourceSha256 ?? sourceBefore,
report: relative(resource.reportFile),
reportSha256,
obj: relative(resource.objFile),
objSha256,
triangleFlags: relative(resource.flagsFile),
triangleFlagsSha256: flagsSha256,
nativeTriangleCount: Number(resource.report.nativeCollisionTriangleCount),
completeNativeTriangleCount: Number(resource.report.completeNativeCollisionTriangleCount ?? resource.report.nativeCollisionTriangleCount),
nativeTrianglesOmittedByCollisionOptions: Number(resource.report.nativeTrianglesOmittedByCollisionOptions ?? 0),
disabledCollisionOptionEntityCount: Number(resource.report.disabledCollisionOptionEntityCount ?? 0),
entityNodesSkippedByCollisionOptionGates: Number(resource.report.entityNodesSkippedByCollisionOptionGates ?? 0),
renderMeshFallbackTriangleCount: Number(resource.report.renderMeshFallbackTriangleCount ?? 0),
});
}
const manifest = {
schemaVersion: 2,
extraction: "Rune Engine serialized IRuCollisionObject / CRuCollisionStructure; render-mesh fallback excluded",
collisionMode,
placementManifestSha256,
sourceWdbSha256,
nativeExtractor,
triangleFlagsEncoding: "one little-endian uint32 per serialized native collision triangle, in OBJ triangle order",
resources: rows,
};
const file = path.join(inventoryRoot, "native-collision-inventory.json");
await writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
return { manifest, file, sha256: await sha256File(file) };
}
export async function readAndValidateNativeCollisionInventory({
inventoryRoot,
placementManifestSha256,
sourceWdbSha256,
collisionMode = "complete",
nativeExtractor,
expectedResources,
sourceResourceRoot,
}) {
const manifestPath = path.join(inventoryRoot, "native-collision-inventory.json");
const inventory = JSON.parse(await readFile(manifestPath, "utf8"));
if (![1, 2].includes(inventory.schemaVersion)) throw new Error(`Unsupported native collision inventory schema: ${inventory.schemaVersion}`);
const indexedCollisionMode = inventory.collisionMode ?? "complete";
if (indexedCollisionMode !== collisionMode) {
throw new Error(`Native collision inventory mode ${indexedCollisionMode} does not match requested mode ${collisionMode}.`);
}
if (nativeExtractor) {
for (const field of ["executableSha256", "sourceSha256"]) {
if (inventory.nativeExtractor?.[field] !== nativeExtractor[field]) {
throw new Error(`Native collision inventory ${field} does not match the current extractor.`);
}
}
if ((inventory.nativeExtractor?.collisionMode ?? "complete") !== collisionMode) {
throw new Error("Native collision inventory extractor mode does not match the requested mode.");
}
}
if (inventory.placementManifestSha256 !== placementManifestSha256) {
throw new Error("Native collision inventory is bound to a different placement manifest.");
}
if (inventory.sourceWdbSha256 !== sourceWdbSha256) throw new Error("Native collision inventory is bound to a different source WDB.");
if (!Array.isArray(inventory.resources)) throw new Error("Native collision inventory has no resource rows.");
const expected = new Map(expectedResources.map((resource) => [normalizedResourceKey(resource), resource]));
const validated = new Map();
for (const row of inventory.resources) {
const key = normalizedResourceKey(row.resource);
if (!expected.has(key)) throw new Error(`Unexpected resource in collision inventory: ${row.resource}`);
if (validated.has(key)) throw new Error(`Duplicate resource in collision inventory: ${row.resource}`);
const sourcePath = nativeResourceFile(sourceResourceRoot, row.resource);
const actualSourceHash = await sha256File(sourcePath);
if (actualSourceHash !== row.sourceResourceSha256) {
throw new Error(`Source ROS SHA-256 mismatch for ${row.resource}; regenerate the collision inventory.`);
}
const reportFile = resolveInventoryFile(inventoryRoot, row.report);
const objFile = resolveInventoryFile(inventoryRoot, row.obj);
const flagsFile = resolveInventoryFile(inventoryRoot, row.triangleFlags);
if (await sha256File(reportFile) !== row.reportSha256) throw new Error(`Collision report changed after inventory was indexed: ${row.resource}`);
if (await sha256File(objFile) !== row.objSha256) throw new Error(`Collision OBJ changed after inventory was indexed: ${row.resource}`);
if (await sha256File(flagsFile) !== row.triangleFlagsSha256) throw new Error(`Collision flag sidecar changed after inventory was indexed: ${row.resource}`);
const report = JSON.parse(await readFile(reportFile, "utf8"));
if (normalizedResourceKey(report.sourceModel) !== key) throw new Error(`Collision report resource identity mismatch for ${row.resource}`);
if ((report.collisionMode ?? "complete") !== collisionMode) throw new Error(`Collision report mode mismatch for ${row.resource}`);
if (Number(report.nativeCollisionTriangleCount) !== Number(row.nativeTriangleCount)) {
throw new Error(`Collision report triangle count mismatch for ${row.resource}`);
}
const completeCount = Number(report.completeNativeCollisionTriangleCount ?? report.nativeCollisionTriangleCount);
if (!Number.isSafeInteger(completeCount) || completeCount < Number(row.nativeTriangleCount)) {
throw new Error(`Invalid complete collision triangle count for ${row.resource}`);
}
if (Number(row.completeNativeTriangleCount ?? completeCount) !== completeCount) {
throw new Error(`Complete collision triangle count mismatch for ${row.resource}`);
}
const fallbackCount = Number(report.renderMeshFallbackTriangleCount ?? 0);
if (fallbackCount !== Number(row.renderMeshFallbackTriangleCount ?? 0)) {
throw new Error(`Collision report fallback count mismatch for ${row.resource}`);
}
if (fallbackCount < 0 || !Number.isSafeInteger(fallbackCount)) throw new Error(`Invalid collision fallback count for ${row.resource}`);
validated.set(key, {
resource: expected.get(key),
row,
report,
reportFile,
objFile,
flagsFile,
});
}
const missing = [...expected.keys()].filter((key) => !validated.has(key));
if (missing.length) throw new Error(`Native collision inventory omits ${missing.length} WDB resource(s): ${missing.slice(0, 5).join(", ")}`);
return { inventory, manifestPath, manifestSha256: await sha256File(manifestPath), resources: validated };
}
@@ -0,0 +1,124 @@
const normalizeResource = (value) => String(value ?? "")
.replaceAll("\\", "/")
.replace(/^\/+/, "")
.toLowerCase();
/**
* The Windows native audit tool currently emits its input/output path strings
* directly into JSON. Forward slashes are accepted by the filesystem and
* prevent unescaped backslashes from making that report invalid JSON.
*/
export function buildNativeCollisionAuditArguments({ resourceRoot, sourceModel, reportPath }) {
const normalizePath = (value, label) => {
const normalized = String(value ?? "").replaceAll("\\", "/");
if (!normalized) throw new Error(`${label} is required for native collision audit.`);
return normalized;
};
return [
normalizePath(resourceRoot, "Resource root"),
normalizePath(sourceModel, "Source ROS path"),
normalizePath(reportPath, "Native report path"),
"respect-options",
];
}
function requireHash(value, label) {
const hash = String(value ?? "").toLowerCase();
if (!/^[a-f0-9]{64}$/.test(hash)) throw new Error(`${label} must be a SHA-256 hash.`);
return hash;
}
function requireEqual(actual, expected, label) {
if (actual !== expected) throw new Error(`${label} does not match.`);
}
function samePlacement(actual, expected) {
if (!actual || !expected) return false;
if (String(actual.guid ?? "").toLowerCase() !== String(expected.guid ?? "").toLowerCase()) return false;
if (normalizeResource(actual.resource) !== normalizeResource(expected.resource)) return false;
for (const field of ["translation", "rotation", "scale"]) {
if (!Array.isArray(actual[field]) || !Array.isArray(expected[field]) || actual[field].length !== expected[field].length) return false;
for (let index = 0; index < actual[field].length; index += 1) {
if (Math.abs(Number(actual[field][index]) - Number(expected[field][index])) > 1e-5) return false;
}
}
return true;
}
/**
* Verifies that native-core collision candidates are bound to one exact
* recipe, primary ROS/WDB source cache, import metadata, WDB placement, and
* strict engine-query geometry report before any candidate files are emitted.
*/
export function validateNativeCoreCandidateProvenance(input) {
const {
slug,
recipe,
recipeSha256,
importMetadata,
actualSourceModelSha256,
actualSourceWdbSha256,
wdbReport,
wdbReportSha256,
exportReport,
exportReportSha256,
conversionReportSha256,
nativeCollisionReport,
nativeCollisionReportSha256,
nativeCollisionObjSha256,
nativeCollisionFlagsSha256,
primaryPlacement,
} = input;
requireEqual(recipe?.slug, slug, "Native-core candidate recipe slug");
requireEqual(importMetadata?.dungeonId, slug, "Native-core import metadata dungeon ID");
const recipeModel = normalizeResource(recipe?.source?.model);
const recipeWdb = normalizeResource(recipe?.source?.wdb);
requireEqual(normalizeResource(importMetadata.source?.model), recipeModel, "Imported source ROS path");
requireEqual(normalizeResource(importMetadata.source?.wdb), recipeWdb, "Imported source WDB path");
const sourceModelSha256 = requireHash(actualSourceModelSha256, "Current source ROS SHA-256");
const sourceWdbSha256 = requireHash(actualSourceWdbSha256, "Current source WDB SHA-256");
requireEqual(requireHash(importMetadata.source?.modelSha256, "Imported source ROS SHA-256"), sourceModelSha256, "Imported source ROS hash");
requireEqual(requireHash(importMetadata.source?.wdbSha256, "Imported source WDB SHA-256"), sourceWdbSha256, "Imported source WDB hash");
const recipeHash = requireHash(recipeSha256, "Recipe file SHA-256");
const wdbReportHash = requireHash(wdbReportSha256, "WDB descriptor report SHA-256");
const exportReportHash = requireHash(exportReportSha256, "Native export report SHA-256");
const conversionHash = requireHash(conversionReportSha256, "Converted collision report SHA-256");
const nativeReportHash = requireHash(nativeCollisionReportSha256, "Native collision query report SHA-256");
const nativeObjHash = requireHash(nativeCollisionObjSha256, "Native collision query OBJ SHA-256");
const nativeFlagsHash = requireHash(nativeCollisionFlagsSha256, "Native collision query flags SHA-256");
const primaryDescriptor = wdbReport?.descriptors?.find((descriptor) => samePlacement(descriptor, primaryPlacement));
if (!primaryDescriptor || normalizeResource(primaryDescriptor.resource) !== recipeModel) {
throw new Error("Native-core primary WDB descriptor does not match the resolved placement and recipe ROS.");
}
requireEqual(normalizeResource(exportReport?.sourceModel), recipeModel, "Native exporter source ROS path");
requireEqual(normalizeResource(nativeCollisionReport?.sourceModel), recipeModel, "Native collision query source ROS path");
requireEqual(nativeCollisionReport?.collisionMode, "respect-options", "Native collision query mode");
requireEqual(nativeCollisionReport?.queryMasks?.ignoreEntityCollisionOption, false, "Native collision entity-option gate policy");
requireEqual(nativeCollisionReport?.renderMeshFallbackTriangleCount, 0, "Native collision render-mesh fallback count");
if (
!Number.isInteger(nativeCollisionReport?.nativeCollisionTriangleCount)
|| nativeCollisionReport.nativeCollisionTriangleCount <= 0
|| nativeCollisionReport.nativeCollisionTriangleCount !== nativeCollisionReport.enabledNativeCollisionTriangleCount
) {
throw new Error("Options-aware collision query count must equal the enabled serialized native triangle count.");
}
return Object.freeze({
recipeSha256: recipeHash,
sourceModelSha256,
sourceWdbSha256,
wdbReportSha256: wdbReportHash,
exportReportSha256: exportReportHash,
conversionReportSha256: conversionHash,
nativeCollisionReportSha256: nativeReportHash,
nativeCollisionObjSha256: nativeObjHash,
nativeCollisionFlagsSha256: nativeFlagsHash,
nativeEnabledTriangleCount: nativeCollisionReport.nativeCollisionTriangleCount,
primaryPlacementGuid: primaryDescriptor.guid,
});
}
@@ -0,0 +1,87 @@
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS, KHRMaterialsUnlit } from "@gltf-transform/extensions";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
const SHA256 = /^[\da-f]{64}$/i;
export function hasVerifiedNativeMaterialIdentity(extras) {
return typeof extras?.nativeSourceModel === "string"
&& extras.nativeSourceModel.toLowerCase().endsWith(".ros")
&& typeof extras.nativeSourceResourceSha256 === "string"
&& SHA256.test(extras.nativeSourceResourceSha256)
&& Number.isInteger(extras.nativeMaterialIndex)
&& extras.nativeMaterialIndex >= 0
&& typeof extras.nativeSourceTexture === "string"
&& extras.nativeSourceTexture.trim().length > 0;
}
/** Preserve only source-proven lighting modes on native ROS materials. */
export async function applyNativeMaterialSemantics(inputFile, outputFile = inputFile) {
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const document = await io.read(inputFile);
const materials = document.getRoot().listMaterials();
const prelitRows = [];
const additiveRows = [];
for (const material of materials) {
const extras = material.getExtras() ?? {};
if (!hasVerifiedNativeMaterialIdentity(extras)) {
if (extras.nativeLightingType === "prelit" || extras.nativeBlendMode === "additive") {
throw new Error(`Native lighting/blend metadata lacks verified material provenance: ${material.getName()}`);
}
continue;
}
if (extras.nativeLightingType === "prelit") prelitRows.push(material);
if (extras.nativeBlendMode === "additive"
&& extras.nativeAlphaPolicy === "native-srcalpha-one-additive") additiveRows.push(material);
}
if (prelitRows.length > 0) {
const unlit = document.createExtension(KHRMaterialsUnlit).setRequired(false).createUnlit();
for (const material of prelitRows) {
// Blender exports specular metadata even for source-prelit surfaces.
// glTF forbids its combination with unlit; it has no effect on prelit RGB.
material.setExtension("KHR_materials_specular", null);
material.setExtension("KHR_materials_unlit", unlit);
}
}
await io.write(outputFile, document);
const report = {
schemaVersion: 1,
input: path.resolve(inputFile),
output: path.resolve(outputFile),
khrMaterialsUnlit: prelitRows.map(material => ({
material: material.getName(),
sourceModel: material.getExtras().nativeSourceModel,
sourceResourceSha256: material.getExtras().nativeSourceResourceSha256,
nativeMaterialIndex: material.getExtras().nativeMaterialIndex,
sourceTexture: material.getExtras().nativeSourceTexture,
})),
nativeAdditiveMaterials: additiveRows.map(material => ({
material: material.getName(),
sourceModel: material.getExtras().nativeSourceModel,
sourceResourceSha256: material.getExtras().nativeSourceResourceSha256,
nativeMaterialIndex: material.getExtras().nativeMaterialIndex,
sourceTexture: material.getExtras().nativeSourceTexture,
alphaMode: material.getAlphaMode(),
})),
};
return report;
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const inputFile = process.argv[2];
if (!inputFile) throw new Error("Usage: node apply-native-material-semantics.mjs <input.glb> [report.json]");
const report = await applyNativeMaterialSemantics(inputFile);
const reportFile = process.argv[3];
if (reportFile) await writeFile(reportFile, `${JSON.stringify(report, null, 2)}\n`, "utf8");
console.log(JSON.stringify({
output: report.output,
unlitMaterialCount: report.khrMaterialsUnlit.length,
additiveMaterialCount: report.nativeAdditiveMaterials.length,
}));
}
@@ -0,0 +1,208 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { deflateSync } from "node:zlib";
import test from "node:test";
import { Document, NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS, KHRMaterialsSpecular } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
import { environmentOptimizationArguments } from "./environment-optimization.mjs";
import { applyNativeMaterialSemantics, hasVerifiedNativeMaterialIdentity } from "./native-material-semantics.mjs";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDirectory, "../../..");
const gltfTransformCli = path.join(projectRoot, "node_modules/@gltf-transform/cli/bin/cli.js");
const nativeIdentity = {
nativeSourceModel: "model\\dungeon\\the_origin\\act_precipice_01.ros",
nativeSourceResourceSha256: "b".repeat(64),
nativeMaterialIndex: 8,
nativeSourceTexture: "texture\\dungeon\\the_origin\\wave_01",
};
function crc32(bytes) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
}
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type, data) {
const typeBytes = Buffer.from(type, "ascii");
const contents = Buffer.concat([typeBytes, data]);
const header = Buffer.alloc(4);
header.writeUInt32BE(data.length);
const checksum = Buffer.alloc(4);
checksum.writeUInt32BE(crc32(contents));
return Buffer.concat([header, contents, checksum]);
}
function patternedPng() {
const header = Buffer.alloc(13);
header.writeUInt32BE(2, 0);
header.writeUInt32BE(2, 4);
header[8] = 8;
header[9] = 6;
const rows = Buffer.from([
0, 210, 170, 125, 255, 15, 80, 210, 255,
0, 25, 240, 95, 255, 160, 20, 180, 255,
]);
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
pngChunk("IHDR", header),
pngChunk("IDAT", deflateSync(rows)),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
async function writeFixture(file, extras) {
const document = new Document();
const buffer = document.createBuffer();
const position = document.createAccessor()
.setType("VEC3")
.setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]))
.setBuffer(buffer);
const material = document.createMaterial("native wave")
.setAlphaMode("BLEND")
.setExtras(extras);
material.setExtension("KHR_materials_specular",
document.createExtension(KHRMaterialsSpecular).createSpecular().setSpecularFactor(0));
const primitive = document.createPrimitive()
.setAttribute("POSITION", position)
.setMaterial(material);
const mesh = document.createMesh("wave mesh").addPrimitive(primitive);
const node = document.createNode("wave node").setMesh(mesh);
const scene = document.createScene("fixture scene").addChild(node);
document.getRoot().setDefaultScene(scene);
await new NodeIO().registerExtensions(ALL_EXTENSIONS).write(file, document);
}
test("adds KHR_materials_unlit only to exact-proven prelit materials and retains additive extras", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "rom-native-material-"));
try {
const file = path.join(directory, "wave.glb");
await writeFixture(file, {
...nativeIdentity,
nativeBlendMode: "additive",
nativeAlphaPolicy: "native-srcalpha-one-additive",
nativeLightingType: "prelit",
});
const report = await applyNativeMaterialSemantics(file);
assert.equal(report.khrMaterialsUnlit.length, 1);
assert.equal(report.nativeAdditiveMaterials.length, 1);
assert.equal(report.nativeAdditiveMaterials[0].alphaMode, "BLEND");
const document = await new NodeIO().registerExtensions(ALL_EXTENSIONS).read(file);
const material = document.getRoot().listMaterials()[0];
assert.ok(material.getExtension("KHR_materials_unlit"));
assert.equal(material.getExtension("KHR_materials_specular"), null);
assert.equal(material.getExtras().nativeBlendMode, "additive");
assert.equal(material.getAlphaMode(), "BLEND");
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("rejects prelit claims without complete native material identity", async () => {
assert.equal(hasVerifiedNativeMaterialIdentity(nativeIdentity), true);
assert.equal(hasVerifiedNativeMaterialIdentity({ ...nativeIdentity, nativeSourceResourceSha256: "bad" }), false);
const directory = await mkdtemp(path.join(os.tmpdir(), "rom-native-material-invalid-"));
try {
const file = path.join(directory, "bad.glb");
await writeFixture(file, { nativeLightingType: "prelit", ...nativeIdentity, nativeMaterialIndex: -1 });
await assert.rejects(() => applyNativeMaterialSemantics(file), /lacks verified material provenance/);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("gltf-transform optimize keeps shared-map standard and prelit materials distinct", async (context) => {
const directory = await mkdtemp(path.join(os.tmpdir(), "rom-native-material-optimize-"));
context.after(async () => rm(directory, { recursive: true, force: true }));
const sourceFile = path.join(directory, "source.glb");
const outputFile = path.join(directory, "optimized.glb");
const document = new Document();
const buffer = document.createBuffer();
const image = document.createTexture("shared-water-image")
.setMimeType("image/png")
.setImage(patternedPng());
const positions = document.createAccessor("triangle positions")
.setType("VEC3")
.setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]))
.setBuffer(buffer);
const texcoords = document.createAccessor("triangle UVs")
.setType("VEC2")
.setArray(new Float32Array([0, 0, 1, 0, 0, 1]))
.setBuffer(buffer);
const indices = document.createAccessor("triangle indices")
.setType("SCALAR")
.setArray(new Uint16Array([0, 1, 2]))
.setBuffer(buffer);
const scene = document.createScene("shared scene");
for (const [name, lightingType, nativeMaterialIndex] of [
["standard water", "standard", 8],
["prelit water", "prelit", 9],
]) {
const material = document.createMaterial(name)
.setBaseColorTexture(image)
.setExtras({ ...nativeIdentity, nativeMaterialIndex, nativeLightingType: lightingType });
const primitive = document.createPrimitive()
.setAttribute("POSITION", positions)
.setAttribute("TEXCOORD_0", texcoords)
.setIndices(indices)
.setMaterial(material);
const mesh = document.createMesh(`${name} mesh`).addPrimitive(primitive);
scene.addChild(document.createNode(name).setMesh(mesh));
}
document.getRoot().setDefaultScene(scene);
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
await io.write(sourceFile, document);
assert.ok((await io.read(sourceFile)).getRoot().listMaterials().every((material) => material.getBaseColorTexture()));
const semantics = await applyNativeMaterialSemantics(sourceFile);
assert.equal(semantics.khrMaterialsUnlit.length, 1);
// Keep the fixture materials identical apart from the glTF lighting extension,
// isolating the behavior at risk from optimizer material de-duplication.
const annotated = await io.read(sourceFile);
for (const material of annotated.getRoot().listMaterials()) material.setExtras({});
await io.write(sourceFile, annotated);
assert.equal((await io.read(sourceFile)).getRoot().listMaterials().filter((material) => material.getBaseColorTexture()).length, 2);
const args = environmentOptimizationArguments(sourceFile, outputFile);
const run = spawnSync(process.execPath, [gltfTransformCli, ...args], {
cwd: projectRoot,
encoding: "utf8",
windowsHide: true,
});
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
const optimized = await io.read(outputFile);
const materials = optimized.getRoot().listMaterials();
assert.equal(materials.length, 2, "optimization must not merge materials with different lighting modes");
const unlitMaterials = materials.filter((material) => material.getExtension("KHR_materials_unlit"));
assert.equal(unlitMaterials.length, 1);
assert.equal(materials.filter((material) => !material.getExtension("KHR_materials_unlit")).length, 1);
assert.ok(materials.every((material) => material.getBaseColorTexture()), JSON.stringify(materials.map((material) => ({
name: material.getName(),
texture: material.getBaseColorTexture()?.getName() ?? null,
unlit: Boolean(material.getExtension("KHR_materials_unlit")),
})).concat([{
textureCount: optimized.getRoot().listTextures().length,
sceneChildren: optimized.getRoot().listScenes().flatMap((entry) => entry.listChildren()).length,
primitives: optimized.getRoot().listMeshes().flatMap((mesh) => mesh.listPrimitives()).map((primitive) => ({
material: primitive.getMaterial()?.getName(),
attributes: Object.keys(primitive.listAttributes()),
})),
}])));
});
@@ -0,0 +1,51 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { ensureWithinProject } from "./native-water-build-utils.mjs";
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
async function writeOrVerify(file, bytes, expectedHash) {
await mkdir(path.dirname(file), { recursive: true });
try {
await writeFile(file, bytes, { flag: "wx" });
} catch (error) {
if (error.code !== "EEXIST") throw error;
}
const persisted = await readFile(file);
const actualHash = sha256(persisted);
if (actualHash !== expectedHash || !persisted.equals(bytes)) {
throw new Error(`Refusing to replace non-identical native water audit-cache alias: ${file}`);
}
return { sha256: actualHash, bytes: persisted.length };
}
/** Write exact source and prepared-frame aliases for the environment-quality audit. */
export async function writeNativeWaterAuditCacheAliases({
projectRoot,
auditCacheRoot,
variantName,
ddsBytes,
pngBytes,
}) {
if (!auditCacheRoot) return null;
if (!/^[a-z\d][a-z\d_-]*$/i.test(variantName)) {
throw new Error(`Native water audit alias must be a safe texture basename: ${variantName}`);
}
ensureWithinProject(projectRoot, auditCacheRoot);
const root = path.resolve(auditCacheRoot);
const ddsHash = sha256(ddsBytes);
const pngHash = sha256(pngBytes);
const ddsFile = path.join(root, "raw", "textures", `${variantName}.dds`);
const pngFile = path.join(root, "prepared", "textures", `${variantName}.png`);
const [dds, png] = await Promise.all([
writeOrVerify(ddsFile, ddsBytes, ddsHash),
writeOrVerify(pngFile, pngBytes, pngHash),
]);
const relative = (file) => path.relative(projectRoot, file).replaceAll("\\", "/");
return {
textureBasename: variantName,
rawDds: { path: relative(ddsFile), ...dds },
preparedPng: { path: relative(pngFile), ...png },
};
}
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { writeNativeWaterAuditCacheAliases } from "./native-water-audit-cache.mjs";
test("water audit-cache aliases preserve exact DDS/PNG bytes under a unique texture basename", async () => {
const projectRoot = await mkdtemp(path.join(os.tmpdir(), "native-water-audit-cache-"));
const auditCacheRoot = path.join(projectRoot, "water-audit-cache");
const ddsBytes = Buffer.from("source-dds-frame");
const pngBytes = Buffer.from("prepared-lossless-png-frame");
const options = { projectRoot, auditCacheRoot, variantName: "rom-water-normal-1234-00", ddsBytes, pngBytes };
try {
const first = await writeNativeWaterAuditCacheAliases(options);
const second = await writeNativeWaterAuditCacheAliases(options);
assert.deepEqual(second, first, "existing exact aliases are safe to reuse");
assert.match(first.rawDds.path, /raw\/textures\/rom-water-normal-1234-00\.dds$/);
assert.match(first.preparedPng.path, /prepared\/textures\/rom-water-normal-1234-00\.png$/);
assert.equal(first.rawDds.bytes, ddsBytes.length);
assert.equal(first.preparedPng.bytes, pngBytes.length);
await assert.rejects(writeNativeWaterAuditCacheAliases({
...options,
ddsBytes: Buffer.from("different source frame"),
}), /non-identical/);
await assert.rejects(writeNativeWaterAuditCacheAliases({
...options,
variantName: "../escape",
}), /safe texture basename/);
} finally {
await rm(projectRoot, { recursive: true, force: true });
}
});
@@ -0,0 +1,96 @@
import path from "node:path";
function parseCoordinate(value, label) {
let x;
let z;
if (Array.isArray(value) && value.length === 2) {
[x, z] = value;
} else if (value && typeof value === "object") {
({ x, z } = value);
} else if (typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 0xffffffff) {
x = value >>> 16;
z = value & 0xffff;
} else if (typeof value === "string") {
const normalized = value.trim().replace(/^\[/, "").replace(/\]$/, "");
const match = /^(?:chunk-x)?(-?\d+)(?:-z|,)(-?\d+)$/i.exec(normalized);
if (match) [, x, z] = match;
}
x = Number(x);
z = Number(z);
if (!Number.isInteger(x) || !Number.isInteger(z) || x < 0 || x > 0xffff || z < 0 || z > 0xffff) {
throw new Error(`${label} must identify non-negative uint16 chunk coordinates.`);
}
return { x, z, key: ((x << 16) | z) >>> 0 };
}
export function parseNativeWaterBuildArgs(argv) {
const options = {};
const valueOptions = new Set([
"recipe", "source-root", "wdb-report", "output-root", "ground-manifest", "audit-cache-root",
]);
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (token === "--help" || token === "-h") return { help: true };
if (!token.startsWith("--")) throw new Error(`Unexpected positional argument: ${token}`);
const name = token.slice(2);
if (!valueOptions.has(name)) throw new Error(`Unknown option --${name}.`);
if (options[name] !== undefined) throw new Error(`Option --${name} may only be supplied once.`);
const value = argv[index + 1];
if (!value || value.startsWith("--")) throw new Error(`Option --${name} requires a value.`);
options[name] = value;
index += 1;
}
return { help: false, ...options };
}
export function chunkCoordinateFromManifestRow(row) {
if (row?.coordinate !== undefined) return parseCoordinate(row.coordinate, "Ground-manifest chunk coordinate");
if (row?.key !== undefined) return parseCoordinate(row.key, "Ground-manifest chunk key");
if (row?.id !== undefined) return parseCoordinate(row.id, "Ground-manifest chunk id");
throw new Error("Ground-manifest chunk row has no coordinate, key, or id.");
}
export function selectNativeWaterChunkKeys({ wdbChunkKeys, groundManifest, sourceWdbSha256 }) {
const available = new Set(wdbChunkKeys.map((key) => Number(key) >>> 0));
if (!groundManifest) return { selectedKeys: [...available].sort((a, b) => a - b), selection: { kind: "all-wdb-chunks" } };
const manifestSha = groundManifest.source?.wdbSha256 ?? groundManifest.sourceWdbSha256;
if (typeof manifestSha !== "string" || !/^[a-f\d]{64}$/i.test(manifestSha)) {
throw new Error("Ground manifest must include source.wdbSha256 or sourceWdbSha256 to bind it to its source WDB.");
}
if (manifestSha.toLowerCase() !== sourceWdbSha256.toLowerCase()) {
throw new Error("Ground manifest source WDB SHA-256 does not match the water source WDB.");
}
if (!Array.isArray(groundManifest.chunks) || groundManifest.chunks.length === 0) {
throw new Error("Ground manifest must contain a non-empty chunks array.");
}
const coordinates = groundManifest.chunks.map(chunkCoordinateFromManifestRow);
const selectedKeys = new Set();
for (const coordinate of coordinates) {
if (!available.has(coordinate.key)) {
throw new Error(`Ground-manifest chunk [${coordinate.x},${coordinate.z}] is absent from the source WDB chunk table.`);
}
if (selectedKeys.has(coordinate.key)) {
throw new Error(`Ground manifest repeats chunk [${coordinate.x},${coordinate.z}].`);
}
selectedKeys.add(coordinate.key);
}
return {
selectedKeys: [...selectedKeys].sort((a, b) => a - b),
selection: {
kind: "ground-manifest-chunks",
chunkCount: selectedKeys.size,
coordinates: coordinates.map(({ x, z }) => ({ x, z })),
},
};
}
export function ensureWithinProject(projectRoot, outputPath) {
const resolved = path.resolve(outputPath);
const relative = path.relative(path.resolve(projectRoot), resolved);
if (!relative || relative === ".") return resolved;
if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) {
throw new Error(`Output path must stay inside the project workspace: ${outputPath}`);
}
return resolved;
}
@@ -0,0 +1,62 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
parseNativeWaterBuildArgs,
selectNativeWaterChunkKeys,
} from "./native-water-build-utils.mjs";
test("water builder parses recipe, source and output options without losing paths", () => {
assert.deepEqual(parseNativeWaterBuildArgs([
"--recipe", "recipes/the-origin.json",
"--source-root", "F:/rom source",
"--output-root", "runewaker-export-work/the-origin/native-water-source",
"--audit-cache-root", "runewaker-export-work/the-origin",
]), {
help: false,
recipe: "recipes/the-origin.json",
"source-root": "F:/rom source",
"output-root": "runewaker-export-work/the-origin/native-water-source",
"audit-cache-root": "runewaker-export-work/the-origin",
});
assert.throws(() => parseNativeWaterBuildArgs(["--typo", "x"]), /Unknown option/);
assert.throws(() => parseNativeWaterBuildArgs(["--recipe", "one", "--recipe", "two"]), /only be supplied once/);
});
test("water builder binds a ground selection to the identical WDB and uses exact coordinates", () => {
const sourceWdbSha256 = "a".repeat(64);
const selected = selectNativeWaterChunkKeys({
wdbChunkKeys: [0x00020002, 0x00030004, 0x00040005],
sourceWdbSha256,
groundManifest: {
source: { wdbSha256: sourceWdbSha256 },
chunks: [
{ id: "chunk-x2-z2", coordinate: { x: 2, z: 2 } },
{ key: 0x00030004 },
],
},
});
assert.deepEqual(selected.selectedKeys, [0x00020002, 0x00030004]);
assert.equal(selected.selection.chunkCount, 2);
assert.throws(() => selectNativeWaterChunkKeys({
wdbChunkKeys: [0x00020002],
sourceWdbSha256,
groundManifest: { source: { wdbSha256: "b".repeat(64) }, chunks: [{ coordinate: { x: 2, z: 2 } }] },
}), /does not match/);
assert.throws(() => selectNativeWaterChunkKeys({
wdbChunkKeys: [0x00020002],
sourceWdbSha256,
groundManifest: { source: { wdbSha256: sourceWdbSha256 }, chunks: [{ id: "chunk-x2-z2" }, { id: "chunk-x2-z2" }] },
}), /repeats chunk/);
assert.throws(() => selectNativeWaterChunkKeys({
wdbChunkKeys: [0x00020002],
sourceWdbSha256,
groundManifest: { source: { wdbSha256: sourceWdbSha256 }, chunks: [{ coordinate: { x: 9, z: 9 } }] },
}), /absent from the source WDB/);
});
test("water builder defaults to every WDB chunk when no ground manifest is supplied", () => {
const result = selectNativeWaterChunkKeys({ wdbChunkKeys: [9, 3, 5], sourceWdbSha256: "c".repeat(64) });
assert.deepEqual(result.selectedKeys, [3, 5, 9]);
assert.equal(result.selection.kind, "all-wdb-chunks");
});
@@ -0,0 +1,62 @@
import { createHash } from "node:crypto";
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
/** Validate a legacy DXT1 DDS and describe every source mip without decoding or resampling it. */
export function inspectNativeWaterDxt1Dds(bytes) {
if (!Buffer.isBuffer(bytes)) bytes = Buffer.from(bytes);
if (bytes.length < 128 || bytes.toString("ascii", 0, 4) !== "DDS ") {
throw new Error("Native water frame is not a complete DDS file.");
}
const headerSize = bytes.readUInt32LE(4);
const height = bytes.readUInt32LE(12);
const width = bytes.readUInt32LE(16);
const pixelFormatSize = bytes.readUInt32LE(76);
const formatFourCC = bytes.toString("ascii", 84, 88);
const mipCount = Math.max(1, bytes.readUInt32LE(28));
if (headerSize !== 124 || pixelFormatSize !== 32) {
throw new Error(`Unsupported DDS header sizes ${headerSize}/${pixelFormatSize}.`);
}
if (formatFourCC !== "DXT1") throw new Error(`Unsupported native water frame format ${formatFourCC}.`);
if (width < 1 || height < 1 || width % 4 !== 0 || height % 4 !== 0) {
throw new Error(`Native water DXT1 dimensions must be positive multiples of four; got ${width}x${height}.`);
}
const maxMipCount = 1 + Math.floor(Math.log2(Math.max(width, height)));
if (mipCount > maxMipCount) throw new Error(`DDS declares ${mipCount} mips for ${width}x${height}.`);
let offset = 128;
let mipWidth = width;
let mipHeight = height;
const mips = [];
for (let level = 0; level < mipCount; level += 1) {
const byteLength = Math.ceil(mipWidth / 4) * Math.ceil(mipHeight / 4) * 8;
const end = offset + byteLength;
if (end > bytes.length) throw new Error(`DDS mip ${level} is truncated.`);
const data = bytes.subarray(offset, end);
mips.push({
level,
width: mipWidth,
height: mipHeight,
offset,
byteLength,
sha256: sha256(data),
});
offset = end;
mipWidth = Math.max(1, Math.floor(mipWidth / 2));
mipHeight = Math.max(1, Math.floor(mipHeight / 2));
}
if (offset !== bytes.length) {
throw new Error(`DDS has ${bytes.length - offset} trailing bytes after its declared mip chain.`);
}
return {
width,
height,
mipCount,
formatFourCC,
headerBytes: 128,
fileBytes: bytes.length,
sha256: sha256(bytes),
mips,
exactMipChainConsumption: offset === bytes.length,
};
}
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import test from "node:test";
import { inspectNativeWaterDxt1Dds } from "./native-water-dds.mjs";
function ddsFixture({ width = 8, height = 8, mipCount = 3, trailingBytes = 0, fourCC = "DXT1" } = {}) {
const mipSizes = [];
let mipWidth = width;
let mipHeight = height;
for (let level = 0; level < mipCount; level += 1) {
mipSizes.push(Math.ceil(mipWidth / 4) * Math.ceil(mipHeight / 4) * 8);
mipWidth = Math.max(1, Math.floor(mipWidth / 2));
mipHeight = Math.max(1, Math.floor(mipHeight / 2));
}
const bytes = Buffer.alloc(128 + mipSizes.reduce((sum, size) => sum + size, 0) + trailingBytes);
bytes.write("DDS ", 0, "ascii");
bytes.writeUInt32LE(124, 4);
bytes.writeUInt32LE(height, 12);
bytes.writeUInt32LE(width, 16);
bytes.writeUInt32LE(mipCount, 28);
bytes.writeUInt32LE(32, 76);
bytes.write(fourCC, 84, "ascii");
for (let index = 128; index < bytes.length; index += 1) bytes[index] = index & 0xff;
return bytes;
}
test("native water DDS inspection hashes each declared mip and confirms exact consumption", () => {
const result = inspectNativeWaterDxt1Dds(ddsFixture());
assert.equal(result.formatFourCC, "DXT1");
assert.equal(result.width, 8);
assert.equal(result.height, 8);
assert.equal(result.mipCount, 3);
assert.equal(result.exactMipChainConsumption, true);
assert.deepEqual(result.mips.map(({ width, height, byteLength }) => ({ width, height, byteLength })), [
{ width: 8, height: 8, byteLength: 32 },
{ width: 4, height: 4, byteLength: 8 },
{ width: 2, height: 2, byteLength: 8 },
]);
assert.ok(result.mips.every((mip) => /^[a-f\d]{64}$/.test(mip.sha256)));
});
test("native water DDS inspection rejects truncated, trailing and non-DXT1 data", () => {
const complete = ddsFixture();
assert.throws(() => inspectNativeWaterDxt1Dds(complete.subarray(0, complete.length - 1)), /truncated/);
assert.throws(() => inspectNativeWaterDxt1Dds(ddsFixture({ trailingBytes: 1 })), /trailing bytes/);
assert.throws(() => inspectNativeWaterDxt1Dds(ddsFixture({ fourCC: "DXT5" })), /Unsupported native water frame format/);
});
@@ -0,0 +1,214 @@
import { Matrix4, Quaternion, Vector3 } from "three";
import { sourceTerrainToThreeMatrix } from "../sand-vortex/terrain-geometry.mjs";
const EVEN_FACES = [0, 3, 4, 0, 4, 1, 1, 4, 2, 2, 4, 5];
const ODD_FACES = [0, 3, 1, 1, 3, 4, 1, 5, 2, 1, 4, 5];
const NATIVE_UP = [0, 1, 0];
function checkedParcel(layer, parcel, label) {
if (!parcel || !ArrayBuffer.isView(parcel.values)) {
throw new Error(`${layer.waterBodyGuid} ${label} parcel has no decoded values.`);
}
const expected = parcel.width * parcel.height;
if (parcel.values.length !== expected) {
throw new Error(`${layer.waterBodyGuid} ${label} parcel has ${parcel.values.length} samples; expected ${expected}.`);
}
const sideX = parcel.width - parcel.overlap * 2;
const sideZ = parcel.height - parcel.overlap * 2;
if (sideX < 1 || sideZ < 1) throw new Error(`${layer.waterBodyGuid} ${label} parcel has no inner grid.`);
}
function layerSourceTransform(layer) {
const transform = layer.transform;
if (!transform) return new Matrix4();
const rotation = new Quaternion(...transform.rotation);
if (rotation.lengthSq() < 1e-12) throw new Error(`${layer.waterBodyGuid} has a zero rotation quaternion.`);
rotation.normalize();
const scale = new Vector3(...transform.scale);
const translation = new Vector3(...transform.translation);
if (![...scale.toArray(), ...translation.toArray()].every(Number.isFinite)
|| scale.x === 0 || scale.y === 0 || scale.z === 0) {
throw new Error(`${layer.waterBodyGuid} has an invalid entity transform.`);
}
return new Matrix4().compose(translation, rotation, scale);
}
function finitePosition(vector, label) {
if (![vector.x, vector.y, vector.z].every(Number.isFinite)) throw new Error(`${label} transformed to a non-finite position.`);
return vector;
}
function sourceColor(value, flag) {
const packed = Number(value) >>> 0;
const alphaByte = Math.trunc(((Number(flag) & 0x3f) / 63) * 255);
return [
((packed >>> 16) & 0xff) / 255,
((packed >>> 8) & 0xff) / 255,
(packed & 0xff) / 255,
alphaByte / 255,
];
}
function buildLayer(layer, sourceToThree) {
if (!layer.color?.present || !ArrayBuffer.isView(layer.color?.values)) {
throw new Error(`${layer.waterBodyGuid} cannot be built faithfully: ${layer.unbuildableReason ?? "serialized color data is missing"}.`);
}
if (layer.descriptorJoin && layer.descriptorJoin.status !== "matched") {
throw new Error(`${layer.waterBodyGuid} descriptor GUID join is ${layer.descriptorJoin.status}; refusing material guessing.`);
}
const { height, color, flags } = layer;
checkedParcel(layer, height, "height");
checkedParcel(layer, color, "color");
checkedParcel(layer, flags, "flags");
const width = height.width - height.overlap * 2;
const depth = height.height - height.overlap * 2;
if (color.width - color.overlap * 2 !== width || color.height - color.overlap * 2 !== depth
|| flags.width - flags.overlap * 2 !== width || flags.height - flags.overlap * 2 !== depth) {
throw new Error(`${layer.waterBodyGuid} water height/color/flag inner grids differ.`);
}
if (width < 3 || width % 2 !== 1 || depth < 2) {
throw new Error(`${layer.waterBodyGuid} source topology requires an odd inner width ≥3 and a depth ≥2; got ${width}x${depth}.`);
}
const vertexCount = width * depth;
if (vertexCount > 0x100000000) throw new Error(`${layer.waterBodyGuid} water grid is too large.`);
const positions = new Float32Array(vertexCount * 3);
const sourceWorldXZ = new Float32Array(vertexCount * 2);
const normals = new Float32Array(vertexCount * 3);
const colors = new Float32Array(vertexCount * 4);
const entityTransform = layerSourceTransform(layer);
let sourceSplitPlaneHeight = null;
for (let z = 0; z < depth; z += 1) {
for (let x = 0; x < width; x += 1) {
const vertex = z * width + x;
const heightSample = (x + height.overlap) + (z + height.overlap) * height.width;
const colorSample = (x + color.overlap) + (z + color.overlap) * color.width;
const alphaSample = (x + flags.overlap) + (z + flags.overlap) * flags.width;
const source = new Vector3(
x * height.spacing + height.origin[0] + height.spacing * height.overlap,
Number(height.values[heightSample]),
z * height.spacing + height.origin[2] + height.spacing * height.overlap,
);
const sourceWorld = finitePosition(source.applyMatrix4(entityTransform), `Water source vertex ${vertex}`);
if (vertex === 0) sourceSplitPlaneHeight = sourceWorld.y;
const output = finitePosition(sourceWorld.clone().applyMatrix4(sourceToThree), `Water vertex ${vertex}`);
positions.set(output.toArray(), vertex * 3);
// KHR_materials_variants normalTexture carriers require TEXCOORD_0 for
// Khronos validation. This native-world XZ stream is only a glTF
// compatibility carrier; the runtime shader recovers its own source
// position from world transforms and does not sample this attribute.
sourceWorldXZ.set([sourceWorld.x, sourceWorld.z], vertex * 2);
// The native vertex format is POSITION|DIFFUSE. Its water shader uses a
// fixed world-up normal for spherical-harmonic lighting; this NORMAL is
// only a static viewport/rendering carrier for that source behavior.
normals.set(NATIVE_UP, vertex * 3);
colors.set(sourceColor(color.values[colorSample], flags.values[alphaSample]), vertex * 4);
}
}
const indices = [];
const visiblePairs = [];
let consideredPairs = 0;
for (let z = 0; z < depth - 1; z += 1) {
const faces = (z & 1) === 0 ? EVEN_FACES : ODD_FACES;
for (let x = 0; x < width - 1; x += 2) {
consideredPairs += 1;
const rowStart = z * width + x;
const indexSet = [rowStart, rowStart + 1, rowStart + 2,
rowStart + width, rowStart + width + 1, rowStart + width + 2];
const pairIndices = faces.map((slot) => indexSet[slot]);
let visible = false;
for (const sourceIndex of pairIndices) {
// Match Mojo_BuildMeshFromHeightField: visibility checks the direct flat
// attribute index. Per-vertex diffuse alpha, in contrast, accounts for
// the flags parcel's overlap gap above.
if (sourceIndex >= flags.values.length) {
throw new Error(`${layer.waterBodyGuid} visibility index ${sourceIndex} exceeds the native flags array (${flags.values.length}).`);
}
if ((flags.values[sourceIndex] & 0x80) !== 0) visible = true;
}
if (!visible) continue;
for (let face = 0; face < pairIndices.length; face += 3) {
const a = pairIndices[face];
const b = pairIndices[face + 1];
const c = pairIndices[face + 2];
// sourceTerrainToThreeMatrix includes the same reflected-Z bridge used
// by native terrain; invert the source triangle order once for glTF.
indices.push(a, c, b);
}
visiblePairs.push({ gridX: x, gridZ: z });
}
}
const IndexArray = vertexCount > 0xffff ? Uint32Array : Uint16Array;
let minHeight = Number.POSITIVE_INFINITY;
let maxHeight = Number.NEGATIVE_INFINITY;
for (let offset = 1; offset < positions.length; offset += 3) {
minHeight = Math.min(minHeight, positions[offset]);
maxHeight = Math.max(maxHeight, positions[offset]);
}
return {
layer,
positions,
sourceWorldXZ,
normals,
colors,
indices: IndexArray.from(indices),
summary: {
waterBodyGuid: layer.waterBodyGuid,
layerVersion: layer.version,
descriptorJoin: layer.descriptorJoin,
grid: { width, height: depth, sourceVertexCount: vertexCount },
consideredTwoCellQuadPairs: consideredPairs,
emittedTwoCellQuadPairs: visiblePairs.length,
emittedTriangles: indices.length / 3,
emittedIndexCount: indices.length,
planAreaSquareSourceUnits: visiblePairs.length * 2 * height.spacing * height.spacing,
sourceWaterSpacing: height.spacing,
sourceSplitPlaneHeight,
outputHeightRangeMeters: { min: minHeight, max: maxHeight },
alpha: {
sourceBits: "flags & 0x3f",
nativeByteConversion: "trunc((flags & 0x3f) / 63 * 255) / 255",
visibilityBit: "0x80, applied at 2-cell quad-pair topology level",
},
positionsTransform: "terrain sourceToThree matrix (inverse primary WDB TRS + reflected-Z meter bridge) multiplied by serialized water entity TRS",
normals: "constant world-up [0,1,0] carrier; native water source has no NORMAL attribute",
textureCoordinates: "TEXCOORD_0 carries source-world XZ for glTF normal-texture compatibility only; runtime animation samples transformed world coordinates and ignores mesh UVs",
nativePairSamples: visiblePairs.slice(0, 12),
},
};
}
/** Build source water layers in the exact primary-terrain coordinate bridge. */
export function buildNativeWaterGeometry(layers, primaryPlacement, metersPerSourceUnit) {
if (!Array.isArray(layers)) throw new TypeError("Native water layers must be an array.");
const sourceToThree = sourceTerrainToThreeMatrix(primaryPlacement, metersPerSourceUnit);
const built = [];
const skipped = [];
for (const layer of layers) {
if (layer.unbuildableReason) {
skipped.push({ waterBodyGuid: layer.waterBodyGuid, reason: layer.unbuildableReason });
continue;
}
built.push(buildLayer(layer, sourceToThree));
}
return {
layers: built,
skipped,
report: {
layerCount: layers.length,
builtLayerCount: built.length,
skippedLayerCount: skipped.length,
sourceToThree: "same sourceTerrainToThreeMatrix used by native terrain: reflected-Z meter bridge multiplied by inverse primary WDB descriptor TRS",
nativeWinding: "source triangle index order is reversed exactly once after the reflected-Z bridge",
nativeGeometryResponsibility: "this module serializes water geometry, native visibility masks, vertex RGBA, topology, and source placement transforms; animated source bump normals are supplied by src/scene/runewakerWaterMaterial.ts",
nativeMaterialLimit: "browser scene reflection/refraction uses the source enable property and Fresnel equation; native SH, reflect-class filtering, and unknown effectType names remain unsupported",
layers: built.map((entry) => entry.summary),
skipped,
},
};
}
export const NATIVE_WATER_FACE_CONSTRUCTION = Object.freeze({ evenRows: EVEN_FACES, oddRows: ODD_FACES });
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildNativeWaterGeometry } from "./native-water-geometry.mjs";
const primaryPlacement = {
translation: [2, 3, 4],
rotation: [0, 0, 0, 1],
scale: [1, 1, 1],
};
function fixtureLayer({ flags = [0xff, 0xdf, 0x9f, 0xbf, 0x80, 0x00, 0xff, 0x9f, 0xbf] } = {}) {
const colors = [
0xff112233, 0xff445566, 0xff778899,
0xffaabbcc, 0xffddeeff, 0xff010203,
0xff102030, 0xff405060, 0xff708090,
];
return {
classGuid: "test-water",
version: 3,
waterBodyGuid: "exact-body-guid",
transform: { scale: [2, 1, 1], rotation: [0, 0, 0, 1], translation: [100, 5, 200] },
height: {
origin: [0, 0, 0], spacing: 10, overlap: 0, width: 3, height: 3,
values: Float32Array.from([1, 2, 3, 4, 5, 6, 7, 8, 9]),
},
color: {
present: true, origin: [0, 0, 0], spacing: 10, overlap: 0, width: 3, height: 3,
values: Uint32Array.from(colors),
},
flags: {
origin: [0, 0, 0], spacing: 10, overlap: 0, width: 3, height: 3,
values: Uint8Array.from(flags),
},
descriptorJoin: {
strategy: "exact serialized body GUID bytes",
status: "matched",
descriptorKeys: ["body-row"],
descriptors: [{ key: "body-row", guid: "exact-body-guid", effectType: 4 }],
},
unbuildableReason: null,
};
}
function assertClose(actual, expected, epsilon = 1e-6) {
assert.equal(actual.length, expected.length);
for (let index = 0; index < expected.length; index += 1) {
assert.ok(Math.abs(actual[index] - expected[index]) <= epsilon, `value[${index}] ${actual[index]} != ${expected[index]}`);
}
}
test("native water geometry expands even and odd pair patterns with one reflected-Z winding reversal", () => {
const built = buildNativeWaterGeometry([fixtureLayer()], primaryPlacement, 0.1);
const [water] = built.layers;
assert.equal(water.summary.consideredTwoCellQuadPairs, 2);
assert.equal(water.summary.emittedTwoCellQuadPairs, 2);
assert.equal(water.summary.emittedTriangles, 8);
assert.deepEqual(Array.from(water.indices.slice(0, 12)), [0, 4, 3, 0, 1, 4, 1, 2, 4, 2, 5, 4]);
assert.deepEqual(Array.from(water.indices.slice(12, 24)), [3, 4, 6, 4, 7, 6, 4, 5, 8, 4, 8, 7]);
assertClose(Array.from(water.positions.slice(0, 3)), [9.8, 0.3, -19.6]);
assert.equal(water.summary.sourceSplitPlaneHeight, 6);
assertClose(Array.from(water.positions.slice(3, 6)), [11.8, 0.4, -19.6]);
assertClose(Array.from(water.sourceWorldXZ.slice(0, 6)), [100, 200, 120, 200, 140, 200]);
assertClose(Array.from(water.sourceWorldXZ.slice(6, 12)), [100, 210, 120, 210, 140, 210]);
assert.deepEqual(Array.from(water.normals.slice(0, 3)), [0, 1, 0]);
assertClose(Array.from(water.colors.slice(0, 4)), [17 / 255, 34 / 255, 51 / 255, 1]);
assertClose([water.colors[1 * 4 + 3]], [125 / 255]);
assert.equal(water.layer.descriptorJoin.descriptors[0].effectType, 4);
});
test("water visibility bit masks pair geometry while preserving per-vertex diffuse alpha", () => {
const hidden = buildNativeWaterGeometry([fixtureLayer({ flags: new Array(9).fill(0x3f) })], primaryPlacement, 0.1);
assert.equal(hidden.layers[0].indices.length, 0);
assert.equal(hidden.layers[0].colors[3], 1);
const visibleFromOneVertex = buildNativeWaterGeometry([fixtureLayer({ flags: [0x80, 0, 0, 0, 0, 0, 0, 0, 0] })], primaryPlacement, 0.1);
assert.equal(visibleFromOneVertex.layers[0].summary.emittedTriangles, 4);
assert.equal(visibleFromOneVertex.layers[0].colors[3], 0);
});
test("native water builder refuses non-exact material joins and missing source color bytes", () => {
const unmatched = fixtureLayer();
unmatched.descriptorJoin.status = "missing";
assert.throws(() => buildNativeWaterGeometry([unmatched], primaryPlacement, 0.1), /descriptor GUID join is missing/);
const v1 = fixtureLayer();
v1.color = { present: false };
v1.unbuildableReason = "native v1 color absent";
const result = buildNativeWaterGeometry([v1], primaryPlacement, 0.1);
assert.equal(result.layers.length, 0);
assert.equal(result.skipped[0].reason, "native v1 color absent");
});
@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { Document, NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS, KHRMaterialsVariants } from "@gltf-transform/extensions";
import sharp from "sharp";
import { MeshoptDecoder } from "meshoptimizer";
import { environmentOptimizationArguments } from "../lib/environment-optimization.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const gltfTransformCli = path.join(projectRoot, "node_modules/@gltf-transform/cli/bin/cli.js");
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
test("real glTF Transform optimize preserves opted-in material variant carriers and UVs", async (context) => {
await MeshoptDecoder.ready;
const workingDirectory = await mkdtemp(path.join(os.tmpdir(), "runewaker-water-variant-opt-"));
context.after(async () => rm(workingDirectory, { recursive: true, force: true }));
const sourceFile = path.join(workingDirectory, "water-variant-source.glb");
const outputFile = path.join(workingDirectory, "water-variant-optimized.glb");
const document = new Document();
const buffer = document.createBuffer("fixture");
const variants = document.createExtension(KHRMaterialsVariants).setRequired(false);
const normalPng = await sharp({
create: { width: 4, height: 4, channels: 4, background: { r: 128, g: 128, b: 255, alpha: 1 } },
}).png().toBuffer();
const normalTexture = document.createTexture("native-normal-frame")
.setImage(normalPng)
.setMimeType("image/png");
const regularMaterial = document.createMaterial("source-water").setAlphaMode("BLEND");
const frameMaterial = document.createMaterial("normal-frame-00")
.setNormalTexture(normalTexture)
.setAlphaMode("OPAQUE");
frameMaterial.getNormalTextureInfo().setWrapS(10497).setWrapT(10497);
const variant = variants.createVariant("native-frame-00");
const mapping = variants.createMappingList().addMapping(
variants.createMapping().setMaterial(frameMaterial).addVariant(variant),
);
const primitive = document.createPrimitive()
.setAttribute("POSITION", document.createAccessor("positions", buffer)
.setType("VEC3")
.setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 0, 1])))
.setAttribute("NORMAL", document.createAccessor("normals", buffer)
.setType("VEC3")
.setArray(new Float32Array([0, 1, 0, 0, 1, 0, 0, 1, 0])))
.setAttribute("TEXCOORD_0", document.createAccessor("source-world-xz", buffer)
.setType("VEC2")
.setArray(new Float32Array([100, 200, 110, 200, 100, 210])))
.setIndices(document.createAccessor("indices", buffer).setArray(new Uint16Array([0, 1, 2])))
.setMaterial(regularMaterial)
.setExtension(KHRMaterialsVariants.EXTENSION_NAME, mapping);
const mesh = document.createMesh("native-water").addPrimitive(primitive);
const scene = document.createScene("scene").addChild(document.createNode("water-layer").setMesh(mesh));
document.getRoot().setDefaultScene(scene);
await io.write(sourceFile, document);
const args = environmentOptimizationArguments(sourceFile, outputFile, { preserveMaterialVariantCarriers: true });
assert.equal(args[args.indexOf("--join") + 1], "false");
assert.equal(args[args.indexOf("--prune") + 1], "false");
assert.equal(args[args.indexOf("--prune-attributes") + 1], "false");
const waterArgs = environmentOptimizationArguments(sourceFile, outputFile, { preserveNativeWaterLayers: true });
assert.equal(waterArgs[waterArgs.indexOf("--join") + 1], "false", "native water keeps its existing carrier-preservation behavior");
const optimized = spawnSync(process.execPath, [gltfTransformCli, ...args], {
cwd: projectRoot,
encoding: "utf8",
windowsHide: true,
});
assert.equal(optimized.status, 0, `${optimized.stdout}\n${optimized.stderr}`);
const output = await io.read(outputFile);
const outputMesh = output.getRoot().listMeshes().find((candidate) => candidate.getName() === "native-water");
const outputPrimitive = outputMesh?.listPrimitives()[0];
assert.ok(outputPrimitive, "optimized water primitive must remain present");
const texcoords = outputPrimitive.getAttribute("TEXCOORD_0");
assert.ok(texcoords, "normal-texture variant must retain its required UV0 attribute");
assert.equal(texcoords.getType(), "VEC2");
assert.deepEqual(Array.from(texcoords.getArray()), [100, 200, 110, 200, 100, 210]);
const outputMapping = outputPrimitive.getExtension(KHRMaterialsVariants.EXTENSION_NAME)?.listMappings()[0];
assert.ok(outputMapping, "KHR_materials_variants mapping must survive optimize");
assert.equal(outputMapping.getMaterial()?.getNormalTexture()?.getName(), "native-normal-frame");
assert.deepEqual(outputMapping.listVariants().map((item) => item.getName()), ["native-frame-00"]);
assert.ok((await readFile(outputFile)).length > 0);
});
@@ -0,0 +1,341 @@
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,
};
}
@@ -0,0 +1,176 @@
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/);
});
@@ -0,0 +1,15 @@
/** Mirrors BuildWaterBodyMaterial's branch without assigning names to raw 4. */
export function nativeWaterRenderPolicy(descriptor) {
const property = descriptor.baseObject?.properties?.find(row => row.name === "waterDesc:full:enable");
if (property && (property.type !== 2 || !Number.isInteger(property.value))) {
throw new Error("Native water full-model enable must be a serialized BOOL.");
}
const fullModelRequested = property ? property.value !== 0 : false;
const eligibleBody = descriptor.bodyType === 0 || descriptor.bodyType === 2;
return {
fullModelEnabled: eligibleBody && descriptor.effectType !== 2 && fullModelRequested,
bodyTypeRaw: descriptor.bodyType,
fullModelRequested,
sourceBranch: "NORMAL/SOLID body, non-DIRECT effect, waterDesc:full:enable; browser enables reflection/refraction together",
};
}
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { nativeWaterRenderPolicy } from "./native-water-render-policy.mjs";
const descriptor = (bodyType = 0, effectType = 4, value = 1, type = 2) => ({
bodyType, effectType,
baseObject: { properties: [{ name: "waterDesc:full:enable", type, value }] },
});
test("follows the source non-DIRECT branch for raw 4 without guessing its name", () => {
assert.equal(nativeWaterRenderPolicy(descriptor()).fullModelEnabled, true);
assert.equal(nativeWaterRenderPolicy(descriptor(2)).fullModelEnabled, true);
assert.equal(nativeWaterRenderPolicy(descriptor(0, 2)).fullModelEnabled, false);
assert.equal(nativeWaterRenderPolicy(descriptor(1)).fullModelEnabled, false);
assert.equal(nativeWaterRenderPolicy(descriptor(0, 4, 0)).fullModelEnabled, false);
assert.equal(nativeWaterRenderPolicy({ bodyType: 0, effectType: 4 }).fullModelEnabled, false);
assert.throws(() => nativeWaterRenderPolicy(descriptor(0, 4, 1, 8)), /BOOL/);
});
@@ -0,0 +1,136 @@
const SHA256 = /^[a-f0-9]{64}$/i;
function resourceKey(value) {
return String(value ?? "").replaceAll("/", "\\").toLowerCase();
}
function requireObject(value, label) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${label} must be an object.`);
}
return value;
}
export function buildOriginNativeMaterialProvenance({ metadata, assembly, semantics, conversionReports }) {
requireObject(metadata, "Import metadata");
requireObject(assembly, "Assembly evidence");
requireObject(semantics, "Native semantics report");
if (!Array.isArray(assembly.stagedOverrides) || assembly.stagedOverrides.length < 1) {
throw new Error("Assembly evidence must contain one or more staged overrides.");
}
if (!Array.isArray(assembly.resources)) {
throw new Error("Assembly evidence is missing its authoritative source resource rows.");
}
if (!Array.isArray(semantics.khrMaterialsUnlit) || !Array.isArray(semantics.nativeAdditiveMaterials)) {
throw new Error("Native semantics report is missing its verified unlit/additive material lists.");
}
if (!SHA256.test(assembly.sourceWdbSha256 ?? "") || !SHA256.test(assembly.assembledVisual?.sha256 ?? "")) {
throw new Error("Assembly evidence is missing a valid source WDB or assembled-source SHA-256.");
}
if (assembly.sourceWdbReportSha256 != null && !SHA256.test(assembly.sourceWdbReportSha256)) {
throw new Error("Assembly evidence has an invalid source WDB report SHA-256.");
}
const reportEntries = conversionReports instanceof Map
? conversionReports
: new Map(Object.entries(conversionReports ?? {}).map(([key, value]) => [resourceKey(key), value]));
const nativeAlphaResources = [];
const metadataResources = [];
const seenResources = new Set();
const authoritativeHashes = new Map();
for (const override of assembly.stagedOverrides) {
const key = resourceKey(override.resource);
if (!key || seenResources.has(key)) throw new Error(`Staged override resource is missing or duplicated: ${override.resource}.`);
seenResources.add(key);
const sourceRows = assembly.resources.filter((row) => resourceKey(row.resource) === key);
if (sourceRows.length !== 1) throw new Error(`Expected one authoritative assembly resource row for ${override.resource}; found ${sourceRows.length}.`);
const sourceRow = sourceRows[0];
if (!SHA256.test(sourceRow.sourceSha256 ?? "")) throw new Error(`Invalid assembly source SHA-256 for ${override.resource}.`);
if (Number(sourceRow.placementCount) !== Number(override.placements)) {
throw new Error(`Placement count mismatch for ${override.resource}.`);
}
const conversion = reportEntries.get(key);
const nativeAlpha = conversion?.visual?.materials?.nativeAlpha;
if (!nativeAlpha || !Array.isArray(nativeAlpha.materials)) {
throw new Error(`Missing native alpha material evidence for ${override.resource}.`);
}
const sourceSha256 = nativeAlpha.sourceResourceSha256;
if (!SHA256.test(sourceSha256 ?? "")) {
throw new Error(`Invalid native source hash for ${override.resource}.`);
}
if (resourceKey(nativeAlpha.sourceModel) !== key) {
throw new Error(`Source model identity mismatch for ${override.resource}: ${nativeAlpha.sourceModel}.`);
}
if (resourceKey(conversion.source?.model) !== key || conversion.source?.resourceSha256 !== sourceSha256) {
throw new Error(`Conversion source provenance mismatch for ${override.resource}.`);
}
if (sourceRow.sourceSha256 !== sourceSha256) {
throw new Error(`Conversion source SHA-256 does not match the authoritative assembly row for ${override.resource}.`);
}
if (nativeAlpha.materials.some((material) =>
resourceKey(material.sourceModel) !== key || material.sourceResourceSha256 !== sourceSha256)) {
throw new Error(`Material-level native identity does not match ${override.resource}.`);
}
authoritativeHashes.set(key, sourceSha256);
nativeAlphaResources.push({
resource: override.resource,
placements: override.placements,
sourceResourceSha256: sourceSha256,
nativeAlpha,
});
metadataResources.push({
resource: override.resource,
placements: override.placements,
sourceResourceSha256: sourceSha256,
});
}
for (const semanticMaterial of [...semantics.khrMaterialsUnlit, ...semantics.nativeAdditiveMaterials]) {
const key = resourceKey(semanticMaterial.sourceModel);
if (!key || !authoritativeHashes.has(key) || semanticMaterial.sourceResourceSha256 !== authoritativeHashes.get(key)) {
throw new Error(`Unlit/additive material has no exact staged source identity: ${semanticMaterial.sourceModel ?? "<missing>"}.`);
}
}
const assemblyProvenance = {
sourceWdbSha256: assembly.sourceWdbSha256,
sourceWdbReportSha256: assembly.sourceWdbReportSha256 ?? null,
assembledSourceSha256: assembly.assembledVisual.sha256,
staticPlacements: assembly.staticPlacementCount,
uniqueResources: assembly.uniqueResourceCount,
dynamicPlacements: assembly.dynamicPlacementCount,
stagedOverrideCount: assembly.stagedOverrides.length,
primaryResource: assembly.primaryResource,
primarySourceSha256: assembly.primarySourceSha256,
};
const alphaReport = {
schemaVersion: 2,
source: "native material conversion reports joined by exact ROS resource identity and source SHA-256",
assembly: assemblyProvenance,
resources: nativeAlphaResources,
};
const semanticsReport = {
schemaVersion: 1,
source: "native material semantics annotated before gltf-transform optimization",
assembly: assemblyProvenance,
materialCounts: semantics.materialCounts ?? null,
sharedMapLightingPair: semantics.sharedMapLightingPair ?? null,
khrMaterialsUnlit: semantics.khrMaterialsUnlit,
nativeAdditiveMaterials: semantics.nativeAdditiveMaterials,
limitation: "This report records source-proven material semantics for the listed staged overrides; it does not certify every surface as visually correct.",
};
const updatedMetadata = structuredClone(metadata);
updatedMetadata.nativeMaterialAlpha = {
report: "native-material-alpha-report.json",
resources: metadataResources,
};
updatedMetadata.nativeMaterialSemantics = {
report: "native-material-semantics-report.json",
stagedOverrideCount: assembly.stagedOverrides.length,
additiveMaterialCount: semantics.nativeAdditiveMaterials.length,
unlitMaterialCount: semantics.khrMaterialsUnlit.length,
assembledSourceSha256: assembly.assembledVisual.sha256,
};
return { metadata: updatedMetadata, alphaReport, semanticsReport };
}
@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildOriginNativeMaterialProvenance } from "./origin-native-material-provenance.mjs";
const resources = [
"model\\dungeon\\the_origin\\act_precipice_01.ros",
"model\\dungeon\\the_origin\\act_precipice_02.ros",
"model\\element\\wailing_mountains\\prop\\waterfall\\act_waterfall02.ros",
"model\\element\\elves_island\\vegetation\\gaint_tree_a.ros",
"model\\element\\weeping_coast\\vegetation\\n_tree_02.ros",
"model\\dungeon\\the_origin\\tree_01_01.ros",
"model\\dungeon\\the_origin\\act_cocoon_01.ros",
];
function fixture() {
const conversionReports = new Map();
const stagedOverrides = resources.map((resource, index) => {
const sha256 = String(index + 1).padStart(64, "0");
conversionReports.set(resource.toLowerCase(), {
source: { model: resource, resourceSha256: sha256 },
visual: {
materials: {
nativeAlpha: {
sourceModel: resource,
sourceResourceSha256: sha256,
materials: [{ sourceModel: resource, sourceResourceSha256: sha256, materialIndex: 1 }],
},
},
},
});
return { resource, file: `staging/${index}/visual.glb`, placements: index + 1 };
});
return {
conversionReports,
assembly: {
sourceWdbSha256: "a".repeat(64),
sourceWdbReportSha256: "b".repeat(64),
primaryResource: "model\\dungeon\\the_origin\\cathedral_01_b.ros",
primarySourceSha256: "c".repeat(64),
staticPlacementCount: 693,
uniqueResourceCount: 220,
dynamicPlacementCount: 0,
resources: stagedOverrides.map((override, index) => ({
resource: override.resource.replaceAll("\\", "/"),
placementCount: override.placements,
sourceSha256: String(index + 1).padStart(64, "0"),
})),
stagedOverrides,
assembledVisual: { sha256: "d".repeat(64) },
},
semantics: {
khrMaterialsUnlit: [{ sourceModel: resources[2], sourceResourceSha256: String(3).padStart(64, "0") }],
nativeAdditiveMaterials: [{ sourceModel: resources[0], sourceResourceSha256: String(1).padStart(64, "0"), alphaMode: "BLEND" }],
materialCounts: { total: 2 },
sharedMapLightingPair: { note: "same map, different native lighting identity" },
},
metadata: {
title: "The Origin",
entry: { x: 1, y: 2, z: 3 },
sourceTerrain: { resource: "terrain/source.wdb", chunkCount: 4 },
nativeTerrain: { report: "terrain-report.json", sourceSha256: "e".repeat(64) },
assets: [{ role: "visual", sha256: "old-visual-hash" }],
nativeMaterialAlpha: { report: "old-report.json", resources: [] },
},
};
}
test("joins staged Origin overrides by authoritative native ROS identity while retaining current metadata", () => {
const data = fixture();
const originalEntry = structuredClone(data.metadata.entry);
const originalSourceTerrain = structuredClone(data.metadata.sourceTerrain);
const originalNativeTerrain = structuredClone(data.metadata.nativeTerrain);
const originalAssets = structuredClone(data.metadata.assets);
const output = buildOriginNativeMaterialProvenance(data);
assert.equal(output.alphaReport.resources.length, resources.length);
assert.equal(output.alphaReport.resources[2].nativeAlpha.materials[0].sourceModel, resources[2]);
assert.equal(output.metadata.nativeMaterialAlpha.resources[6].placements, 7);
assert.equal(output.metadata.nativeMaterialSemantics.unlitMaterialCount, 1);
assert.equal(output.metadata.nativeMaterialSemantics.additiveMaterialCount, 1);
assert.deepEqual(output.metadata.entry, originalEntry);
assert.deepEqual(output.metadata.sourceTerrain, originalSourceTerrain);
assert.deepEqual(output.metadata.nativeTerrain, originalNativeTerrain);
assert.deepEqual(output.metadata.assets, originalAssets);
assert.equal(data.metadata.nativeMaterialAlpha.report, "old-report.json");
});
test("rejects conversion evidence whose ROS identity or source hash does not match its placement override", () => {
const data = fixture();
const first = data.assembly.stagedOverrides[0].resource.toLowerCase();
data.conversionReports.get(first).visual.materials.nativeAlpha.sourceResourceSha256 = "not-a-sha";
assert.throws(() => buildOriginNativeMaterialProvenance(data), /Invalid native source hash/);
});
test("rejects staged source hashes that disagree with authoritative assembly resource rows", () => {
const data = fixture();
data.assembly.resources[0].sourceSha256 = "f".repeat(64);
assert.throws(() => buildOriginNativeMaterialProvenance(data), /does not match the authoritative assembly row/);
});
@@ -0,0 +1,14 @@
import { readFile } from "node:fs/promises";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { applyRunewakerDerivedSupportStyling } from "./rom-derived-support-styling.mjs";
export async function applyRunewakerDerivedSupportStylingToFile(file, options) {
if (!options.policyId) return { status: "not-requested", geometryChanged: false };
const sourceDdsBytes = await readFile(options.sourceDdsFile);
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const document = await io.read(file);
const report = applyRunewakerDerivedSupportStyling(document, { ...options, sourceDdsBytes });
if (report.status === "applied") await io.write(file, document);
return report;
}
@@ -0,0 +1,384 @@
import { createHash } from "node:crypto";
import { Accessor } from "@gltf-transform/core";
const POLICIES = Object.freeze({
"rom-demon-stronghold-derived-support-entrance-floor-02": Object.freeze({
id: "rom-demon-stronghold-derived-support-entrance-floor-02",
version: 1,
dungeonId: "demon-stronghold",
sourceModel: "model/dungeon/devil_city/act_devil_city.ros",
sourceModelSha256: "44f2f455b1895192689e9bc70fa80a5aef70a32c98799540827b30d6c9e7d47b",
sourceMaterial: "runewaker_material_32",
sourceTexture: "texture/dungeon/devil_city/entrance_floor_02",
stagedTexture: "5825da77_entrance_floor_02.dds",
sourceDdsSha256: "58911f1b62882c68b7f1f1024f4af0df47f86586ae37def4153e1bc77e2fff6c",
gltfTexture: "5825da77_entrance_floor_02",
convertedPngSha256: "99d89ac06969dbc39a9071b68b264b97ea7a91c8139e8983d1e2b8ef5f1d5980",
imageSize: [512, 512],
supportNode: "runewaker_source_anchor_support",
supportMaterial: "runewaker_source_anchor_support",
expectedPadCount: 186,
expectedTriangleCount: 2232,
metersPerUv: 1 / 0.0707851783,
uvPerMeter: 0.0707851783,
anchorWorld: [-5.025092, -0.220868, -0.382562],
anchorUv: [0.5238620214, 4.69202905],
}),
"rom-mystic-altar-derived-support-mystic-floor": Object.freeze({
id: "rom-mystic-altar-derived-support-mystic-floor",
version: 1,
dungeonId: "mystic-altar",
sourceModel: "model/dungeon/mystic_altar/act_mystic_altar_instance.ros",
sourceModelSha256: "14974678994c48a7315a588c7fbcdd6cdcae9c3313cf1b48a470ed736a14b85e",
sourceMaterial: "runewaker_material_236",
sourceTexture: "texture/dungeon/mystic_altar/mystic_altar_floor",
stagedTexture: "caa8475c_mystic_altar_floor.dds",
sourceDdsSha256: "58db1826f84f913676e79c89f40126afadb9569daa056c789ad7d73faafc7223",
gltfTexture: "caa8475c_mystic_altar_floor",
convertedPngSha256: "ef1d7d6d6876ca650d9318804eaba7e706feeb9a3df66c7099a735d63f8b3dd6",
imageSize: [512, 512],
supportNode: "runewaker_source_anchor_support",
supportMaterial: "runewaker_source_anchor_support",
expectedPadCount: 250,
expectedTriangleCount: 3000,
metersPerUv: 1 / 0.043312560155488414,
uvPerMeter: 0.043312560155488414,
anchorWorld: [-16.99230033164831, -0.9904689643118673, -28.474957258355573],
anchorUv: [1.0586127324699568, 0.5470526715423152],
nativeWorldUvBasis: Object.freeze({
dUdx: 0.043312560155488414,
dUdz: 0,
dVdx: 0,
dVdz: 0.04331255484774635,
sideUvDelta: [0.043312560155488414, 0.04331255484774635],
sourcePrimitive: "Object03_236_mesh_0",
sourceTriangle: 13,
}),
}),
});
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
const normalizeResource = (value) => String(value ?? "").replaceAll("\\", "/").replace(/^\/+/, "").toLowerCase();
const accessorHash = (accessor) => {
const array = accessor.getArray();
return sha256(Buffer.from(array.buffer, array.byteOffset, array.byteLength));
};
function proveSource(policy, dungeonId, sourceReport, sourceDdsBytes) {
if (dungeonId !== policy.dungeonId) throw new Error(`Derived-support style ${policy.id} cannot run for ${dungeonId}.`);
if (normalizeResource(sourceReport?.sourceModel) !== policy.sourceModel.toLowerCase()) {
throw new Error(`Derived-support style ${policy.id} source ROS path does not match the reviewed model.`);
}
if (String(sourceReport?.sourceResourceSha256 ?? "").toLowerCase() !== policy.sourceModelSha256) {
throw new Error(`Derived-support style ${policy.id} source ROS SHA-256 does not match the reviewed model.`);
}
const materials = (sourceReport.materials ?? []).filter((material) => material.name === policy.sourceMaterial);
if (materials.length !== 1) throw new Error(`Derived-support style ${policy.id} expected one exact native material record; found ${materials.length}.`);
const material = materials[0];
if (normalizeResource(material.sourceTexture) !== policy.sourceTexture.toLowerCase()) {
throw new Error(`Derived-support style ${policy.id} native diffuse texture path does not match the reviewed floor.`);
}
if (normalizeResource(material.stagedTexture).split("/").at(-1) !== policy.stagedTexture.toLowerCase()) {
throw new Error(`Derived-support style ${policy.id} staged DDS basename does not match the reviewed floor.`);
}
if (!sourceDdsBytes || sha256(sourceDdsBytes) !== policy.sourceDdsSha256) {
throw new Error(`Derived-support style ${policy.id} source DDS hash does not match the reviewed floor.`);
}
return {
sourceModel: policy.sourceModel,
sourceModelSha256: policy.sourceModelSha256,
sourceNativeMaterial: policy.sourceMaterial,
sourceTexture: policy.sourceTexture,
stagedTexture: policy.stagedTexture,
sourceDdsSha256: policy.sourceDdsSha256,
convertedPngSha256: policy.convertedPngSha256,
};
}
function pngDimensions(bytes) {
if (bytes.length < 24 || bytes.toString("hex", 0, 8) !== "89504e470d0a1a0a") return null;
return [bytes.readUInt32BE(16), bytes.readUInt32BE(20)];
}
function findTarget(document, policy) {
const root = document.getRoot();
const floorMaterials = root.listMaterials().filter((material) => material.getName() === policy.sourceMaterial);
if (floorMaterials.length !== 1) throw new Error(`Derived-support style ${policy.id} expected one converted native floor material; found ${floorMaterials.length}.`);
const floorMaterial = floorMaterials[0];
const floorTexture = floorMaterial.getBaseColorTexture();
if (floorTexture?.getName() !== policy.gltfTexture || !floorMaterial.getBaseColorTextureInfo()) {
throw new Error(`Derived-support style ${policy.id} converted floor image does not match the reviewed texture.`);
}
const image = Buffer.from(floorTexture.getImage() ?? []);
if (sha256(image) !== policy.convertedPngSha256) throw new Error(`Derived-support style ${policy.id} converted floor image hash changed.`);
const dimensions = pngDimensions(image);
if (!dimensions || dimensions[0] !== policy.imageSize[0] || dimensions[1] !== policy.imageSize[1]) {
throw new Error(`Derived-support style ${policy.id} converted floor image dimensions changed.`);
}
if (floorMaterial.getBaseColorTextureInfo().getTexCoord() !== 0) {
throw new Error(`Derived-support style ${policy.id} expected the native floor to use TEXCOORD_0.`);
}
if (floorMaterial.getBaseColorTextureInfo().getExtension("KHR_texture_transform")
|| [floorMaterial.getNormalTexture(), floorMaterial.getOcclusionTexture(),
floorMaterial.getEmissiveTexture(), floorMaterial.getMetallicRoughnessTexture()].some(Boolean)) {
throw new Error(`Derived-support style ${policy.id} refuses unreviewed texture transforms or secondary maps.`);
}
const targets = [];
for (const node of root.listNodes()) {
if (node.getName() !== policy.supportNode || !node.getMesh()) continue;
for (const [primitiveIndex, primitive] of node.getMesh().listPrimitives().entries()) {
targets.push({ node, primitive, primitiveIndex });
}
}
if (targets.length !== 1) throw new Error(`Derived-support style ${policy.id} expected one support primitive; found ${targets.length}.`);
return { floorMaterial, floorTexture, image, ...targets[0] };
}
function transformPoint(matrix, point) {
return [
matrix[0] * point[0] + matrix[4] * point[1] + matrix[8] * point[2] + matrix[12],
matrix[1] * point[0] + matrix[5] * point[1] + matrix[9] * point[2] + matrix[13],
matrix[2] * point[0] + matrix[6] * point[1] + matrix[10] * point[2] + matrix[14],
];
}
function supportComponents(position, indices, matrix) {
const count = position.getCount();
const adjacency = Array.from({ length: count }, () => new Set());
for (let i = 0; i < indices.length; i += 3) {
const a = indices[i];
const b = indices[i + 1];
const c = indices[i + 2];
adjacency[a].add(b); adjacency[a].add(c);
adjacency[b].add(a); adjacency[b].add(c);
adjacency[c].add(a); adjacency[c].add(b);
}
const worldPositions = Array.from({ length: count }, (_, i) => transformPoint(matrix, position.getElement(i, [0, 0, 0])));
const componentByVertex = new Int32Array(count).fill(-1);
const components = [];
for (let start = 0; start < count; start += 1) {
if (componentByVertex[start] !== -1) continue;
const componentIndex = components.length;
const queue = [start];
componentByVertex[start] = componentIndex;
for (let cursor = 0; cursor < queue.length; cursor += 1) {
for (const neighbor of adjacency[queue[cursor]]) {
if (componentByVertex[neighbor] !== -1) continue;
componentByVertex[neighbor] = componentIndex;
queue.push(neighbor);
}
}
components.push(queue);
}
const topByVertex = new Float64Array(count);
for (const component of components) {
if (component.length !== 8) throw new Error(`Derived-support style expected eight box vertices per support; found ${component.length}.`);
const topY = Math.max(...component.map((index) => worldPositions[index][1]));
if (component.filter((index) => Math.abs(worldPositions[index][1] - topY) <= 1e-4).length !== 4) {
throw new Error("Derived-support style could not identify one four-vertex top face.");
}
for (const index of component) topByVertex[index] = topY;
}
return { worldPositions, components, topByVertex };
}
function styleMarker(policy, proof) {
return {
id: policy.id,
version: policy.version,
kind: "derived-support-texture-fallback",
nativeReconstruction: false,
adaptedAttribute: "TEXCOORD_1",
uvPerMeter: policy.uvPerMeter,
...(policy.nativeWorldUvBasis ? { nativeWorldUvBasis: policy.nativeWorldUvBasis } : {}),
sourceNativeMaterial: policy.sourceMaterial,
sourceTexture: policy.gltfTexture,
...proof,
};
}
function validateApplied(policy, primitive, marker) {
const uv1 = primitive.getAttribute("TEXCOORD_1");
const position = primitive.getAttribute("POSITION");
const normal = primitive.getAttribute("NORMAL");
const indices = primitive.getIndices();
const material = primitive.getMaterial();
if (!uv1 || uv1.getCount() !== position?.getCount() || material?.getBaseColorTexture()?.getName() !== policy.gltfTexture
|| material.getBaseColorTextureInfo()?.getTexCoord() !== 1) {
throw new Error(`Previously applied derived-support style ${policy.id} is incomplete.`);
}
if (primitive.getExtras()?.romDerivedSupportStyling?.id !== policy.id) {
throw new Error(`Previously applied derived-support style ${policy.id} has a mismatched marker.`);
}
const prior = primitive.getExtras().romDerivedSupportStyling;
if (prior.sourcePositionHash !== accessorHash(position) || prior.sourceNormalHash !== accessorHash(normal)
|| prior.sourceIndexHash !== accessorHash(indices) || prior.adaptedUvHash !== accessorHash(uv1)) {
throw new Error(`Previously applied derived-support style ${policy.id} accessors no longer match the policy report.`);
}
return marker;
}
function validateFullRankUvBasis(policy) {
if (!policy.nativeWorldUvBasis) return null;
const { dUdx, dUdz, dVdx, dVdz, sideUvDelta } = policy.nativeWorldUvBasis;
const determinants = {
topXZ: dUdx * dVdz - dUdz * dVdx,
sideXY: dUdx * sideUvDelta[1] - sideUvDelta[0] * dVdx,
sideZY: dUdz * sideUvDelta[1] - sideUvDelta[0] * dVdz,
};
if (Object.values(determinants).some((determinant) => Math.abs(determinant) < 1e-8)) {
throw new Error(`Derived-support style ${policy.id} native floor projection is not full-rank on every box face.`);
}
return determinants;
}
/**
* Applies the reviewed Demon Stronghold floor texture to its generated source-anchor
* support boxes. This is an explicit derived-geometry fallback, not native floor recovery.
* Existing geometry and attributes remain untouched; only a TEXCOORD_1 accessor and a
* cloned base-color material are added.
*/
export function applyRunewakerDerivedSupportStyling(document, {
dungeonId,
policyId,
sourceReport,
sourceDdsBytes,
} = {}) {
const policy = POLICIES[policyId];
if (!policy) throw new Error(`Unknown RoM derived-support style policy: ${policyId}.`);
const uvFaceDeterminants = validateFullRankUvBasis(policy);
const proof = proveSource(policy, dungeonId, sourceReport, sourceDdsBytes);
const { floorMaterial, floorTexture, image, node, primitive, primitiveIndex } = findTarget(document, policy);
if (primitive.getExtras()?.romDerivedSupportStyling) {
const marker = primitive.getExtras().romDerivedSupportStyling;
validateApplied(policy, primitive, marker);
return {
schemaVersion: 1,
dungeonId,
status: "already-applied",
policy: marker,
primitiveCount: 1,
triangleCount: primitive.getIndices()?.getCount() / 3,
padCount: policy.expectedPadCount,
geometryChanged: false,
sourceGeometryPreserved: true,
nativeLightingMetadataChanged: false,
};
}
if (primitive.getMaterial()?.getName() !== policy.supportMaterial) {
throw new Error(`Derived-support style ${policy.id} target material changed.`);
}
if (primitive.getAttribute("TEXCOORD_0") || primitive.getAttribute("TEXCOORD_1")) {
throw new Error(`Derived-support style ${policy.id} refuses to overwrite existing UV attributes.`);
}
const position = primitive.getAttribute("POSITION");
const normal = primitive.getAttribute("NORMAL");
const indices = primitive.getIndices();
if (!position || !normal || !indices || position.getType() !== Accessor.Type.VEC3 || normal.getCount() !== position.getCount()) {
throw new Error(`Derived-support style ${policy.id} target lacks matching POSITION, NORMAL, or indices.`);
}
if (indices.getCount() / 3 !== policy.expectedTriangleCount) {
throw new Error(`Derived-support style ${policy.id} support topology changed: expected ${policy.expectedTriangleCount} triangles.`);
}
const buffer = document.getRoot().listBuffers()[0];
if (!buffer) throw new Error(`Derived-support style ${policy.id} has no GLB buffer for TEXCOORD_1.`);
const indexValues = indices.getArray();
const matrix = Array.from(node.getWorldMatrix());
const { worldPositions, components, topByVertex } = supportComponents(position, indexValues, matrix);
if (components.length !== policy.expectedPadCount) {
throw new Error(`Derived-support style ${policy.id} expected ${policy.expectedPadCount} disconnected support boxes; found ${components.length}.`);
}
const [anchorX, anchorY, anchorZ] = policy.anchorWorld;
const [anchorU, anchorV] = policy.anchorUv;
const scale = policy.uvPerMeter;
const values = new Float32Array(position.getCount() * 2);
for (let index = 0; index < position.getCount(); index += 1) {
const [x, y, z] = worldPositions[index];
const vertical = y - topByVertex[index];
if (policy.nativeWorldUvBasis) {
const basis = policy.nativeWorldUvBasis;
values[index * 2] = anchorU + basis.dUdx * (x - anchorX) + basis.dUdz * (z - anchorZ)
+ basis.sideUvDelta[0] * vertical;
values[index * 2 + 1] = anchorV + basis.dVdx * (x - anchorX) + basis.dVdz * (z - anchorZ)
+ basis.sideUvDelta[1] * vertical;
} else {
values[index * 2] = anchorU - scale * (z - anchorZ) + scale * vertical;
values[index * 2 + 1] = anchorV + scale * (x - anchorX) + scale * vertical;
}
}
const sourceHashes = {
sourcePositionHash: accessorHash(position),
sourceNormalHash: accessorHash(normal),
sourceIndexHash: accessorHash(indices),
};
const uv1 = document.createAccessor(`${node.getName()}:${policy.id}:TEXCOORD_1`)
.setType(Accessor.Type.VEC2)
.setArray(values)
.setBuffer(buffer);
const adaptedMaterial = floorMaterial.clone()
.setName("runewaker_source_anchor_support_entrance_floor_02_adapted")
.setExtras({
...(floorMaterial.getExtras() ?? {}),
romDerivedSupportStyling: {
id: policy.id,
kind: "derived-support-texture-fallback",
nativeReconstruction: false,
sourceTexture: policy.gltfTexture,
},
});
adaptedMaterial.getBaseColorTextureInfo().setTexCoord(1);
const marker = {
...styleMarker(policy, proof),
padCount: components.length,
triangleCount: indices.getCount() / 3,
sourcePositionHash: sourceHashes.sourcePositionHash,
sourceNormalHash: sourceHashes.sourceNormalHash,
sourceIndexHash: sourceHashes.sourceIndexHash,
adaptedUvHash: sha256(Buffer.from(values.buffer, values.byteOffset, values.byteLength)),
geometryChanged: false,
sourceGeometryPreserved: true,
sourceNativeMaterialPreserved: true,
nativeLightingMetadataChanged: false,
};
primitive.setAttribute("TEXCOORD_1", uv1);
primitive.setMaterial(adaptedMaterial);
primitive.setExtras({ ...(primitive.getExtras() ?? {}), romDerivedSupportStyling: marker });
return {
schemaVersion: 1,
dungeonId,
status: "applied",
policy: marker,
primitiveCount: 1,
primitiveIndex,
triangleCount: indices.getCount() / 3,
padCount: components.length,
uvChannel: "TEXCOORD_1",
imageSize: policy.imageSize,
geometryChanged: false,
sourceGeometryPreserved: true,
nativeLightingMetadataChanged: false,
sourceTextureImageSha256: sha256(image),
metersPerUv: policy.metersPerUv,
sourceFloorReference: {
material: policy.sourceMaterial,
texture: policy.gltfTexture,
anchorWorld: policy.anchorWorld,
anchorUv: policy.anchorUv,
uvPerMeter: policy.uvPerMeter,
densityPixelsPerMeter: policy.uvPerMeter * policy.imageSize[0],
topFaceMatchesReferencePhase: true,
sideFacesUseVerticalWorldProjection: true,
...(policy.nativeWorldUvBasis ? {
nativeWorldUvBasis: policy.nativeWorldUvBasis,
fullRankFaceDeterminants: uvFaceDeterminants,
} : {}),
},
};
}
export function listRunewakerDerivedSupportStylingPolicies() {
return Object.values(POLICIES).map((policy) => ({ ...policy, imageSize: [...policy.imageSize], anchorWorld: [...policy.anchorWorld], anchorUv: [...policy.anchorUv] }));
}
@@ -0,0 +1,112 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Document, NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
import { applyRunewakerDerivedSupportStyling, listRunewakerDerivedSupportStylingPolicies } from "./rom-derived-support-styling.mjs";
import { applyRunewakerDerivedSupportStylingToFile } from "./rom-derived-support-styling-file.mjs";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const mysticVisual = path.join(repositoryRoot, "runewaker-export-work/mystic-altar/converted/mystic-altar-visual.glb");
const mysticReport = path.join(repositoryRoot, "runewaker-export-work/mystic-altar/prepared/export-report.json");
const mysticMetadata = path.join(repositoryRoot, "src/assets/game/dungeons/mystic-altar/import-metadata.json");
const mysticDds = path.join(repositoryRoot, "runewaker-export-work/mystic-altar/raw/textures/caa8475c_mystic_altar_floor.dds");
const mysticRos = path.resolve(repositoryRoot, "../rom-pvt-server-backups/Runewaker/Resource/model/dungeon/mystic_altar/act_mystic_altar_instance.ros");
const sourceSha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
function accessorSnapshot(document) {
return document.getRoot().listAccessors().map((accessor) => {
const array = accessor.getArray();
return JSON.stringify({
name: accessor.getName(),
type: accessor.getType(),
count: accessor.getCount(),
hash: array ? sourceSha256(Buffer.from(array.buffer, array.byteOffset, array.byteLength)) : null,
});
}).sort();
}
function imageSnapshot(document) {
return document.getRoot().listTextures().map((texture) => JSON.stringify({
name: texture.getName(),
hash: sourceSha256(Buffer.from(texture.getImage() ?? [])),
})).sort();
}
test("unlisted packages do not read or mutate a converted source", async () => {
assert.deepEqual(await applyRunewakerDerivedSupportStylingToFile("missing-source.glb", {}), {
status: "not-requested", geometryChanged: false,
});
});
test("the base Demon support policy refuses the level125 variant before mutation", () => {
const document = new Document();
assert.throws(() => applyRunewakerDerivedSupportStyling(document, {
dungeonId: "demon-stronghold-125", policyId: listRunewakerDerivedSupportStylingPolicies()[0].id,
}), /cannot run/);
assert.equal(document.getRoot().listMaterials().length, 0);
});
test("the reviewed floor cannot be substituted with mismatched source DDS content", () => {
const policy = listRunewakerDerivedSupportStylingPolicies()[0];
const document = new Document();
assert.throws(() => applyRunewakerDerivedSupportStyling(document, {
dungeonId: policy.dungeonId, policyId: policy.id,
sourceReport: { sourceModel: policy.sourceModel, sourceResourceSha256: policy.sourceModelSha256,
materials: [{ name: policy.sourceMaterial, sourceTexture: policy.sourceTexture, stagedTexture: policy.stagedTexture }] },
sourceDdsBytes: Buffer.from("unreviewed floor"),
}), /source DDS hash/);
assert.equal(document.getRoot().listMaterials().length, 0);
});
test("Mystic derived pads use the source floor UV mapping without changing native accessors or images", {
skip: ![mysticVisual, mysticReport, mysticMetadata, mysticDds, mysticRos].every(existsSync),
}, async () => {
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({ "meshopt.decoder": MeshoptDecoder });
await MeshoptDecoder.ready;
const document = await io.read(mysticVisual);
const policy = listRunewakerDerivedSupportStylingPolicies().find(({ dungeonId }) => dungeonId === "mystic-altar");
assert.ok(policy);
const sourceReport = JSON.parse(await readFile(mysticReport, "utf8"));
const metadata = JSON.parse(await readFile(mysticMetadata, "utf8"));
sourceReport.sourceResourceSha256 = metadata.source.modelSha256;
const sourceDdsBytes = await readFile(mysticDds);
assert.equal(sourceSha256(await readFile(mysticRos)), policy.sourceModelSha256);
const originalAccessors = accessorSnapshot(document);
const originalImages = imageSnapshot(document);
const floorMaterial = document.getRoot().listMaterials().find((material) => material.getName() === policy.sourceMaterial);
const floorUv = floorMaterial?.getBaseColorTextureInfo()?.getTexCoord();
const floorImageHash = sourceSha256(Buffer.from(floorMaterial?.getBaseColorTexture()?.getImage() ?? []));
const result = applyRunewakerDerivedSupportStyling(document, {
dungeonId: "mystic-altar",
policyId: policy.id,
sourceReport,
sourceDdsBytes,
});
assert.equal(result.status, "applied");
assert.equal(result.padCount, 250);
assert.equal(result.triangleCount, 3000);
assert.deepEqual(result.sourceFloorReference.anchorWorld, policy.anchorWorld);
assert.deepEqual(result.sourceFloorReference.anchorUv, policy.anchorUv);
assert.equal(result.sourceFloorReference.densityPixelsPerMeter, 22.176030799610068);
assert.ok(Object.values(result.sourceFloorReference.fullRankFaceDeterminants).every((value) => Math.abs(value) > 1e-8));
const support = document.getRoot().listNodes().find((node) => node.getName() === policy.supportNode);
const supportPrimitive = support?.getMesh()?.listPrimitives()[0];
assert.ok(supportPrimitive?.getAttribute("TEXCOORD_1"));
assert.equal(supportPrimitive.getAttribute("TEXCOORD_0"), null);
assert.equal(supportPrimitive.getExtras().romDerivedSupportStyling.sourcePositionHash, result.policy.sourcePositionHash);
assert.equal(supportPrimitive.getExtras().romDerivedSupportStyling.sourceIndexHash, result.policy.sourceIndexHash);
const styledAccessors = accessorSnapshot(document);
for (const original of originalAccessors) assert.ok(styledAccessors.includes(original), `missing original accessor ${original}`);
assert.deepEqual(imageSnapshot(document), originalImages);
const unchangedFloorMaterial = document.getRoot().listMaterials().find((material) => material.getName() === policy.sourceMaterial);
assert.equal(unchangedFloorMaterial?.getBaseColorTextureInfo()?.getTexCoord(), floorUv);
assert.equal(sourceSha256(Buffer.from(unchangedFloorMaterial?.getBaseColorTexture()?.getImage() ?? [])), floorImageHash);
});
@@ -0,0 +1,746 @@
import { createHash } from "node:crypto";
import { Accessor } from "@gltf-transform/core";
import { KHRMaterialsVariants } from "@gltf-transform/extensions";
import sharp from "sharp";
const BARREN_POLICY_ID = "rom-barren-caves-wretched-tunnel-surface-quality-v2";
const HOTO_POLICY_ID = "rom-hoto-source-rock-detail-v2";
const HOTO_LEGACY_POLICY_ID = "rom-hoto-source-rock-detail-v1";
const BARREN_SOURCE = Object.freeze({
model: "model/dungeon/wretched_grotto/act_world_wretched_grotto.ros",
sha256: "d2de5b83f63248b9b6f6d17e13104ee3cddbc4e77903d4eb561ca5731556bab7",
material: "runewaker_material_12",
texture: "texture/dungeon/wretched_grotto/wretched_tunnel",
image: "a6d9c718_wretched_tunnel",
imageSha256: "0f05ae62b2486ae90a9836873fac287fd89169abcef962ac7dfacd81fb469a7c",
});
const HOTO_SOURCE = Object.freeze({
model: "model/dungeon/echoes_of_the_sea/act_echoes_of_the_sea.ros",
sha256: "a0bd77c09045f8bfa1507484bbb2c2ff0cc72d397df4f769fc4e6d5aab14f8a2",
});
const HOTO_TARGETS = Object.freeze({
runewaker_material_96: Object.freeze({
nativeMaterial: "runewaker_material_96", texture: "texture/dungeon/echoes_of_the_sea/echoes_of_the_sea_01",
image: "122cc01b_echoes_of_the_sea_01", imageSha256: "ea395afa1d0c65aa57ed7e675a352ca47aab0b1f7a629a3bebdf36e03621a3b1",
floorStrength: 0.85, floorBaseStrength: 0, wallStrength: 0.85, legacyWallStrength: 0.45,
wallBaseContrast: 0.6, wallDetailVariation: 1, useOwnBaseMean: true,
}),
runewaker_material_53: Object.freeze({
nativeMaterial: "runewaker_material_53", texture: "texture/dungeon/echoes_of_the_sea/echoes_of_the_sea_04",
image: "5c863840_echoes_of_the_sea_04", imageSha256: "fbb0eb3ac74689cf2e7b6bc043bc15f1c3cbc4638fc15675ff0710037e9666f9",
floorStrength: 0.85, floorBaseStrength: 0, wallStrength: 0.85, legacyWallStrength: 0.45,
wallBaseContrast: 0.6, wallDetailVariation: 1, useOwnBaseMean: true,
}),
runewaker_material_26: Object.freeze({
nativeMaterial: "runewaker_material_94", texture: "texture/dungeon/echoes_of_the_sea/echoes_of_the_sea_02",
image: "5672f062_echoes_of_the_sea_02", imageSha256: "bc32a6751da4db246d50f66d2c9fb625f640be652956d7022a4feb649d62a1d2",
floorVariantRole: "floor", floorStrength: 0, floorBaseStrength: 1, wallStrength: 0, legacyWallStrength: 0,
wallBaseContrast: 1, wallDetailVariation: 0,
}),
runewaker_material_18: Object.freeze({
nativeMaterial: "runewaker_material_95", texture: "texture/dungeon/echoes_of_the_sea/echoes_of_the_sea_03",
image: "b2ababb9_echoes_of_the_sea_03", imageSha256: "7e20e5f22744d638fa9bd983c13844800bc316e3502ab092bf6d8b6f68a1d018",
floorVariantRole: "wall", floorStrength: 0, floorBaseStrength: 1, wallStrength: 0.45, legacyWallStrength: 0.45,
wallBaseContrast: 1, wallDetailVariation: 0,
}),
});
const HOTO_DETAIL = Object.freeze({
floor: Object.freeze({
nativeMaterial: "runewaker_material_94", canonicalMaterial: "runewaker_material_26",
texture: "texture/dungeon/echoes_of_the_sea/echoes_of_the_sea_02",
image: "5672f062_echoes_of_the_sea_02", imageSha256: "bc32a6751da4db246d50f66d2c9fb625f640be652956d7022a4feb649d62a1d2",
}),
wall: Object.freeze({
nativeMaterial: "runewaker_material_95", canonicalMaterial: "runewaker_material_18",
texture: "texture/dungeon/echoes_of_the_sea/echoes_of_the_sea_03",
image: "b2ababb9_echoes_of_the_sea_03", imageSha256: "7e20e5f22744d638fa9bd983c13844800bc316e3502ab092bf6d8b6f68a1d018",
}),
});
const HOTO_VARIANTS = Object.freeze({ floor: "rom-detail-hoto-floor", wall: "rom-detail-hoto-wall" });
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
const normalizeResource = (value) => String(value ?? "").replaceAll("\\", "/").replace(/^\/+/, "").toLowerCase();
const accessorHash = (accessor) => {
const array = accessor?.getArray();
return array ? sha256(Buffer.from(array.buffer, array.byteOffset, array.byteLength)) : null;
};
function verifySourceReport(sourceReport, source) {
if (normalizeResource(sourceReport?.sourceModel) !== source.model.toLowerCase()) {
throw new Error(`Surface policy source ROS path does not match ${source.model}.`);
}
if (String(sourceReport?.sourceResourceSha256 ?? "").toLowerCase() !== source.sha256) {
throw new Error(`Surface policy source ROS SHA-256 does not match ${source.model}.`);
}
}
function verifyNativeMaterial(sourceReport, nativeName, texturePath) {
const rows = (sourceReport?.materials ?? []).filter((row) => row.name === nativeName);
if (rows.length !== 1 || normalizeResource(rows[0].sourceTexture) !== texturePath.toLowerCase()) {
throw new Error(`Expected exact native material/texture binding ${nativeName} -> ${texturePath}.`);
}
return rows[0];
}
function listVariants(document) {
const root = document.getRoot();
const extension = root.listExtensionsUsed().find((item) => item.extensionName === KHRMaterialsVariants.EXTENSION_NAME)
?? document.createExtension(KHRMaterialsVariants).setRequired(false);
const byName = new Map(extension.listVariants().map((variant) => [variant.getName(), variant]));
const getOrCreate = (name) => byName.get(name) ?? (byName.set(name, extension.createVariant(name)), byName.get(name));
return { extension, getOrCreate };
}
function addSurfaceMappings(document, primitive, carrierByName, variantByName, names = [HOTO_VARIANTS.floor, HOTO_VARIANTS.wall]) {
if (primitive.getExtension(KHRMaterialsVariants.EXTENSION_NAME)) {
throw new Error("Surface-quality policy will not replace an existing KHR_materials_variants mapping.");
}
const variants = document.getRoot().listExtensionsUsed().find((item) => item.extensionName === KHRMaterialsVariants.EXTENSION_NAME);
const mappingList = variants.createMappingList();
for (const name of names) {
const carrier = carrierByName.get(name);
const variant = variantByName.get(name);
if (!carrier || !variant) throw new Error(`Missing surface-detail carrier or variant ${name}.`);
mappingList.addMapping(variants.createMapping().setMaterial(carrier).addVariant(variant));
}
primitive.setExtension(KHRMaterialsVariants.EXTENSION_NAME, mappingList);
}
function getOrCreateCarrier(document, sourceTexture, name, extras) {
const root = document.getRoot();
const found = root.listMaterials().find((material) => material.getName() === `${name}:source-tile-carrier`);
if (found) {
if (found.getBaseColorTexture() !== sourceTexture || found.getExtras()?.runewakerSurfaceDetailCarrier?.policyId !== extras.policyId) {
throw new Error(`Existing surface-detail carrier ${name} does not match its pinned source.`);
}
return found;
}
return document.createMaterial(`${name}:source-tile-carrier`)
.setBaseColorTexture(sourceTexture)
.setMetallicFactor(0)
.setRoughnessFactor(1)
.setExtras({ runewakerSurfaceDetailCarrier: extras });
}
async function meanLinearFromPng(bytes) {
const { data, info } = await sharp(bytes).removeAlpha().raw().toBuffer({ resolveWithObject: true });
if (info.channels < 3 || info.width < 1 || info.height < 1) throw new Error("Surface-detail source image must contain RGB pixels.");
const sums = [0, 0, 0];
for (let offset = 0; offset < data.length; offset += info.channels) {
for (let channel = 0; channel < 3; channel += 1) {
const srgb = data[offset + channel] / 255;
sums[channel] += srgb <= 0.04045 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4;
}
}
const count = info.width * info.height;
return sums.map((value) => value / count);
}
function trianglePositions(matrix, position, indices) {
return indices.map((index) => {
const value = position.getElement(index, [0, 0, 0]);
return [
matrix[0] * value[0] + matrix[4] * value[1] + matrix[8] * value[2] + matrix[12],
matrix[1] * value[0] + matrix[5] * value[1] + matrix[9] * value[2] + matrix[13],
matrix[2] * value[0] + matrix[6] * value[1] + matrix[10] * value[2] + matrix[14],
];
});
}
function sub3(left, right) { return [left[0] - right[0], left[1] - right[1], left[2] - right[2]]; }
function cross3(left, right) {
return [left[1] * right[2] - left[2] * right[1], left[2] * right[0] - left[0] * right[2], left[0] * right[1] - left[1] * right[0]];
}
function length3(value) { return Math.hypot(value[0], value[1], value[2]); }
const BARREN_PATCHES = Object.freeze([
{ key: "u0-v0", col: 0, row: 0, rect: { left: 0, top: 0, width: 256, height: 256 } },
{ key: "u3-v0", col: 3, row: 0, rect: { left: 768, top: 0, width: 256, height: 256 } },
{ key: "u0-v1", col: 0, row: 1, rect: { left: 0, top: 256, width: 256, height: 256 } },
{ key: "u3-v1", col: 3, row: 1, rect: { left: 768, top: 256, width: 256, height: 256 } },
].map((patch) => Object.freeze({
...patch,
uvRect: { u0: patch.col / 4, u1: (patch.col + 1) / 4, v0: patch.row / 2, v1: (patch.row + 1) / 2 },
})));
const BARREN_WALL_DETAIL_PATCH = Object.freeze({
key: "wall-u1-v0",
rect: Object.freeze({ left: 256, top: 0, width: 256, height: 256 }),
textureName: "rom-barren-wretched-tunnel-wall-detail-u1-v0",
});
function barrenPatchForTriangle(uvs, worldNormalY) {
if (worldNormalY < 0.7) return null;
const tolerance = 0.0025;
const minU = Math.min(...uvs.map((uv) => uv[0]));
const maxU = Math.max(...uvs.map((uv) => uv[0]));
const minV = Math.min(...uvs.map((uv) => uv[1]));
const maxV = Math.max(...uvs.map((uv) => uv[1]));
return BARREN_PATCHES.find((patch) => minU >= patch.uvRect.u0 - tolerance && maxU <= patch.uvRect.u1 + tolerance
&& minV >= patch.uvRect.v0 - tolerance && maxV <= patch.uvRect.v1 + tolerance)?.key ?? null;
}
async function applyBarrenFloorPolicy(document, sourceReport) {
verifySourceReport(sourceReport, BARREN_SOURCE);
verifyNativeMaterial(sourceReport, BARREN_SOURCE.material, BARREN_SOURCE.texture);
const root = document.getRoot();
const sourceMaterial = root.listMaterials().find((material) => material.getName() === BARREN_SOURCE.material);
if (!sourceMaterial) throw new Error("Barren atlas-safe policy cannot find native material 12.");
const sourceTexture = sourceMaterial.getBaseColorTexture();
const sourcePng = Buffer.from(sourceTexture?.getImage() ?? []);
if (sourceTexture?.getName() !== BARREN_SOURCE.image || sha256(sourcePng) !== BARREN_SOURCE.imageSha256) {
throw new Error("Barren material 12 embedded atlas differs from the pinned native prepared PNG.");
}
const imageMeta = await sharp(sourcePng).metadata();
if (imageMeta.width !== 1024 || imageMeta.height !== 512) throw new Error("Barren atlas dimensions differ from the reviewed 1024×512 source.");
const baseMeanLinear = await meanLinearFromPng(sourcePng);
// Migrate the previous whole-atlas x4 experiment back to authentic UV0
// before splitting only faces proven to stay inside a native floor island.
let legacyRepeatPrimitivesRestored = 0;
for (const node of root.listNodes()) {
const mesh = node.getMesh();
if (!mesh) continue;
for (const primitive of mesh.listPrimitives()) {
if (primitive.getExtras()?.romUvAdaptation?.id !== "rom-barren-caves-wretched-tunnel-repeat-x4") continue;
const material = primitive.getMaterial();
const uv0 = primitive.getAttribute("TEXCOORD_0");
const uv1 = primitive.getAttribute("TEXCOORD_1");
if (material?.getBaseColorTexture()?.getName() !== BARREN_SOURCE.image || !uv0 || !uv1
|| uv0.getCount() !== uv1.getCount()) {
throw new Error("Cannot safely restore an earlier Barren whole-atlas x4 primitive.");
}
for (let index = 0; index < uv0.getCount(); index += 1) {
const original = uv0.getElement(index, [0, 0]);
const adapted = uv1.getElement(index, [0, 0]);
if (Math.abs(adapted[0] - original[0] * 4) > 1e-6 || Math.abs(adapted[1] - original[1] * 4) > 1e-6) {
throw new Error("Earlier Barren repeat marker does not contain the expected x4 UV1 values.");
}
}
const extras = { ...(primitive.getExtras() ?? {}) };
delete extras.romUvAdaptation;
primitive.setExtras(extras);
primitive.setAttribute("TEXCOORD_1", null);
primitive.setMaterial(sourceMaterial);
legacyRepeatPrimitivesRestored += 1;
}
}
const alreadyApplied = sourceMaterial.getExtras()?.runewakerSurfaceDetail?.policyId === BARREN_POLICY_ID;
if (alreadyApplied) {
const surfaceDetail = sourceMaterial.getExtras()?.runewakerSurfaceDetail;
const cropTextures = root.listTextures().filter((texture) => texture.getExtras()?.romSourcePixelCrop?.policyId === BARREN_POLICY_ID);
const cropMaterials = root.listMaterials().filter((material) => material.getExtras()?.romUvAdaptation?.id === BARREN_POLICY_ID);
const expectedCropMaterialNames = new Set(BARREN_PATCHES.map((patch) => `${BARREN_SOURCE.material}_${patch.key}_crop_repeat4`));
const actualCropMaterialNames = new Set(cropMaterials.map((material) => material.getName()));
const wallTexture = root.listTextures().find((texture) => texture.getName() === BARREN_WALL_DETAIL_PATCH.textureName);
const wallCarrier = root.listMaterials().find((material) => material.getName() === "rom-detail-barren-caves-wall:source-tile-carrier");
const wallInfo = wallCarrier?.getBaseColorTextureInfo();
if (surfaceDetail.wallBaseContrast !== 0.25 || surfaceDetail.wallStrength !== 0.85 || surfaceDetail.wallDetailVariation !== 1
|| JSON.stringify(surfaceDetail.baseMeanLinear) !== JSON.stringify(baseMeanLinear)
|| cropTextures.length !== BARREN_PATCHES.length + 1 || !wallTexture || wallCarrier?.getBaseColorTexture() !== wallTexture
|| wallInfo?.getWrapS() !== 33648 || wallInfo?.getWrapT() !== 33648
|| actualCropMaterialNames.size !== BARREN_PATCHES.length
|| [...expectedCropMaterialNames].some((name) => !actualCropMaterialNames.has(name))) {
throw new Error("Barren atlas-safe policy marker is present but its exact floor/wall crop carriers are incomplete.");
}
return {
id: BARREN_POLICY_ID, status: "already-applied", cropTextureCount: cropTextures.length,
cropMaterialCount: actualCropMaterialNames.size, cropMaterialInstanceCount: cropMaterials.length,
wallDetailTextureName: wallTexture.getName(), wallDetailWrap: "mirrored-repeat",
repeatFactor: 4, trianglePartitionChanged: true,
sourceModel: BARREN_SOURCE.model, sourceModelSha256: BARREN_SOURCE.sha256,
};
}
if (root.listTextures().some((texture) => texture.getExtras()?.romSourcePixelCrop?.policyId === BARREN_POLICY_ID)) {
throw new Error("Barren source GLB already contains partial atlas-safe floor crops.");
}
const buffer = root.listBuffers()[0];
if (!buffer) throw new Error("Barren canonical GLB has no binary buffer for UV1.");
const cropped = new Map();
for (const patch of BARREN_PATCHES) {
const png = await sharp(sourcePng).extract(patch.rect).png().toBuffer();
const rgba = await sharp(png).ensureAlpha().raw().toBuffer();
const meanLinear = await meanLinearFromPng(png);
const sourcePixelCrop = {
version: 1,
sourceImageName: BARREN_SOURCE.image,
sourcePreparedPngSha256: BARREN_SOURCE.imageSha256,
sourceWidth: 1024,
sourceHeight: 512,
rect: patch.rect,
croppedPngSha256: sha256(png),
croppedRgbaSha256: sha256(rgba),
policyId: BARREN_POLICY_ID,
};
const texture = document.createTexture(`rom-barren-wretched-tunnel-${patch.key}`)
.setMimeType("image/png")
.setImage(png)
.setExtras({ romSourcePixelCrop: sourcePixelCrop });
cropped.set(patch.key, { patch, png, meanLinear, sourcePixelCrop, texture });
}
const wallDetailPng = await sharp(sourcePng).extract(BARREN_WALL_DETAIL_PATCH.rect).png().toBuffer();
const wallDetailRgba = await sharp(wallDetailPng).ensureAlpha().raw().toBuffer();
const wallDetailCrop = {
version: 1,
sourceImageName: BARREN_SOURCE.image,
sourcePreparedPngSha256: BARREN_SOURCE.imageSha256,
sourceWidth: 1024,
sourceHeight: 512,
rect: BARREN_WALL_DETAIL_PATCH.rect,
croppedPngSha256: sha256(wallDetailPng),
croppedRgbaSha256: sha256(wallDetailRgba),
policyId: BARREN_POLICY_ID,
};
const wallDetailTexture = document.createTexture(BARREN_WALL_DETAIL_PATCH.textureName)
.setMimeType("image/png")
.setImage(wallDetailPng)
.setExtras({ romSourcePixelCrop: wallDetailCrop });
const wallDetailMeanLinear = await meanLinearFromPng(wallDetailPng);
const variantInfo = listVariants(document);
const variantNames = ["rom-detail-barren-caves-floor", "rom-detail-barren-caves-wall"];
const variantByName = new Map(variantNames.map((name) => [name, variantInfo.getOrCreate(name)]));
const baseExtras = sourceMaterial.getExtras() ?? {};
const cropForDetail = cropped.get("u0-v0");
const sourceProof = {
sourceModel: BARREN_SOURCE.model,
sourceModelSha256: BARREN_SOURCE.sha256,
sourceMaterial: BARREN_SOURCE.material,
sourceTexture: BARREN_SOURCE.texture,
sourceImageName: BARREN_SOURCE.image,
sourcePreparedPngSha256: BARREN_SOURCE.imageSha256,
sourcePixelCrop: cropForDetail.sourcePixelCrop,
baseMeanLinear,
wallBaseContrast: 0.25,
wallDetailVariation: 1,
wallDetailImageName: BARREN_WALL_DETAIL_PATCH.textureName,
wallDetailSourcePixelCrop: wallDetailCrop,
wallDetailMeanLinear,
wallDetailWrap: "mirrored-repeat",
};
sourceMaterial.setExtras({
...baseExtras,
runewakerSurfaceDetail: {
version: 1,
floorVariant: variantNames[0],
wallVariant: variantNames[1],
periodMeters: 2,
floorStrength: 0,
wallStrength: 0.85,
baseMeanLinear,
wallBaseContrast: 0.25,
wallDetailVariation: 1,
floorMeanLinear: cropForDetail.meanLinear,
wallMeanLinear: wallDetailMeanLinear,
policyId: BARREN_POLICY_ID,
sourceProof,
},
});
const carrierExtras = { version: 1, policyId: BARREN_POLICY_ID, sourceImageName: BARREN_SOURCE.image, sourcePixelCrop: cropForDetail.sourcePixelCrop };
const carriers = new Map([
[variantNames[0], getOrCreateCarrier(document, cropForDetail.texture, variantNames[0], { ...carrierExtras, variantName: variantNames[0] })],
[variantNames[1], getOrCreateCarrier(document, wallDetailTexture, variantNames[1], {
...carrierExtras,
variantName: variantNames[1],
sourcePixelCrop: wallDetailCrop,
sourceImageName: BARREN_WALL_DETAIL_PATCH.textureName,
wrap: "mirrored-repeat",
})],
]);
carriers.get(variantNames[1]).getBaseColorTextureInfo().setWrapS(33648).setWrapT(33648);
const reports = [];
let adaptedTriangles = 0;
let untouchedTriangles = 0;
for (const node of root.listNodes()) {
const mesh = node.getMesh();
if (!mesh) continue;
const matrix = node.getWorldMatrix();
const originalPrimitives = [...mesh.listPrimitives()];
for (const [primitiveIndex, primitive] of originalPrimitives.entries()) {
if (primitive.getMaterial() !== sourceMaterial) continue;
if (primitive.getExtension(KHRMaterialsVariants.EXTENSION_NAME)) throw new Error("Barren material 12 already has variant mappings.");
const position = primitive.getAttribute("POSITION");
const normal = primitive.getAttribute("NORMAL");
const uv0 = primitive.getAttribute("TEXCOORD_0");
const indices = primitive.getIndices();
if (!position || !normal || !uv0 || !indices || uv0.getType() !== Accessor.Type.VEC2) {
throw new Error(`${node.getName()} material 12 primitive ${primitiveIndex} lacks position/normal/index/UV0 data.`);
}
const indexArray = indices.getArray();
const indicesByCrop = new Map(BARREN_PATCHES.map((patch) => [patch.key, []]));
const untouched = [];
for (let offset = 0; offset < indexArray.length; offset += 3) {
const triangle = [indexArray[offset], indexArray[offset + 1], indexArray[offset + 2]];
const points = trianglePositions(matrix, position, triangle);
const cross = cross3(sub3(points[1], points[0]), sub3(points[2], points[0]));
const magnitude = length3(cross);
const normalY = magnitude > 1e-12 ? Math.abs(cross[1]) / magnitude : 0;
const triangleUvs = triangle.map((index) => uv0.getElement(index, [0, 0]));
const key = barrenPatchForTriangle(triangleUvs, normalY);
(key ? indicesByCrop.get(key) : untouched).push(...triangle);
}
const activeCrops = [...indicesByCrop].filter(([, group]) => group.length > 0);
if (!activeCrops.length) continue;
const originalTriangleCount = indexArray.length / 3;
const before = accessorHash(indices);
const primitiveOutputs = [];
if (untouched.length) {
primitive.setIndices(document.createAccessor(`${node.getName()}:${BARREN_POLICY_ID}:untouched-indices`)
.setType(Accessor.Type.SCALAR).setArray(new indexArray.constructor(untouched)).setBuffer(buffer));
primitive.setExtension(KHRMaterialsVariants.EXTENSION_NAME, (() => {
const current = document.getRoot().listExtensionsUsed().find((item) => item.extensionName === KHRMaterialsVariants.EXTENSION_NAME);
const mappingList = current.createMappingList();
for (const name of variantNames) mappingList.addMapping(current.createMapping().setMaterial(carriers.get(name)).addVariant(variantByName.get(name)));
return mappingList;
})());
primitiveOutputs.push({ primitive, triangles: untouched.length / 3, category: "untouched-atlas" });
} else mesh.removePrimitive(primitive);
for (const [key, group] of activeCrops) {
const { patch, texture, sourcePixelCrop } = cropped.get(key);
const adaptedMaterial = sourceMaterial.clone()
.setName(`${BARREN_SOURCE.material}_${key}_crop_repeat4`)
.setBaseColorTexture(texture);
adaptedMaterial.getBaseColorTextureInfo().setTexCoord(1).setWrapS(10497).setWrapT(10497);
adaptedMaterial.setExtras({
...baseExtras,
romUvAdaptation: {
version: 1, id: BARREN_POLICY_ID, atlasRect: patch.rect, uvRect: patch.uvRect,
repeatFactor: 4, nativeBaseTexture: BARREN_SOURCE.image, nativeBaseTexturePngSha256: BARREN_SOURCE.imageSha256,
sourcePixelCrop,
},
});
const values = new Float32Array(uv0.getCount() * 2);
for (let index = 0; index < uv0.getCount(); index += 1) {
const uv = uv0.getElement(index, [0, 0]);
values[index * 2] = ((uv[0] - patch.uvRect.u0) / (patch.uvRect.u1 - patch.uvRect.u0)) * 4;
values[index * 2 + 1] = ((uv[1] - patch.uvRect.v0) / (patch.uvRect.v1 - patch.uvRect.v0)) * 4;
}
const uv1 = document.createAccessor(`${node.getName()}:${BARREN_POLICY_ID}:${key}:TEXCOORD_1`)
.setType(Accessor.Type.VEC2).setArray(values).setBuffer(buffer);
const adapted = primitive.clone()
.setIndices(document.createAccessor(`${node.getName()}:${BARREN_POLICY_ID}:${key}:indices`)
.setType(Accessor.Type.SCALAR).setArray(new indexArray.constructor(group)).setBuffer(buffer))
.setAttribute("TEXCOORD_1", uv1)
.setMaterial(adaptedMaterial)
.setExtras({
...(primitive.getExtras() ?? {}),
romUvAdaptation: {
version: 1, id: BARREN_POLICY_ID, atlasRect: patch.rect, uvRect: patch.uvRect, repeatFactor: 4,
sourceMaterial: BARREN_SOURCE.material, sourceTexture: BARREN_SOURCE.image,
sourcePngSha256: BARREN_SOURCE.imageSha256, sourcePixelCrop,
},
});
const variants = document.getRoot().listExtensionsUsed().find((item) => item.extensionName === KHRMaterialsVariants.EXTENSION_NAME);
const mappings = variants.createMappingList();
for (const name of variantNames) mappings.addMapping(variants.createMapping().setMaterial(carriers.get(name)).addVariant(variantByName.get(name)));
adapted.setExtension(KHRMaterialsVariants.EXTENSION_NAME, mappings);
mesh.addPrimitive(adapted);
primitiveOutputs.push({ primitive: adapted, triangles: group.length / 3, category: key });
adaptedTriangles += group.length / 3;
}
untouchedTriangles += untouched.length / 3;
const outputTriangles = primitiveOutputs.reduce((sum, item) => sum + item.triangles, 0);
if (outputTriangles !== originalTriangleCount) throw new Error(`Barren triangle partition failed on ${node.getName()} primitive ${primitiveIndex}.`);
for (const { primitive: outputPrimitive } of primitiveOutputs) {
if (outputPrimitive.getAttribute("POSITION") !== position || outputPrimitive.getAttribute("NORMAL") !== normal
|| outputPrimitive.getAttribute("TEXCOORD_0") !== uv0) {
throw new Error(`Barren UV policy changed source position/normal/UV0 accessors on ${node.getName()}.`);
}
}
reports.push({
node: node.getName(), sourcePrimitiveIndex: primitiveIndex, sourceTriangles: originalTriangleCount,
sourceIndexHash: before, sourcePositionHash: accessorHash(position), sourceNormalHash: accessorHash(normal),
sourceUv0Hash: accessorHash(uv0),
outputGroups: primitiveOutputs.map(({ category, triangles }) => ({ category, triangles })),
triangleCoverageExact: true, positionNormalUv0AccessorsShared: true, indexStreamLayoutChanged: true,
});
}
}
if (!reports.length) throw new Error("Barren atlas-safe policy found no floor triangles in the four reviewed source-image quadrants.");
const patchReports = [...cropped.values()].map(({ patch, png, sourcePixelCrop, meanLinear }) => ({
key: patch.key, sourcePixelRect: patch.rect, sourceUvRect: patch.uvRect, repeatFactor: 4,
croppedPngSha256: sourcePixelCrop.croppedPngSha256, croppedRgbaSha256: sourcePixelCrop.croppedRgbaSha256,
bytes: png.length, meanLinear,
}));
patchReports.push({
key: BARREN_WALL_DETAIL_PATCH.key,
role: "wall-detail-carrier",
sourcePixelRect: BARREN_WALL_DETAIL_PATCH.rect,
repeatMode: "mirrored-repeat",
croppedPngSha256: wallDetailCrop.croppedPngSha256,
croppedRgbaSha256: wallDetailCrop.croppedRgbaSha256,
bytes: wallDetailPng.length,
meanLinear: wallDetailMeanLinear,
});
return {
id: BARREN_POLICY_ID,
status: "applied",
sourceModel: BARREN_SOURCE.model,
sourceModelSha256: BARREN_SOURCE.sha256,
sourceMaterial: BARREN_SOURCE.material,
sourceTexture: BARREN_SOURCE.texture,
sourceImage: BARREN_SOURCE.image,
sourceImageSha256: BARREN_SOURCE.imageSha256,
sourceImageDimensions: [imageMeta.width, imageMeta.height],
repeatFactor: 4,
worldNormalYThreshold: 0.7,
atlasSeamToleranceUv: 0.0025,
trianglePolicy: "floor triangles whose UV0 corners remain inside a reviewed 256x256 floor island receive crop-localized repeat UV1; other faces keep the full native atlas",
trianglePartitionChanged: true,
legacyRepeatPrimitivesRestored,
sourcePositionNormalUv0Preserved: true,
untouchedTriangles,
adaptedTriangles,
primitiveReports: reports,
crops: patchReports,
wallDetail: {
textureName: BARREN_WALL_DETAIL_PATCH.textureName,
sourcePixelCrop: wallDetailCrop,
meanLinear: wallDetailMeanLinear,
samplerWrap: "mirrored-repeat",
},
wallBaseContrast: 0.25,
wallDetailVariation: 1,
baseMeanLinear,
variants: variantNames,
};
}
async function applyHotoDetailPolicy(document, sourceReport) {
verifySourceReport(sourceReport, HOTO_SOURCE);
for (const target of Object.values(HOTO_TARGETS)) verifyNativeMaterial(sourceReport, target.nativeMaterial, target.texture);
const root = document.getRoot();
const sourceTextures = new Map();
const targetMaterials = new Map();
for (const [materialName, target] of Object.entries(HOTO_TARGETS)) {
const material = root.listMaterials().find((item) => item.getName() === materialName);
if (!material) throw new Error(`HOTO surface policy cannot find canonical material ${materialName}.`);
const texture = material.getBaseColorTexture();
const image = Buffer.from(texture?.getImage() ?? []);
if (texture?.getName() !== target.image || sha256(image) !== target.imageSha256) {
throw new Error(`HOTO ${materialName} base atlas/tile is not the pinned source PNG.`);
}
if (material.getNormalTexture() || material.getMetallicRoughnessTexture() || material.getEmissiveTexture()) {
throw new Error(`HOTO surface policy refuses unexpected auxiliary native texture slots on ${materialName}.`);
}
targetMaterials.set(materialName, material);
sourceTextures.set(materialName, texture);
}
const detailSources = new Map();
for (const [role, detail] of Object.entries(HOTO_DETAIL)) {
const material = targetMaterials.get(detail.canonicalMaterial);
const texture = sourceTextures.get(detail.canonicalMaterial);
if (texture?.getName() !== detail.image || sha256(texture.getImage() ?? Buffer.alloc(0)) !== detail.imageSha256) {
throw new Error(`HOTO ${role} detail tile is not the native, byte-identical image.`);
}
detailSources.set(role, { ...detail, sourceTexturePath: detail.texture, textureObject: texture, meanLinear: await meanLinearFromPng(Buffer.from(texture.getImage())) });
}
const expectedByMaterial = new Map();
const detailProofs = {};
for (const [role, detail] of detailSources) {
detailProofs[role] = {
nativeMaterial: detail.nativeMaterial,
canonicalMaterialName: detail.canonicalMaterial,
sourceTexture: detail.sourceTexturePath,
sourceImage: detail.image,
sourcePreparedPngSha256: detail.imageSha256,
meanLinear: detail.meanLinear,
};
}
for (const [materialName, target] of Object.entries(HOTO_TARGETS)) {
const material = targetMaterials.get(materialName);
const baseTexture = sourceTextures.get(materialName);
const baseMeanLinear = target.useOwnBaseMean ? await meanLinearFromPng(Buffer.from(baseTexture.getImage())) : undefined;
const floorRole = target.floorVariantRole ?? "floor";
const sourceProof = {
sourceModel: HOTO_SOURCE.model,
sourceModelSha256: HOTO_SOURCE.sha256,
sourceMaterial: target.nativeMaterial,
canonicalMaterialName: materialName,
sourceTexture: target.texture,
sourceTextureName: target.image,
sourcePreparedPngSha256: target.imageSha256,
floorDetailSource: detailProofs[floorRole],
wallDetailSource: detailProofs.wall,
floorVariantRole: floorRole,
floorBaseStrength: target.floorBaseStrength,
floorStrength: target.floorStrength,
wallStrength: target.wallStrength,
wallBaseContrast: target.wallBaseContrast,
wallDetailVariation: target.wallDetailVariation,
};
const metadata = {
version: 1,
floorVariant: HOTO_VARIANTS[floorRole],
wallVariant: HOTO_VARIANTS.wall,
periodMeters: 2,
floorBaseStrength: target.floorBaseStrength,
floorStrength: target.floorStrength,
wallStrength: target.wallStrength,
floorMeanLinear: detailSources.get(floorRole).meanLinear,
wallMeanLinear: detailSources.get("wall").meanLinear,
wallBaseContrast: target.wallBaseContrast,
wallDetailVariation: target.wallDetailVariation,
policyId: HOTO_POLICY_ID,
sourceProof,
};
if (baseMeanLinear) metadata.baseMeanLinear = baseMeanLinear;
expectedByMaterial.set(materialName, metadata);
}
const carrierNames = new Map(Object.entries(HOTO_VARIANTS).map(([role, variantName]) => [variantName, role]));
const existingMaterialPolicies = [...targetMaterials.values()].map((material) => material.getExtras()?.runewakerSurfaceDetail?.policyId ?? null);
const hasExistingPolicy = existingMaterialPolicies.some(Boolean);
if (hasExistingPolicy) {
if (existingMaterialPolicies.some((id) => id !== HOTO_POLICY_ID && id !== HOTO_LEGACY_POLICY_ID)) {
throw new Error("HOTO surface metadata contains an unknown or partial policy marker.");
}
const existingPolicy = existingMaterialPolicies[0];
if (!existingPolicy || existingMaterialPolicies.some((id) => id !== existingPolicy)) {
throw new Error("HOTO source-rock detail policy is only partially applied.");
}
const extension = root.listExtensionsUsed().find((item) => item.extensionName === KHRMaterialsVariants.EXTENSION_NAME);
const variantByName = new Map((extension?.listVariants() ?? []).map((variant) => [variant.getName(), variant]));
if (!extension || [...carrierNames.keys()].some((name) => !variantByName.has(name))) {
throw new Error("HOTO source-rock detail policy marker exists but its material variants are absent.");
}
const carrierByName = new Map();
for (const [variantName, role] of carrierNames) {
const detail = detailSources.get(role);
const carrier = root.listMaterials().find((material) => material.getName() === `${variantName}:source-tile-carrier`);
const carrierProof = carrier?.getExtras()?.runewakerSurfaceDetailCarrier;
if (!carrier || carrier.getBaseColorTexture() !== detail.textureObject
|| carrierProof?.policyId !== existingPolicy
|| carrierProof?.sourceResourceSha256 !== HOTO_SOURCE.sha256
|| carrierProof?.sourceTextureName !== detail.image
|| carrierProof?.sourcePreparedPngSha256 !== detail.imageSha256) {
throw new Error(`HOTO ${variantName} carrier does not match the pinned source tile/policy.`);
}
carrierByName.set(variantName, carrier);
}
let mappedPrimitiveCount = 0;
for (const node of root.listNodes()) for (const primitive of node.getMesh()?.listPrimitives() ?? []) {
const materialName = primitive.getMaterial()?.getName();
if (!targetMaterials.has(materialName)) continue;
const mappingList = primitive.getExtension(KHRMaterialsVariants.EXTENSION_NAME)?.listMappings() ?? [];
const mappedNames = new Set();
for (const mapping of mappingList) {
if (mapping.listVariants().length !== 1) throw new Error(`HOTO ${materialName} has a bundled or ambiguous surface-detail mapping.`);
const variantName = mapping.listVariants()[0].getName();
if (!carrierByName.has(variantName) || mapping.getMaterial() !== carrierByName.get(variantName) || mappedNames.has(variantName)) {
throw new Error(`HOTO ${materialName} surface-detail mapping does not point to its verified source carrier.`);
}
mappedNames.add(variantName);
}
if (mappedNames.size !== 2 || [...carrierNames.keys()].some((name) => !mappedNames.has(name))) {
throw new Error(`HOTO ${materialName} is missing one of the pinned source-detail mappings.`);
}
mappedPrimitiveCount += 1;
}
if (mappedPrimitiveCount !== 36) throw new Error(`HOTO source-rock detail mappings are incomplete: expected 36, found ${mappedPrimitiveCount}.`);
if (existingPolicy === HOTO_LEGACY_POLICY_ID) {
for (const [materialName, target] of Object.entries(HOTO_TARGETS)) {
const old = targetMaterials.get(materialName).getExtras()?.runewakerSurfaceDetail;
const proof = old?.sourceProof;
if (old?.version !== 1 || old.floorVariant !== HOTO_VARIANTS.floor || old.wallVariant !== HOTO_VARIANTS.wall
|| old.periodMeters !== 2 || old.floorStrength !== 0.65 || old.floorBaseStrength !== undefined && old.floorBaseStrength !== 0
|| old.wallStrength !== (target.legacyWallStrength ?? target.wallStrength)
|| old.wallBaseContrast !== undefined && old.wallBaseContrast !== 1
|| old.wallDetailVariation !== undefined && old.wallDetailVariation !== 0
|| JSON.stringify(old.floorMeanLinear) !== JSON.stringify(detailSources.get("floor").meanLinear)
|| JSON.stringify(old.wallMeanLinear) !== JSON.stringify(detailSources.get("wall").meanLinear)
|| proof?.sourceModelSha256 !== HOTO_SOURCE.sha256 || proof?.canonicalMaterialName !== materialName
|| proof?.sourceMaterial !== target.nativeMaterial || proof?.sourceTexture !== target.texture
|| proof?.sourceTextureName !== target.image || proof?.sourcePreparedPngSha256 !== target.imageSha256) {
throw new Error(`HOTO legacy v1 metadata on ${materialName} is not the reviewed migration source.`);
}
}
for (const carrier of carrierByName.values()) {
const extras = carrier.getExtras() ?? {};
carrier.setExtras({ ...extras, runewakerSurfaceDetailCarrier: { ...extras.runewakerSurfaceDetailCarrier, policyId: HOTO_POLICY_ID } });
}
for (const [materialName, metadata] of expectedByMaterial) {
const material = targetMaterials.get(materialName);
material.setExtras({ ...(material.getExtras() ?? {}), runewakerSurfaceDetail: metadata });
}
return { id: HOTO_POLICY_ID, status: "migrated-v1", migratedFrom: HOTO_LEGACY_POLICY_ID, mappedPrimitiveCount, materials: Object.keys(HOTO_TARGETS) };
}
for (const [variantName, role] of carrierNames) {
const detail = detailSources.get(role);
const proof = carrierByName.get(variantName).getExtras()?.runewakerSurfaceDetailCarrier;
if (proof?.variantName !== variantName || proof?.sourceMaterial !== detail.nativeMaterial
|| proof?.canonicalMaterialName !== detail.canonicalMaterial || proof?.sourceTexturePath !== detail.texture) {
throw new Error(`HOTO ${variantName} carrier provenance does not match the pinned source.`);
}
}
for (const [materialName, metadata] of expectedByMaterial) {
const existing = targetMaterials.get(materialName).getExtras()?.runewakerSurfaceDetail;
if (JSON.stringify(existing) !== JSON.stringify(metadata)) throw new Error(`HOTO v2 surface metadata on ${materialName} differs from the reviewed policy.`);
}
return { id: HOTO_POLICY_ID, status: "already-applied", mappedPrimitiveCount, materials: Object.keys(HOTO_TARGETS) };
}
if (root.listMaterials().some((material) => material.getExtras()?.runewakerSurfaceDetailCarrier?.policyId === HOTO_LEGACY_POLICY_ID
|| material.getExtras()?.runewakerSurfaceDetailCarrier?.policyId === HOTO_POLICY_ID)) {
throw new Error("HOTO source-tile carrier markers exist without the complete source-detail policy.");
}
const { getOrCreate } = listVariants(document);
const variantByName = new Map(Object.values(HOTO_VARIANTS).map((name) => [name, getOrCreate(name)]));
const carrierByName = new Map();
for (const [role, detail] of detailSources) {
const variantName = HOTO_VARIANTS[role];
const extras = {
version: 1, variantName, policyId: HOTO_POLICY_ID, sourceResourceSha256: HOTO_SOURCE.sha256,
sourceMaterial: detail.nativeMaterial, canonicalMaterialName: detail.canonicalMaterial,
sourceTexturePath: detail.sourceTexturePath, sourceTextureName: detail.image, sourcePreparedPngSha256: detail.imageSha256,
};
carrierByName.set(variantName, getOrCreateCarrier(document, detail.textureObject, variantName, extras));
}
let mappedPrimitiveCount = 0;
for (const node of root.listNodes()) {
const mesh = node.getMesh();
if (!mesh) continue;
for (const primitive of mesh.listPrimitives()) {
const materialName = primitive.getMaterial()?.getName();
if (targetMaterials.get(materialName) !== primitive.getMaterial()) continue;
addSurfaceMappings(document, primitive, carrierByName, variantByName);
mappedPrimitiveCount += 1;
}
}
if (mappedPrimitiveCount !== 36) throw new Error(`HOTO source-rock detail target count changed: expected 36, found ${mappedPrimitiveCount}.`);
for (const [materialName, metadata] of expectedByMaterial) {
const material = targetMaterials.get(materialName);
material.setExtras({ ...(material.getExtras() ?? {}), runewakerSurfaceDetail: metadata });
}
return {
id: HOTO_POLICY_ID,
status: "applied",
sourceModel: HOTO_SOURCE.model,
sourceModelSha256: HOTO_SOURCE.sha256,
detail: { periodMeters: 2, sourceSpace: "browser-adapted world-space detail; native base UV0 retained" },
detailSources: detailProofs,
materials: Object.keys(HOTO_TARGETS).map((name) => ({ name, metadata: expectedByMaterial.get(name) })),
mappedPrimitiveCount,
sourceGeometryChanged: false,
nativeBaseTexturesAndMaterialSemanticsPreserved: true,
migration: "v1 metadata migrates only when every target material, tile carrier and variant mapping matches the reviewed legacy configuration",
};
}
/** Source-pinned atlas-safe crops and native source-rock detail carriers. */
export async function applyRunewakerSurfaceQualityPolicies(document, { dungeonId, policyIds = [], sourceReport } = {}) {
if (new Set(policyIds).size !== policyIds.length) throw new Error("RoM surface-quality policy IDs must be unique.");
const results = [];
for (const policyId of policyIds) {
if (dungeonId === "barren-caves" && policyId === BARREN_POLICY_ID) results.push(await applyBarrenFloorPolicy(document, sourceReport));
else if (dungeonId === "hoto" && policyId === HOTO_POLICY_ID) results.push(await applyHotoDetailPolicy(document, sourceReport));
else throw new Error(`Unknown or mismatched RoM surface-quality policy ${policyId} for ${dungeonId}.`);
}
return { schemaVersion: 1, dungeonId, status: policyIds.length ? "applied" : "no-policy", policies: results };
}
export const runewakerSurfaceQualityPolicyIds = Object.freeze({ barrenCaves: BARREN_POLICY_ID, hoto: HOTO_POLICY_ID });
@@ -0,0 +1,22 @@
import { Document, NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { applyRunewakerVisualUvAdaptations } from "./rom-visual-uv-adaptations.mjs";
import { applyRunewakerSurfaceQualityPolicies } from "./rom-surface-quality-policies.mjs";
export async function applyRunewakerVisualUvAdaptationsToFile(file, options) {
const policyIds = options.policyIds ?? [];
const surfaceQualityPolicyIds = options.surfaceQualityPolicyIds ?? [];
if (!policyIds.length && !surfaceQualityPolicyIds.length) {
return applyRunewakerVisualUvAdaptations(new Document(), { ...options, policyIds: [] });
}
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const document = await io.read(file);
const visualUv = applyRunewakerVisualUvAdaptations(document, { ...options, policyIds });
const surfaceQuality = await applyRunewakerSurfaceQualityPolicies(document, {
dungeonId: options.dungeonId,
policyIds: surfaceQualityPolicyIds,
sourceReport: options.sourceReport,
});
await io.write(file, document);
return { ...visualUv, surfaceQuality };
}
@@ -0,0 +1,203 @@
import { createHash } from "node:crypto";
import { Accessor } from "@gltf-transform/core";
// Reviewed browser surface fixes live in rom-surface-quality-policies.mjs.
// This registry remains for future UV-only adaptations whose projection is
// safe to express as one source-UV transform.
const POLICIES = Object.freeze({});
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
const accessorHash = (accessor) => {
const array = accessor.getArray();
return sha256(Buffer.from(array.buffer, array.byteOffset, array.byteLength));
};
const normalizeResource = (value) => String(value ?? "").replaceAll("\\", "/").replace(/^\/+/, "").toLowerCase();
function sourceProof(policy, dungeonId, sourceReport) {
if (dungeonId !== policy.dungeonId) throw new Error(`UV policy ${policy.id} cannot run for ${dungeonId}.`);
if (normalizeResource(sourceReport?.sourceModel) !== policy.sourceModel.toLowerCase()) {
throw new Error(`UV policy ${policy.id} source ROS path does not match the pinned model.`);
}
if (String(sourceReport?.sourceResourceSha256 ?? "").toLowerCase() !== policy.sourceModelSha256) {
throw new Error(`UV policy ${policy.id} source ROS SHA-256 does not match the reviewed source.`);
}
const matches = (sourceReport.materials ?? []).filter((material) => material.name === policy.sourceMaterial);
if (matches.length !== 1) throw new Error(`UV policy ${policy.id} expected one exact native material record; found ${matches.length}.`);
const sourceMaterial = matches[0];
if (normalizeResource(sourceMaterial.sourceTexture) !== policy.sourceTexture.toLowerCase()) {
throw new Error(`UV policy ${policy.id} native diffuse texture path does not match the reviewed source.`);
}
if (normalizeResource(sourceMaterial.stagedTexture).split("/").at(-1) !== policy.stagedTexture.toLowerCase()) {
throw new Error(`UV policy ${policy.id} staged texture basename does not match the reviewed source.`);
}
return {
sourceModel: policy.sourceModel,
sourceModelSha256: policy.sourceModelSha256,
sourceMaterial: policy.sourceMaterial,
sourceTexture: policy.sourceTexture,
stagedTexture: policy.stagedTexture,
};
}
function policyMarker(policy) {
return {
id: policy.id,
version: 1,
repeat: [...policy.repeat],
sourceAttribute: "TEXCOORD_0",
adaptedAttribute: "TEXCOORD_1",
sourceMaterial: policy.sourceMaterial,
sourceTexture: policy.gltfTexture,
};
}
function validateAdaptedPrimitive(primitive, policy) {
const info = primitive.getMaterial()?.getBaseColorTextureInfo();
const uv0 = primitive.getAttribute("TEXCOORD_0");
const uv1 = primitive.getAttribute("TEXCOORD_1");
if (!uv0 || !uv1 || info?.getTexCoord() !== 1) throw new Error(`Previously adapted primitive is incomplete for ${policy.id}.`);
if (primitive.getExtras()?.romUvAdaptation?.id !== policy.id) throw new Error(`Previously adapted primitive has a mismatched marker for ${policy.id}.`);
if (uv0.getCount() !== uv1.getCount()) throw new Error(`Previously adapted UV accessor counts differ for ${policy.id}.`);
for (let index = 0; index < uv0.getCount(); index += 1) {
const source = uv0.getElement(index, [0, 0]);
const adapted = uv1.getElement(index, [0, 0]);
if (Math.abs(adapted[0] - source[0] * policy.repeat[0]) > 1e-6
|| Math.abs(adapted[1] - source[1] * policy.repeat[1]) > 1e-6) {
throw new Error(`Previously adapted UV values are invalid for ${policy.id}.`);
}
}
}
/**
* Applies explicitly opted-in, source-pinned RoM UV corrections to a canonical
* unoptimized visual document. The source UV0 and all geometry attributes stay
* intact; the cloned base-color material alone reads repeated UV1.
*/
export function applyRunewakerVisualUvAdaptations(document, { dungeonId, policyIds = [], sourceReport } = {}) {
if (new Set(policyIds).size !== policyIds.length) throw new Error("RoM visual UV adaptation policy IDs must be unique.");
const root = document.getRoot();
const result = {
schemaVersion: 1,
dungeonId,
status: policyIds.length ? "applied" : "no-policy",
policies: [],
materialLightingMetadataChanged: false,
geometryChanged: false,
sourceUv0Changed: false,
};
for (const policyId of policyIds) {
const policy = POLICIES[policyId];
if (!policy) throw new Error(`Unknown RoM visual UV adaptation policy: ${policyId}.`);
const proof = sourceProof(policy, dungeonId, sourceReport);
const marker = policyMarker(policy);
const originalMaterial = root.listMaterials().find((material) => material.getName() === policy.sourceMaterial);
if (!originalMaterial) throw new Error(`UV policy ${policy.id} source material is absent from the canonical GLB.`);
const originalTexture = originalMaterial.getBaseColorTexture();
if (originalTexture?.getName() !== policy.gltfTexture || !originalMaterial.getBaseColorTextureInfo()) {
throw new Error(`UV policy ${policy.id} base-color image does not match the source report.`);
}
if (originalMaterial.getNormalTexture() || originalMaterial.getMetallicRoughnessTexture() || originalMaterial.getEmissiveTexture()) {
throw new Error(`UV policy ${policy.id} has unexpected secondary texture slots.`);
}
const primitives = [];
const alreadyAdapted = [];
for (const node of root.listNodes()) {
if (!node.getMesh()) continue;
for (const [primitiveIndex, primitive] of node.getMesh().listPrimitives().entries()) {
if (primitive.getMaterial()?.getName() === policy.sourceMaterial) {
if (primitive.getMaterial().getBaseColorTexture()?.getName() !== policy.gltfTexture) {
throw new Error(`UV policy ${policy.id} material use maps to an unexpected image.`);
}
primitives.push({ node, primitive, primitiveIndex });
} else if (primitive.getExtras()?.romUvAdaptation?.id === policy.id) {
alreadyAdapted.push({ node, primitive, primitiveIndex });
}
}
}
if (alreadyAdapted.length) {
if (primitives.length) throw new Error(`UV policy ${policy.id} document contains a partial adaptation.`);
for (const { primitive } of alreadyAdapted) validateAdaptedPrimitive(primitive, policy);
result.status = "already-applied";
result.policies.push({
...proof,
...marker,
status: "already-applied",
primitiveCount: alreadyAdapted.length,
triangleCount: alreadyAdapted.reduce((sum, { primitive }) => sum + (primitive.getIndices()?.getCount() ?? primitive.getAttribute("POSITION")?.getCount() ?? 0) / 3, 0),
nodes: alreadyAdapted.map(({ node, primitive }) => ({ node: node.getName(), sourceUvHash: accessorHash(primitive.getAttribute("TEXCOORD_0")), adaptedUvHash: accessorHash(primitive.getAttribute("TEXCOORD_1")) })),
lightingMetadataPreserved: true,
});
continue;
}
if (!primitives.length) throw new Error(`UV policy ${policy.id} found no matching canonical GLB primitives.`);
const buffer = root.listBuffers()[0];
if (!buffer) throw new Error(`UV policy ${policy.id} document has no buffer for new accessors.`);
const preflight = primitives.map(({ node, primitive, primitiveIndex }) => {
if (primitive.getExtras()?.romUvAdaptation) throw new Error(`UV policy ${policy.id} target is marked by another adaptation.`);
if (primitive.getAttribute("TEXCOORD_1")) throw new Error(`UV policy ${policy.id} refuses to overwrite existing TEXCOORD_1.`);
const position = primitive.getAttribute("POSITION");
const normal = primitive.getAttribute("NORMAL");
const indices = primitive.getIndices();
const uv0 = primitive.getAttribute("TEXCOORD_0");
if (!position || !normal || !indices || !uv0 || uv0.getType() !== Accessor.Type.VEC2) {
throw new Error(`UV policy ${policy.id} target is missing a required POSITION, NORMAL, index, or VEC2 TEXCOORD_0 accessor.`);
}
return { node, primitive, primitiveIndex, position, normal, indices, uv0 };
});
const adaptedMaterial = originalMaterial.clone().setName(`${policy.sourceMaterial}_repeat${policy.repeat[0]}x${policy.repeat[1]}_adapted`);
adaptedMaterial.getBaseColorTextureInfo().setTexCoord(1);
const policyReport = {
...proof,
...marker,
status: "applied",
primitiveCount: primitives.length,
triangleCount: 0,
nodes: [],
lightingMetadataPreserved: true,
noGeometryChange: true,
noOtherUvSetChanged: true,
};
for (const { node, primitive, primitiveIndex, position, normal, indices, uv0 } of preflight) {
const sourceHashes = {
position: accessorHash(position),
normal: accessorHash(normal),
indices: accessorHash(indices),
uv0: accessorHash(uv0),
};
const values = new Float32Array(uv0.getCount() * 2);
for (let index = 0; index < uv0.getCount(); index += 1) {
const uv = uv0.getElement(index, [0, 0]);
values[index * 2] = uv[0] * policy.repeat[0];
values[index * 2 + 1] = uv[1] * policy.repeat[1];
}
const uv1 = document.createAccessor(`${node.getName()}:${policy.id}:TEXCOORD_1`)
.setType(Accessor.Type.VEC2)
.setArray(values)
.setBuffer(buffer);
primitive.setAttribute("TEXCOORD_1", uv1);
primitive.setMaterial(adaptedMaterial);
primitive.setExtras({ ...(primitive.getExtras() ?? {}), romUvAdaptation: marker });
policyReport.triangleCount += indices.getCount() / 3;
policyReport.nodes.push({
node: node.getName(),
primitiveIndex,
triangles: indices.getCount() / 3,
vertices: position.getCount(),
sourcePositionHash: sourceHashes.position,
sourceNormalHash: sourceHashes.normal,
sourceIndexHash: sourceHashes.indices,
sourceUvHash: sourceHashes.uv0,
adaptedUvHash: sha256(Buffer.from(values.buffer, values.byteOffset, values.byteLength)),
});
}
result.policies.push(policyReport);
}
return result;
}
export function listRunewakerVisualUvAdaptationPolicies() {
return Object.values(POLICIES).map((policy) => ({ ...policy, repeat: [...policy.repeat] }));
}
@@ -0,0 +1,35 @@
import { sourceTerrainToThreeMatrix } from '../sand-vortex/terrain-geometry.mjs';
import { Vector3 } from 'three';
const BOX_FACES = [
[0, 3, 2], [0, 2, 1], [4, 5, 6], [4, 6, 7],
[0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5],
[2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7],
];
/** Match the native ROS exporter: inverse primary TRS followed by reflected Z. */
export function sourceAnchorSupportGeometry(points, placement, {
halfExtent = 60, thickness = 3, metersPerSourceUnit = 1, omitPointIndices = [],
} = {}) {
if (!Array.isArray(points) || !Number.isFinite(halfExtent) || halfExtent <= 0
|| !Number.isFinite(thickness) || thickness <= 0) throw new Error('Invalid source-anchor support dimensions.');
const transform = sourceTerrainToThreeMatrix(placement, metersPerSourceUnit);
if (!Array.isArray(omitPointIndices) || new Set(omitPointIndices).size !== omitPointIndices.length
|| omitPointIndices.some(i => !Number.isInteger(i) || i < 0 || i >= points.length)) throw new Error('Invalid source-anchor support omissions.');
const omitted = new Set(omitPointIndices);
const faces = transform.determinant() < 0 ? BOX_FACES.map(([a, b, c]) => [a, c, b]) : BOX_FACES;
const positions = [], indices = [], navigationPositions = [];
for (const [pointIndex, point] of points.entries()) {
if (omitted.has(pointIndex)) continue;
if (!Array.isArray(point) || point.length !== 3 || !point.every(Number.isFinite)) throw new Error('Invalid source-anchor support point.');
const [x, y, z] = point, h = halfExtent;
const vertices = [[x-h,y,z-h],[x+h,y,z-h],[x+h,y,z+h],[x-h,y,z+h],
[x-h,y-thickness,z-h],[x+h,y-thickness,z-h],[x+h,y-thickness,z+h],[x-h,y-thickness,z+h]]
.map(p => new Vector3(...p).applyMatrix4(transform).toArray());
const offset = positions.length / 3;
positions.push(...vertices.flat());
for (const face of faces) indices.push(...face.map(i => offset + i));
for (const face of faces.slice(0, 2)) for (const i of face) navigationPositions.push(...vertices[i]);
}
return { positions, indices, navigationPositions };
}
@@ -0,0 +1,35 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Matrix4, Vector3 } from 'three';
import { sourceAnchorSupportGeometry } from './source-anchor-support.mjs';
import { descriptorSourceMatrix } from './environment-assembly.mjs';
test('support OBJ and nav use the ROS reflected-Z bridge and upward floor winding', () => {
const placement = { translation: [10, 20, 30], rotation: [0, 0, 0, 1], scale: [1, 1, 1] };
const obj = sourceAnchorSupportGeometry([[12, 25, 60]], placement, { halfExtent: 1, thickness: 2 });
const nav = sourceAnchorSupportGeometry([[12, 25, 60]], placement, { halfExtent: 1, thickness: 2, metersPerSourceUnit: 0.1 });
assert.deepEqual(obj.positions.slice(0, 3), [1, 5, -29]);
assert.ok(nav.navigationPositions.every((v, i) => Math.abs(v - obj.navigationPositions[i] * 0.1) < 1e-12));
const [a, b, c] = obj.indices.slice(0, 3).map(i => new Vector3().fromArray(obj.positions, i * 3));
assert.ok(b.sub(a).cross(c.sub(a)).y > 0);
});
test('rotated nonuniform primary placements preserve the source support footprint', () => {
const placement = { translation: [10, 20, 30], rotation: [0, Math.sin(0.4), 0, Math.cos(0.4)], scale: [2, 3, 4] };
const geometry = sourceAnchorSupportGeometry([[12, 25, 60]], placement, { halfExtent: 1, thickness: 2, metersPerSourceUnit: 0.1 });
const toSource = descriptorSourceMatrix(placement).multiply(new Matrix4().makeScale(10, 10, -10));
const first = new Vector3().fromArray(geometry.positions).applyMatrix4(toSource);
assert.ok(first.distanceTo(new Vector3(11, 25, 59)) < 1e-10);
assert.equal(geometry.indices.length, 36);
assert.equal(geometry.navigationPositions.length, 18);
});
test('a reviewed native-ground omission removes the same footprint from mesh and nav', () => {
const placement = { translation: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] };
const result = sourceAnchorSupportGeometry([[0, 0, 1], [20, 0, 2]], placement, { omitPointIndices: [0] });
assert.equal(result.positions.length, 24);
assert.equal(result.indices.length, 36);
assert.equal(result.navigationPositions.length, 18);
assert.ok(result.positions.filter((_, i) => i % 3 === 0).every(x => x === -40 || x === 80));
assert.throws(() => sourceAnchorSupportGeometry([[0, 0, 1]], placement, { omitPointIndices: [1] }), /omissions/);
});
@@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import sharp from "sharp";
const digest = bytes => createHash("sha256").update(bytes).digest("hex");
const validHash = value => typeof value === "string" && /^[a-f\d]{64}$/u.test(value);
/** Verifies an exact native atlas island, rather than exempting a resized image. */
export async function verifySourcePixelCrop(metadata, sourcePng, canonicalCropPng, shipped) {
assert.equal(metadata?.version, 1, "Unsupported source crop proof.");
assert.ok(typeof metadata.policyId === "string" && metadata.policyId.length > 0);
assert.ok(typeof metadata.sourceImageName === "string" && !/[\\/]/u.test(metadata.sourceImageName));
for (const key of ["sourcePreparedPngSha256", "croppedPngSha256", "croppedRgbaSha256"]) {
assert.ok(validHash(metadata[key]), `Invalid ${key}.`);
}
assert.equal(digest(sourcePng), metadata.sourcePreparedPngSha256, "Native atlas PNG changed.");
assert.equal(digest(canonicalCropPng), metadata.croppedPngSha256, "Canonical crop PNG changed.");
const source = await sharp(sourcePng).metadata();
assert.equal(source.width, metadata.sourceWidth);
assert.equal(source.height, metadata.sourceHeight);
const rect = metadata.rect;
assert.ok(rect && [rect.left, rect.top, rect.width, rect.height].every(Number.isInteger));
assert.ok(rect.left >= 0 && rect.top >= 0 && rect.width > 0 && rect.height > 0
&& rect.left + rect.width <= source.width && rect.top + rect.height <= source.height,
"Source crop exceeds its verified native atlas.");
const crop = await sharp(canonicalCropPng).metadata();
assert.equal(crop.width, rect.width, "Canonical crop was resized.");
assert.equal(crop.height, rect.height, "Canonical crop was resized.");
assert.equal(shipped.width, rect.width, "Shipped crop was resized.");
assert.equal(shipped.height, rect.height, "Shipped crop was resized.");
if (shipped.levelCount !== undefined) {
assert.equal(shipped.levelCount, Math.floor(Math.log2(Math.max(rect.width, rect.height))) + 1,
"Shipped crop lost its full native island mip chain.");
}
const sourcePixels = await sharp(sourcePng).extract(rect).ensureAlpha().raw().toBuffer();
const cropPixels = await sharp(canonicalCropPng).ensureAlpha().raw().toBuffer();
assert.equal(digest(sourcePixels), metadata.croppedRgbaSha256, "Native crop pixel proof changed.");
assert.deepEqual(cropPixels, sourcePixels, "Derived crop changed native RGBA pixels.");
return { ...metadata, status: "verified-exact-source-pixel-crop", canonicalPixelsByteIdentical: true,
shippedDimensionsPreserved: true, compressionPixelsRequireSeparateDecode: true };
}
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import test from "node:test";
import sharp from "sharp";
import { verifySourcePixelCrop } from "./source-pixel-crop-proof.mjs";
const digest = bytes => createHash("sha256").update(bytes).digest("hex");
async function fixture() {
const pixels = Buffer.from([10, 20, 30, 0, 40, 50, 60, 255, 70, 80, 90, 128, 100, 110, 120, 255]);
const source = await sharp(pixels, { raw: { width: 2, height: 2, channels: 4 } }).png().toBuffer();
const rect = { left: 0, top: 0, width: 1, height: 2 };
const crop = await sharp(source).extract(rect).png().toBuffer();
const cropPixels = await sharp(crop).ensureAlpha().raw().toBuffer();
const metadata = { version: 1, policyId: "fixture-island", sourceImageName: "native-atlas", sourceWidth: 2, sourceHeight: 2,
sourcePreparedPngSha256: digest(source), rect, croppedPngSha256: digest(crop), croppedRgbaSha256: digest(cropPixels) };
return { source, crop, metadata, shipped: { width: 1, height: 2 } };
}
test("exact source crop retains native RGBA and distinguishes island dimensions from downsampling", async () => {
const f = await fixture();
assert.equal((await verifySourcePixelCrop(f.metadata, f.source, f.crop, f.shipped)).canonicalPixelsByteIdentical, true);
});
test("crop proof rejects changed pixels, out-of-bounds metadata, and shipping downsampling", async () => {
const f = await fixture();
const changed = await sharp(f.crop).removeAlpha().png().toBuffer();
await assert.rejects(verifySourcePixelCrop({ ...f.metadata, croppedPngSha256: digest(changed) }, f.source, changed, f.shipped), /RGBA pixels/u);
await assert.rejects(verifySourcePixelCrop({ ...f.metadata, rect: { ...f.metadata.rect, left: 2 } }, f.source, f.crop, f.shipped), /exceeds/u);
await assert.rejects(verifySourcePixelCrop(f.metadata, f.source, f.crop, { width: 1, height: 1 }), /Shipped crop/u);
await assert.rejects(verifySourcePixelCrop(f.metadata, f.source, f.crop, { ...f.shipped, levelCount: 1 }), /mip chain/u);
});
@@ -0,0 +1,241 @@
import { createHash } from "node:crypto";
import { Matrix3, Matrix4, Vector3 } from "three";
import { sourceAnchorSupportGeometry } from "./source-anchor-support.mjs";
const POSITION_TOLERANCE = 2e-4;
function matrixForNode(node) {
return new Matrix4().fromArray(Array.from(node.getWorldMatrix()));
}
function applyPoint(matrix, values) {
return new Vector3().fromArray(values).applyMatrix4(matrix).toArray();
}
function reflectWorldZ(values) {
return values.map((value, index) => index % 3 === 2 ? -value : value);
}
function reverseTriangleWinding(indices) {
const reversed = Array.from(indices);
if (reversed.length % 3 !== 0) throw new Error("Support index buffer is not a triangle list.");
for (let i = 0; i < reversed.length; i += 3) [reversed[i + 1], reversed[i + 2]] = [reversed[i + 2], reversed[i + 1]];
return reversed;
}
function oldNavigationOrder(correctedPositions) {
const oldOrder = [];
if (correctedPositions.length % 9 !== 0) throw new Error("Support navigation positions are not a triangle list.");
for (let i = 0; i < correctedPositions.length; i += 9) {
oldOrder.push(
...correctedPositions.slice(i, i + 3),
...correctedPositions.slice(i + 6, i + 9),
...correctedPositions.slice(i + 3, i + 6),
);
}
return oldOrder;
}
function closeArrays(actual, expected, label, tolerance = POSITION_TOLERANCE) {
if (actual.length !== expected.length) throw new Error(`${label} length ${actual.length} does not match expected ${expected.length}.`);
let maximumError = 0;
for (let i = 0; i < actual.length; i += 1) {
const error = Math.abs(Number(actual[i]) - Number(expected[i]));
maximumError = Math.max(maximumError, error);
if (!Number.isFinite(error) || error > tolerance) {
throw new Error(`${label} mismatches reviewed source geometry at scalar ${i}: ${actual[i]} vs ${expected[i]} (error ${error}).`);
}
}
return maximumError;
}
function accessorHash(accessor) {
if (!accessor) return null;
const values = accessor.getArray();
return createHash("sha256").update(Buffer.from(values.buffer, values.byteOffset, values.byteLength)).digest("hex");
}
function addAccessor(document, primitive, semantic, oldAccessor, array, type = oldAccessor.getType()) {
const buffer = document.getRoot().listBuffers()[0] ?? document.createBuffer("source-anchor-support-correction-buffer");
const accessor = document.createAccessor(`${oldAccessor.getName() || semantic}:z-corrected`)
.setType(type)
.setArray(array)
.setNormalized(oldAccessor.getNormalized())
.setExtras({ ...oldAccessor.getExtras() })
.setBuffer(buffer);
primitive.setAttribute(semantic, accessor);
return accessor;
}
function keptVertexIndices(pointCount, omittedPointIndices) {
const omitted = new Set(omittedPointIndices);
for (const index of omitted) {
if (!Number.isInteger(index) || index < 0 || index >= pointCount) {
throw new Error(`Omitted source-anchor point index ${index} is outside [0, ${pointCount}).`);
}
}
return Array.from({ length: pointCount }, (_, index) => index).filter((index) => !omitted.has(index));
}
/**
* Corrects only a generated support primitive after proving its old world-space
* vertices and winding match the pre-bridge source-local support writer.
*/
export function stageCorrectSourceAnchorSupportPrimitive(document, {
node,
primitive,
points,
placement,
halfExtent = 60,
thickness = 3,
metersPerSourceUnit = 0.1,
omitPointIndices = [],
} = {}) {
if (!node?.getMesh() || !primitive || !Array.isArray(points) || !points.length) {
throw new Error("Source-anchor correction requires one generated support mesh and its source points.");
}
const position = primitive.getAttribute("POSITION");
const normal = primitive.getAttribute("NORMAL");
const indices = primitive.getIndices();
if (!position || !indices || position.getType() !== "VEC3" || position.getCount() !== points.length * 8
|| indices.getCount() !== points.length * 36) {
throw new Error("Generated support topology does not match 8 vertices / 36 indices per source point.");
}
const matrix = matrixForNode(node);
if (Math.abs(matrix.determinant()) < 1e-12) throw new Error("Generated support node transform is singular.");
const inverse = matrix.clone().invert();
const allExpected = sourceAnchorSupportGeometry(points, placement, {
halfExtent, thickness, metersPerSourceUnit,
});
const oldWorldPositions = [];
const localPositionValues = Array.from(position.getArray());
for (let offset = 0; offset < localPositionValues.length; offset += 3) {
oldWorldPositions.push(...applyPoint(matrix, localPositionValues.slice(offset, offset + 3)));
}
const maxOldPositionError = closeArrays(oldWorldPositions, reflectWorldZ(allExpected.positions), "Old support source-local position proof");
const indexValues = Array.from(indices.getArray());
const expectedOldWinding = reverseTriangleWinding(allExpected.indices);
const maxOldWindingMismatch = indexValues.length === expectedOldWinding.length
? indexValues.reduce((m, value, index) => Math.max(m, Math.abs(value - expectedOldWinding[index])), 0)
: Infinity;
if (maxOldWindingMismatch !== 0) throw new Error("Generated support winding does not match the old unreflected source-local box faces.");
if (normal && normal.getCount() !== position.getCount()) throw new Error("Generated support NORMAL count differs from POSITION.");
const keptPoints = keptVertexIndices(points.length, omitPointIndices);
const corrected = sourceAnchorSupportGeometry(keptPoints.map((index) => points[index]), placement, {
halfExtent, thickness, metersPerSourceUnit,
});
const correctedLocalPositions = [];
for (let offset = 0; offset < corrected.positions.length; offset += 3) {
correctedLocalPositions.push(...applyPoint(inverse, corrected.positions.slice(offset, offset + 3)));
}
const correctedPositionAccessor = addAccessor(
document, primitive, "POSITION", position, new Float32Array(correctedLocalPositions), position.getType(),
);
let correctedNormalAccessor = null;
if (normal) {
const normalMatrix = new Matrix3().getNormalMatrix(matrix);
const inverseNormalMatrix = normalMatrix.clone().invert();
const oldNormalValues = normal.getArray();
const correctedNormals = [];
for (const pointIndex of keptPoints) {
for (let vertex = 0; vertex < 8; vertex += 1) {
const scalar = (pointIndex * 8 + vertex) * 3;
const worldNormal = new Vector3().fromArray(oldNormalValues, scalar).applyMatrix3(normalMatrix).normalize();
worldNormal.z *= -1;
const localNormal = worldNormal.applyMatrix3(inverseNormalMatrix).normalize();
correctedNormals.push(localNormal.x, localNormal.y, localNormal.z);
}
}
correctedNormalAccessor = addAccessor(
document, primitive, "NORMAL", normal, new Float32Array(correctedNormals), normal.getType(),
);
}
const oldIndexHash = accessorHash(indices);
const indexArray = indices.getArray();
const correctedIndexValues = new indexArray.constructor(corrected.indices);
const indexBuffer = document.getRoot().listBuffers()[0] ?? document.createBuffer("source-anchor-support-correction-buffer");
const correctedIndexAccessor = document.createAccessor(`${indices.getName() || "support-indices"}:z-corrected`)
.setType(indices.getType())
.setArray(correctedIndexValues)
.setNormalized(indices.getNormalized())
.setExtras({ ...indices.getExtras() })
.setBuffer(indexBuffer);
primitive.setIndices(correctedIndexAccessor);
return {
status: "staged",
sourcePointCount: points.length,
omittedPointIndices: [...omitPointIndices],
retainedPadCount: keptPoints.length,
oldPositionHash: accessorHash(position),
correctedPositionHash: accessorHash(correctedPositionAccessor),
oldNormalHash: accessorHash(normal),
correctedNormalHash: accessorHash(correctedNormalAccessor),
oldIndexHash,
correctedIndexHash: accessorHash(correctedIndexAccessor),
sourceLocalPositionProofMaxError: maxOldPositionError,
sourceLocalWindingProofMaxIndexDifference: maxOldWindingMismatch,
correctedTriangleCount: corrected.indices.length / 3,
correctedNavigationTriangleCount: corrected.navigationPositions.length / 9,
worldZReflectedForGeneratedSupportOnly: true,
reflectedSupportNormalsAndCorrectedWinding: true,
nativePrimitivesTouched: false,
};
}
/** Corrects the explicitly appended support-nav triangles; the baked Recast mesh is untouched. */
export function stageCorrectSourceAnchorNavigationPrimitive(document, {
node,
primitive,
points,
placement,
halfExtent = 60,
thickness = 3,
metersPerSourceUnit = 0.1,
omitPointIndices = [],
} = {}) {
if (!node?.getMesh() || !primitive || !Array.isArray(points) || !points.length) {
throw new Error("Source-anchor navigation correction requires the generated navigation primitive and source points.");
}
const position = primitive.getAttribute("POSITION");
if (!position || position.getType() !== "VEC3" || position.getCount() !== points.length * 6) {
throw new Error("Generated support navigation topology does not match 6 vertices per source point.");
}
const matrix = matrixForNode(node);
if (Math.abs(matrix.determinant()) < 1e-12) throw new Error("Generated support navigation node transform is singular.");
const inverse = matrix.clone().invert();
const allExpected = sourceAnchorSupportGeometry(points, placement, {
halfExtent, thickness, metersPerSourceUnit,
});
const oldWorldPositions = [];
const oldLocal = Array.from(position.getArray());
for (let offset = 0; offset < oldLocal.length; offset += 3) {
oldWorldPositions.push(...applyPoint(matrix, oldLocal.slice(offset, offset + 3)));
}
const maxOldPositionError = closeArrays(oldWorldPositions, oldNavigationOrder(allExpected.navigationPositions), "Old support-nav source position/winding proof");
const keptPoints = keptVertexIndices(points.length, omitPointIndices);
const corrected = sourceAnchorSupportGeometry(keptPoints.map((index) => points[index]), placement, {
halfExtent, thickness, metersPerSourceUnit,
});
const correctedLocal = [];
for (let offset = 0; offset < corrected.navigationPositions.length; offset += 3) {
correctedLocal.push(...applyPoint(inverse, corrected.navigationPositions.slice(offset, offset + 3)));
}
const accessor = addAccessor(
document, primitive, "POSITION", position, new Float32Array(correctedLocal), position.getType(),
);
return {
status: "staged",
sourcePointCount: points.length,
omittedPointIndices: [...omitPointIndices],
retainedPadCount: keptPoints.length,
oldPositionHash: accessorHash(position),
correctedPositionHash: accessorHash(accessor),
sourceLocalPositionAndOldWindingProofMaxError: maxOldPositionError,
correctedTriangleCount: corrected.navigationPositions.length / 9,
bakedRecastNavigationPreservedUnchanged: true,
};
}
@@ -0,0 +1,110 @@
import test from "node:test";
import assert from "node:assert/strict";
import { Document, Accessor } from "@gltf-transform/core";
import { Matrix3, Matrix4, Quaternion, Vector3 } from "three";
import { sourceAnchorSupportGeometry } from "./source-anchor-support.mjs";
import {
stageCorrectSourceAnchorNavigationPrimitive,
stageCorrectSourceAnchorSupportPrimitive,
} from "./stage-source-anchor-support-correction.mjs";
const placement = { translation: [12, 5, -3], rotation: [0, 0, 0, 1], scale: [1, 1, 1] };
const points = [[18, 32, 44], [50, 12, -8]];
const geometry = sourceAnchorSupportGeometry(points, placement, {
halfExtent: 3, thickness: 2, metersPerSourceUnit: 0.1,
});
function makeDocumentWithOldGeneratedSupport() {
const document = new Document();
const buffer = document.createBuffer("test-buffer");
const rotation = new Quaternion().setFromAxisAngle(new Vector3(1, 0, 0), Math.PI / 2);
const world = new Matrix4().compose(new Vector3(0, 0, 0), rotation, new Vector3(0.1, 0.1, 0.1));
const inverse = world.clone().invert();
const oldWorldPositions = geometry.positions.map((value, index) => index % 3 === 2 ? -value : value);
const oldLocalPositions = [];
for (let i = 0; i < oldWorldPositions.length; i += 3) {
oldLocalPositions.push(...new Vector3().fromArray(oldWorldPositions, i).applyMatrix4(inverse).toArray());
}
const oldIndices = Array.from(geometry.indices);
for (let i = 0; i < oldIndices.length; i += 3) [oldIndices[i + 1], oldIndices[i + 2]] = [oldIndices[i + 2], oldIndices[i + 1]];
const normalMatrix = new Matrix3().getNormalMatrix(world);
const inverseNormal = normalMatrix.clone().invert();
const oldNormals = [];
for (let i = 0; i < oldLocalPositions.length; i += 3) {
const worldNormal = new Vector3(0.25, 0.5, Math.sqrt(0.6875));
oldNormals.push(...worldNormal.applyMatrix3(inverseNormal).normalize().toArray());
}
const primitive = document.createPrimitive()
.setAttribute("POSITION", document.createAccessor("old-position", buffer).setType(Accessor.Type.VEC3).setArray(new Float32Array(oldLocalPositions)))
.setAttribute("NORMAL", document.createAccessor("old-normal", buffer).setType(Accessor.Type.VEC3).setArray(new Float32Array(oldNormals)))
.setIndices(document.createAccessor("old-index", buffer).setType(Accessor.Type.SCALAR).setArray(new Uint16Array(oldIndices)));
const supportNode = document.createNode("runewaker_source_anchor_support")
.setRotation(rotation.toArray()).setScale([0.1, 0.1, 0.1])
.setMesh(document.createMesh("support-mesh").addPrimitive(primitive));
const nativePrimitive = document.createPrimitive().setAttribute("POSITION", document.createAccessor("native-position", buffer)
.setType(Accessor.Type.VEC3).setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 0, 1])));
const nativeNode = document.createNode("native-ros-mesh").setMesh(document.createMesh("native-mesh").addPrimitive(nativePrimitive));
const scene = document.createScene("scene").addChild(supportNode).addChild(nativeNode);
document.getRoot().setDefaultScene(scene);
const nativeBefore = Array.from(nativePrimitive.getAttribute("POSITION").getArray());
return { document, supportNode, primitive, nativePrimitive, nativeBefore, normalMatrix };
}
test("staged support correction proves source-local input, reflects generated geometry, and omits only requested source points", () => {
const { document, supportNode, primitive, nativePrimitive, nativeBefore, normalMatrix } = makeDocumentWithOldGeneratedSupport();
const report = stageCorrectSourceAnchorSupportPrimitive(document, {
node: supportNode,
primitive,
points,
placement,
halfExtent: 3,
thickness: 2,
metersPerSourceUnit: 0.1,
omitPointIndices: [0],
});
assert.equal(report.sourceLocalPositionProofMaxError < 1e-4, true);
assert.equal(report.retainedPadCount, 1);
assert.equal(primitive.getAttribute("POSITION").getCount(), 8);
assert.equal(primitive.getAttribute("NORMAL").getCount(), 8);
assert.equal(primitive.getIndices().getCount(), 36);
assert.deepEqual(Array.from(nativePrimitive.getAttribute("POSITION").getArray()), nativeBefore);
const matrix = new Matrix4().fromArray(Array.from(supportNode.getWorldMatrix()));
const localPosition = primitive.getAttribute("POSITION").getArray();
const worldPosition = new Vector3().fromArray(localPosition, 0).applyMatrix4(matrix).toArray();
const expected = geometry.positions.slice(8 * 3, 16 * 3);
assert.ok(worldPosition.every((value, axis) => Math.abs(value - expected[axis]) < 1e-4));
const newLocalNormal = primitive.getAttribute("NORMAL").getArray();
const newWorldNormal = new Vector3().fromArray(newLocalNormal, 0).applyMatrix3(normalMatrix).normalize().toArray();
assert.ok(Math.abs(newWorldNormal[0] - 0.25) < 1e-5);
assert.ok(Math.abs(newWorldNormal[1] - 0.5) < 1e-5);
assert.ok(Math.abs(newWorldNormal[2] + Math.sqrt(0.6875)) < 1e-5);
});
test("support-nav correction verifies the old reflected positions and reverses only appended triangles", () => {
const document = new Document();
const buffer = document.createBuffer("nav-buffer");
const oldPositions = [];
for (let i = 0; i < geometry.navigationPositions.length; i += 9) {
oldPositions.push(
...geometry.navigationPositions.slice(i, i + 3),
...geometry.navigationPositions.slice(i + 6, i + 9),
...geometry.navigationPositions.slice(i + 3, i + 6),
);
}
const primitive = document.createPrimitive().setAttribute("POSITION", document.createAccessor("old-nav-position", buffer)
.setType(Accessor.Type.VEC3).setArray(new Float32Array(oldPositions)));
const node = document.createNode("source-anchor-support-navigation").setMesh(document.createMesh("support-nav").addPrimitive(primitive));
document.getRoot().setDefaultScene(document.createScene("nav-scene").addChild(node));
const report = stageCorrectSourceAnchorNavigationPrimitive(document, {
node, primitive, points, placement, halfExtent: 3, thickness: 2, metersPerSourceUnit: 0.1, omitPointIndices: [0],
});
assert.ok(report.sourceLocalPositionAndOldWindingProofMaxError < 1e-4);
assert.equal(report.retainedPadCount, 1);
const actual = Array.from(primitive.getAttribute("POSITION").getArray());
const expected = geometry.navigationPositions.slice(18);
assert.equal(actual.length, expected.length);
assert.equal(primitive.getAttribute("POSITION").getCount(), 6);
for (let index = 0; index < actual.length; index += 1) {
assert.ok(Math.abs(actual[index] - expected[index]) < 1e-6, `nav scalar ${index} differs`);
}
});