Files
healer-man/scripts/runewaker-pipeline/stage-native-water-screen-effects.mjs
2026-10-06 22:25:34 -04:00

108 lines
5.9 KiB
JavaScript

import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { inflateSync, constants } from "node:zlib";
import { readDataStore, parseTerrainChunk } from "./sand-vortex/extract-native-terrain.mjs";
import { decodeNativeWaterPayload } from "./lib/native-water-payload.mjs";
import { buildNativeWaterGeometry } from "./lib/native-water-geometry.mjs";
import { nativeWaterRenderPolicy } from "./lib/native-water-render-policy.mjs";
// Metadata-only staging: exact original BIN bytes (geometry, UVs and embedded
// images) remain unchanged. Source split planes are recovered before any mesh
// optimization reorders vertices. No runtime enum name is invented for raw 4.
const projectRoot = process.cwd();
const output = path.resolve(process.argv.find(value => value.startsWith("--output="))?.slice(9)
?? "artifacts/playtest/rom-deep-review/water-screen-effects");
const sha = bytes => createHash("sha256").update(bytes).digest("hex");
const report = JSON.parse(await readFile("src/assets/game/dungeons/the-origin/native-water-report.json", "utf8"));
const wdbPath = path.resolve(report.source.sourceRoot, report.source.wdb);
const wdb = await readFile(wdbPath);
assert.equal(sha(wdb), report.source.wdbSha256);
const tables = readDataStore(wdb).tables;
const payload = row => wdb.subarray(row.position, row.position + row.size);
const descriptors = [...tables.get("water_descriptor").records].map(([key, row]) => ({ key, payload: payload(row) }));
const layers = new Map();
for (const chunk of report.payloadDecode.nativeWaterChunks) {
const compressed = payload(tables.get("chunks").records.get(String(chunk.keyUint32)));
assert.equal(sha(compressed), chunk.compressedSha256);
const terrain = parseTerrainChunk(compressed, chunk.key, { allowUnparsedWaterPayload: true });
const decompressed = inflateSync(compressed, { finishFlush: constants.Z_SYNC_FLUSH });
const decoded = decodeNativeWaterPayload(decompressed.subarray(decompressed.length - terrain.unconsumedBytes), {
layerCount: terrain.waterLayerCount, descriptors,
});
assert.equal(decoded.sourcePayloadSha256, chunk.waterPayloadSha256);
const geometry = buildNativeWaterGeometry(decoded.layers, report.source.selectedPrimaryPlacement, report.source.metersPerSourceUnit);
decoded.layers.forEach((layer, index) => {
assert.equal(layer.descriptorJoin.status, "matched");
const descriptor = layer.descriptorJoin.descriptors[0];
layers.set(`${chunk.coordinate.x},${chunk.coordinate.z},${index}`, {
sourceSplitPlaneHeight: geometry.layers[index].summary.sourceSplitPlaneHeight,
descriptorSha256: descriptor.sha256,
waterBodyGuid: descriptor.guid,
...nativeWaterRenderPolicy(descriptor),
});
});
}
function patchGlb(bytes) {
assert.equal(bytes.readUInt32LE(0), 0x46546c67);
const jsonLength = bytes.readUInt32LE(12);
const json = JSON.parse(bytes.toString("utf8", 20, 20 + jsonLength));
const remainder = bytes.subarray(20 + jsonLength);
let patched = 0;
for (const mesh of json.meshes ?? []) {
for (const primitive of mesh.primitives) {
const layer = primitive.extras?.runewakerNativeWaterLayer;
if (!layer) continue;
const key = `${layer.sourceChunk.x},${layer.sourceChunk.z},${layer.sourceLayerIndex}`;
const source = layers.get(key);
assert.ok(source, `Missing native water layer ${key}`);
assert.equal(layer.waterBodyGuid, source.waterBodyGuid);
layer.sourceSplitPlaneHeight = source.sourceSplitPlaneHeight;
const metadata = json.materials[primitive.material].extras?.runewakerWater;
assert.ok(metadata, `Missing native water material ${key}`);
assert.equal(metadata.nativeDescriptorSha256, source.descriptorSha256);
Object.assign(metadata, nativeWaterRenderPolicy({
bodyType: source.bodyTypeRaw, effectType: metadata.effectTypeRaw,
baseObject: { properties: [{ name: "waterDesc:full:enable", type: 2, value: Number(source.fullModelRequested) }] },
}));
patched++;
}
}
assert.equal(patched, layers.size);
const rawJson = Buffer.from(JSON.stringify(json));
const padded = Buffer.alloc((rawJson.length + 3) & ~3, 0x20);
rawJson.copy(padded);
const header = Buffer.from(bytes.subarray(0, 20));
header.writeUInt32LE(20 + padded.length + remainder.length, 8);
header.writeUInt32LE(padded.length, 12);
return { bytes: Buffer.concat([header, padded, remainder]), binSha256: sha(remainder), patched };
}
await mkdir(output, { recursive: true });
const files = [
["canonical", "runewaker-export-work/the-origin/converted/the-origin-visual.glb"],
["optimized", "runewaker-export-work/the-origin/converted/the-origin-visual.optimized.glb"],
["shipping", "src/assets/game/dungeons/the-origin/the-origin-visual.glb"],
];
const proof = [];
for (const [kind, file] of files) {
const previous = await readFile(file);
const patched = patchGlb(previous);
const destination = path.join(output, `${kind}-the-origin-visual.glb`);
await writeFile(destination, patched.bytes);
const newJsonLength = patched.bytes.readUInt32LE(12);
assert.equal(sha(patched.bytes.subarray(20 + newJsonLength)), patched.binSha256);
proof.push({ kind, file, staged: path.relative(projectRoot, destination).replaceAll("\\", "/"),
previousSha256: sha(previous), sha256: sha(patched.bytes), bytes: patched.bytes.length,
binaryChunksUnchanged: true, binaryChunksSha256: patched.binSha256, layerCount: patched.patched });
}
await writeFile(path.join(output, "proof.json"), `${JSON.stringify({
sourceWdbSha256: sha(wdb), sourceBranch: "BuildWaterBodyMaterial non-DIRECT + full enable BOOL",
planeSource: "RuWorld_Water::BuildWaterLayerMesh first vertex transformed to source world height",
uniqueSourceSplitHeights: [...new Set([...layers.values()].map(row => row.sourceSplitPlaneHeight))],
layers: Object.fromEntries(layers), files: proof,
}, null, 2)}\n`);
console.log(JSON.stringify(proof, null, 2));