Files
healer-man/scripts/runewaker-pipeline/lib/rom-derived-support-styling.mjs
2026-10-06 22:25:34 -04:00

385 lines
18 KiB
JavaScript

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