import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { NodeIO } from "@gltf-transform/core"; import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const work = path.join(projectRoot, "runewaker-export-work", "aeternal-circle"); const inspection = path.join(work, "inspection"); const recipe = JSON.parse(await readFile(path.join(projectRoot, "scripts/runewaker-pipeline/recipes/aeternal-circle.json"), "utf8")); const metadata = JSON.parse(await readFile(path.join(projectRoot, "src/assets/game/dungeons/aeternal-circle/import-metadata.json"), "utf8")); const sourceReport = JSON.parse(await readFile(path.join(work, "prepared/export-report.json"), "utf8")); const wdbReport = JSON.parse(await readFile(path.join(work, "raw/wdb-report.json"), "utf8")); const document = await new NodeIO().registerExtensions(ALL_EXTENSIONS).read(path.join(work, "converted/aeternal-circle-visual.glb")); const root = document.getRoot(); const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); const pngDimensions = (bytes) => bytes.toString("hex", 0, 8) === "89504e470d0a1a0a" ? [bytes.readUInt32BE(16), bytes.readUInt32BE(20)] : null; const normalizeResource = (value) => String(value ?? "").replaceAll("/", "\\").toLowerCase(); const xyz = (matrix, point) => [ 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], ]; const subtract = (a, b) => a.map((value, index) => value - b[index]); const cross = (a, b) => [ a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0], ]; const normalize = (value) => { const length = Math.hypot(...value); return length ? value.map((component) => component / length) : [0, 0, 0]; }; const primary = wdbReport.descriptors.find((descriptor) => ( normalizeResource(descriptor.resource) === normalizeResource(recipe.source.model) && descriptor.translation.every((value, index) => Math.abs(value - recipe.source.primaryPlacementTranslation[index]) <= 0.0001) )); if (!primary) throw new Error("Aeternal primary WDB placement could not be matched to the recipe."); const sourceAnchor = recipe.source.sourceAnchorSupport.points[0]; const supportNode = root.listNodes().find((node) => node.getName() === "runewaker_source_anchor_support"); if (!supportNode?.getMesh()) throw new Error("Aeternal source-anchor support mesh is missing from the canonical converted visual."); const supportPrimitive = supportNode.getMesh().listPrimitives()[0]; const supportPosition = supportPrimitive.getAttribute("POSITION"); const supportIndices = supportPrimitive.getIndices()?.getArray(); if (!supportPosition || !supportIndices) throw new Error("Aeternal support mesh is missing positions or indices."); const supportMatrix = Array.from(supportNode.getWorldMatrix()); const supportAdjacency = Array.from({ length: supportPosition.getCount() }, () => new Set()); for (let index = 0; index < supportIndices.length; index += 3) { const [a, b, c] = supportIndices.slice(index, index + 3); supportAdjacency[a].add(b); supportAdjacency[a].add(c); supportAdjacency[b].add(a); supportAdjacency[b].add(c); supportAdjacency[c].add(a); supportAdjacency[c].add(b); } const supportComponents = []; const visited = new Uint8Array(supportPosition.getCount()); for (let start = 0; start < visited.length; start += 1) { if (visited[start]) continue; const vertices = [start]; visited[start] = 1; for (let cursor = 0; cursor < vertices.length; cursor += 1) { for (const next of supportAdjacency[vertices[cursor]]) { if (visited[next]) continue; visited[next] = 1; vertices.push(next); } } const points = vertices.map((index) => xyz(supportMatrix, supportPosition.getElement(index, [0, 0, 0]))); const min = [0, 1, 2].map((axis) => Math.min(...points.map((point) => point[axis]))); const max = [0, 1, 2].map((axis) => Math.max(...points.map((point) => point[axis]))); supportComponents.push({ vertices, min, max, center: [(min[0] + max[0]) / 2, max[1], (min[2] + max[2]) / 2], }); } const expectedAnchorApprox = [ (sourceAnchor[0] - primary.translation[0]) * recipe.coordinateSystem.metersPerSourceUnit, (sourceAnchor[1] - primary.translation[1]) * recipe.coordinateSystem.metersPerSourceUnit, (sourceAnchor[2] - primary.translation[2]) * recipe.coordinateSystem.metersPerSourceUnit, ]; const pad = supportComponents .map((component) => ({ ...component, distanceXZ: Math.hypot(component.center[0] - expectedAnchorApprox[0], component.center[2] - expectedAnchorApprox[2]), })) .sort((a, b) => a.distanceXZ - b.distanceXZ)[0]; if (!pad || pad.distanceXZ > 0.02) throw new Error(`Could not match the first source spawn anchor to one support pad (nearest ${pad?.distanceXZ ?? "none"}m).`); const candidateTriangles = []; for (const node of root.listNodes()) { if (!node.getMesh() || node === supportNode) continue; const matrix = Array.from(node.getWorldMatrix()); for (const primitive of node.getMesh().listPrimitives()) { const positions = primitive.getAttribute("POSITION"); const indices = primitive.getIndices()?.getArray(); if (!positions || !indices) continue; const uv = primitive.getAttribute("TEXCOORD_0"); const material = primitive.getMaterial()?.getName() ?? "unassigned"; for (let index = 0; index < indices.length; index += 3) { const triangleIndices = [indices[index], indices[index + 1], indices[index + 2]]; const [a, b, c] = triangleIndices.map((vertex) => xyz(matrix, positions.getElement(vertex, [0, 0, 0]))); const normal = normalize(cross(subtract(b, a), subtract(c, a))); if (normal[1] < 0.75) continue; if (Math.max(a[0], b[0], c[0]) < pad.min[0] || Math.min(a[0], b[0], c[0]) > pad.max[0] || Math.max(a[2], b[2], c[2]) < pad.min[2] || Math.min(a[2], b[2], c[2]) > pad.max[2]) continue; if (Math.min(a[1], b[1], c[1]) > pad.max[1] + 0.001 || Math.max(a[1], b[1], c[1]) < pad.max[1] - 12) continue; candidateTriangles.push({ node: node.getName(), material, indices: triangleIndices, positions, uv, a, b, c }); } } } function barycentricXZ(x, z, a, b, c) { const edgeAB = [b[0] - a[0], b[2] - a[2]]; const edgeAC = [c[0] - a[0], c[2] - a[2]]; const point = [x - a[0], z - a[2]]; const d00 = edgeAB[0] ** 2 + edgeAB[1] ** 2; const d01 = edgeAB[0] * edgeAC[0] + edgeAB[1] * edgeAC[1]; const d11 = edgeAC[0] ** 2 + edgeAC[1] ** 2; const d20 = point[0] * edgeAB[0] + point[1] * edgeAB[1]; const d21 = point[0] * edgeAC[0] + point[1] * edgeAC[1]; const denominator = d00 * d11 - d01 * d01; if (Math.abs(denominator) < 1e-12) return null; const v = (d11 * d20 - d01 * d21) / denominator; const w = (d00 * d21 - d01 * d20) / denominator; return [1 - v - w, v, w]; } const nativeMaterialMap = new Map(sourceReport.materials.map((material) => [material.name, material])); const sampleHits = []; for (const dx of [-5.5, -3, 0, 3, 5.5]) { for (const dz of [-5.5, -3, 0, 3, 5.5]) { const x = pad.center[0] + dx; const z = pad.center[2] + dz; let highest = null; for (const triangle of candidateTriangles) { const barycentric = barycentricXZ(x, z, triangle.a, triangle.b, triangle.c); if (!barycentric || barycentric.some((weight) => weight < -1e-5)) continue; const y = barycentric[0] * triangle.a[1] + barycentric[1] * triangle.b[1] + barycentric[2] * triangle.c[1]; if (y > pad.max[1] + 0.001 || y < pad.max[1] - 12) continue; if (highest && y <= highest.y) continue; let uv = null; if (triangle.uv) { const values = triangle.indices.map((index) => triangle.uv.getElement(index, [0, 0])); uv = [0, 1].map((axis) => values[0][axis] * barycentric[0] + values[1][axis] * barycentric[1] + values[2][axis] * barycentric[2]); } highest = { y, dropFromSupportTopMeters: pad.max[1] - y, node: triangle.node, nativeMaterial: triangle.material, nativeTexture: nativeMaterialMap.get(triangle.material)?.sourceTexture ?? null, uv0: uv, }; } sampleHits.push({ offsetFromPadCenterMeters: [dx, dz], ...(highest ?? { nativeSurface: null }) }); } } const hitSummary = new Map(); for (const hit of sampleHits) { const name = hit.nativeMaterial ?? "no-native-upward-surface"; const record = hitSummary.get(name) ?? { count: 0, nodeNames: new Set(), texture: hit.nativeTexture ?? null, uv0: [] }; record.count += 1; if (hit.node) record.nodeNames.add(hit.node); if (hit.uv0) record.uv0.push(hit.uv0); hitSummary.set(name, record); } const summarizeUv = (values) => values.length ? { sampleCount: values.length, min: [0, 1].map((axis) => Math.min(...values.map((uv) => uv[axis]))), max: [0, 1].map((axis) => Math.max(...values.map((uv) => uv[axis]))), } : null; const atlasCandidates = ["station", "building_statue", "convey_ground", "production_center_ground_01", "ground_ceiling"]; const textures = []; for (const leaf of atlasCandidates) { const source = sourceReport.materials.find((material) => material.sourceTexture?.toLowerCase().endsWith(`\\${leaf}`)); if (!source) continue; const stagedName = path.basename(source.stagedTexture); const stem = path.parse(stagedName).name; const dds = await readFile(path.join(work, "raw/textures", `${stem}.dds`)); const png = await readFile(path.join(work, "prepared/textures", `${stem}.png`)); const gltfMaterial = root.listMaterials().find((material) => material.getName() === source.name); const image = Buffer.from(gltfMaterial?.getBaseColorTexture()?.getImage() ?? []); textures.push({ sourceTexture: source.sourceTexture, nativeMaterial: source.name, stagedDds: stagedName, ddsSha256: sha256(dds), preparedPngSha256: sha256(png), gltfImageSha256: sha256(image), imageSize: pngDimensions(png), atlasAssessment: { station: "multi-region atlas: floor-like slate patch plus gold and trim regions", building_statue: "multi-region statue/architecture atlas, not a full-tile floor", convey_ground: "512-square repeated 2x2 stone-tile pattern, but the matching ROS surface is remote from this entry pad", production_center_ground_01: "multi-region architecture/ground atlas", ground_ceiling: "multi-region tile/ornament/trim atlas", }[leaf], }); } const candidateMaterialNames = new Set(textures.map((texture) => texture.nativeMaterial)); const candidateMeshLocations = []; for (const node of root.listNodes()) { if (!node.getMesh()) continue; const matrix = Array.from(node.getWorldMatrix()); for (const primitive of node.getMesh().listPrimitives()) { const material = primitive.getMaterial()?.getName(); if (!candidateMaterialNames.has(material)) continue; const positions = primitive.getAttribute("POSITION"); const indices = primitive.getIndices()?.getArray(); if (!positions || !indices) continue; const points = Array.from({ length: positions.getCount() }, (_, index) => xyz(matrix, positions.getElement(index, [0, 0, 0]))); const min = [0, 1, 2].map((axis) => Math.min(...points.map((point) => point[axis]))); const max = [0, 1, 2].map((axis) => Math.max(...points.map((point) => point[axis]))); let upwardTriangleCount = 0; for (let index = 0; index < indices.length; index += 3) { const [a, b, c] = [indices[index], indices[index + 1], indices[index + 2]].map((vertex) => points[vertex]); if (normalize(cross(subtract(b, a), subtract(c, a)))[1] > 0.75) upwardTriangleCount += 1; } const center = min.map((value, axis) => (value + max[axis]) / 2); candidateMeshLocations.push({ nativeMaterial: material, sourceTexture: nativeMaterialMap.get(material)?.sourceTexture ?? null, node: node.getName(), primitiveTriangles: indices.length / 3, upwardTriangleCount, bounds: { min, max }, center, entryPadCenterDistanceXZMeters: Math.hypot(center[0] - pad.center[0], center[2] - pad.center[2]), }); } } const point = sourceAnchor; const secondary = wdbReport.descriptors .filter((descriptor) => descriptor.guid !== primary.guid) .map((descriptor) => { const center = descriptor.worldBounds?.center ?? descriptor.translation; const dx = center[0] - point[0]; const dz = center[2] - point[2]; const distanceXZ = Math.hypot(dx, dz); const radius = Number(descriptor.worldBounds?.radius ?? 0); return { guid: descriptor.guid, resource: descriptor.resource, translation: descriptor.translation, worldBoundsCenter: center, worldBoundsRadius: radius, distanceXZSourceUnits: distanceXZ, lowerBoundHorizontalGapSourceUnits: Math.max(0, distanceXZ - radius), }; }) .sort((a, b) => a.distanceXZSourceUnits - b.distanceXZSourceUnits) .slice(0, 10); const output = { schemaVersion: 1, kind: "aeternal-circle-generated-support-floor-source-triage", generatedFrom: { recipe: "scripts/runewaker-pipeline/recipes/aeternal-circle.json", recipeWdbPolicy: recipe.review.wdbPlacements, canonicalVisual: "runewaker-export-work/aeternal-circle/converted/aeternal-circle-visual.glb", sourceModel: metadata.source.model, sourceModelSha256: metadata.source.modelSha256, sourceWdb: metadata.source.wdb, sourceWdbSha256: metadata.source.wdbSha256, sourceWdbDescriptorCount: wdbReport.descriptorCount, recipeSupportAnchorCount: recipe.source.sourceAnchorSupport.points.length, convertedSupportPrimitive: { node: supportNode.getName(), material: supportPrimitive.getMaterial()?.getName(), vertices: supportPosition.getCount(), triangles: supportIndices.length / 3, disconnectedPadCount: supportComponents.length, }, }, entryPad: { sourceAnchor: point, matchedWdbPrimary: { guid: primary.guid, translation: primary.translation, rotation: primary.rotation, scale: primary.scale, }, actualConvertedSupportPad: { worldCenterXZ: [pad.center[0], pad.center[2]], topY: pad.max[1], bounds: { min: pad.min, max: pad.max }, sideLengthMeters: [pad.max[0] - pad.min[0], pad.max[2] - pad.min[2]], thicknessMeters: pad.max[1] - pad.min[1], topSupportMeshTriangles: supportIndices.length / 3, }, nativeUnderlyingSurfaceProbe: { gridOffsetsMeters: [-5.5, -3, 0, 3, 5.5], upwardNormalYThreshold: 0.75, maximumBelowSupportTopMeters: 12, sampleCount: sampleHits.length, highestNativeSurfaceByMaterial: [...hitSummary.entries()].map(([nativeMaterial, record]) => ({ nativeMaterial, sourceTexture: record.texture, sampleCount: record.count, nodes: [...record.nodeNames], sampleUv0Bounds: summarizeUv(record.uv0), dropRangeMeters: sampleHits.filter((hit) => hit.nativeMaterial === nativeMaterial) .reduce((range, hit) => [Math.min(range[0], hit.dropFromSupportTopMeters), Math.max(range[1], hit.dropFromSupportTopMeters)], [Infinity, -Infinity]), })), samples: sampleHits, interpretation: "Underlying upward-facing triangles vary between station and statue atlas meshes and do not form one continuous, same-height native floor across the generated support pad.", }, }, candidateTextures: textures, candidateTextureMeshLocations: candidateMeshLocations, visibleReference: "artifacts/playtest/rom-environment-quality/aeternal-circle-view-0.png", nearbyWdbPlacements: { entriesAreMeasuredFrom: "first Aeternal support/source spawn anchor in native source units", nearestSecondaryByBoundsCenter: secondary, resourceNamesContainingGroundFloorTerrain: wdbReport.descriptors .filter((descriptor) => /ground|floor|terrain/i.test(descriptor.resource)) .map((descriptor) => descriptor.resource), interpretation: "No secondary WDB descriptor is explicitly named as floor/ground/terrain; nearest secondary geometry centers are tens of meters from the entry. This does not exclude ground surfaces embedded in large tower ROS resources or prove their placements irrelevant.", }, recommendation: { safeTextureFallbackProven: false, nativeFloorRecoveryProven: false, reason: "The adjacent native surfaces use multi-region station/statue atlases with different heights and UV islands. convey_ground is a visually repeatable tile but its matching primary-ROS mesh is over 200 m from the entry pad, so the evidence does not spatially justify borrowing it.", nextEvidenceNeeded: "Review/export full secondary WDB placements and inspect walkable source triangles for a continuous native floor surface; only after that can a nearby repeatable source tile or native floor recovery be justified.", }, }; await mkdir(inspection, { recursive: true }); const reportFile = path.join(inspection, "aeternal-derived-support-ground-triage.json"); await writeFile(reportFile, `${JSON.stringify(output, null, 2)}\n`, "utf8"); console.log(JSON.stringify({ report: path.relative(projectRoot, reportFile), sourceModelSha256: metadata.source.modelSha256, sourceWdbSha256: metadata.source.wdbSha256, supportPads: supportComponents.length, sampleHits: Object.fromEntries([...hitSummary].map(([name, record]) => [name, record.count])) }, null, 2));