#!/usr/bin/env node import { createWriteStream, readFileSync } from "node:fs"; import { mkdir, readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; const BACKENDS = ["DirectGLES", "DirectVulkan"]; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const DEFAULT_FIXTURE_ROOT = path.join(SCRIPT_DIR, "fixtures"); const TRACE_CASES = JSON.parse(readFileSync(path.join(SCRIPT_DIR, "trace_cases.json"), "utf8")); const CASES = TRACE_CASES.cases.map((traceCase) => traceCase.name); function usage() { console.error(`Usage: node tools/trace_replay/render_retrace_summary.mjs \\ --input DIR [--input DIR ...] \\ --output-dir DIR \\ [--title TITLE] [--group-label LABEL] [--html NAME]`); } function parseArgs(argv) { const args = { inputs: [], outputDir: "", title: "MobileGL retrace overview", groupLabel: "retrace", htmlName: "mobilegl-retrace-overview.html", }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; const value = argv[i + 1]; if (arg === "--input" && value) { args.inputs.push(path.resolve(value)); i += 1; } else if (arg === "--output-dir" && value) { args.outputDir = path.resolve(value); i += 1; } else if (arg === "--title" && value) { args.title = value; i += 1; } else if (arg === "--group-label" && value) { args.groupLabel = value; i += 1; } else if (arg === "--html" && value) { args.htmlName = value; i += 1; } else if (arg === "--help" || arg === "-h") { usage(); process.exit(0); } else { throw new Error(`Unknown or incomplete argument: ${arg}`); } } if (args.inputs.length === 0 || !args.outputDir) { usage(); process.exit(2); } return args; } async function exists(filePath) { try { await stat(filePath); return true; } catch { return false; } } async function isUsablePng(filePath) { try { const info = await stat(filePath); return info.isFile() && filePath.toLowerCase().endsWith(".png") && info.size > 1024; } catch { return false; } } async function walk(root) { const results = []; if (!(await exists(root))) return results; const entries = await readdir(root, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(root, entry.name); if (entry.isDirectory()) { results.push(...await walk(fullPath)); } else if (entry.isFile()) { results.push(fullPath); } } return results; } function safeCase(caseName) { return caseName.replace(/[^A-Za-z0-9._-]/g, "_"); } function shortCase(caseName) { return caseName .replace(/^minecraft-1\.21\.4-/, "mc-") .replaceAll("fabric-iris-", "") .replaceAll("fabric-", "") .replace(/-in-world$/, ""); } function normalizeSlashes(value) { return value.split(path.sep).join("/"); } function inferCaseBackend(filePath) { const text = normalizeSlashes(filePath); const backend = BACKENDS.find((candidate) => text.includes(candidate)) ?? ""; const sortedCases = [...CASES].sort((a, b) => b.length - a.length); const caseName = sortedCases.find((candidate) => text.includes(candidate) || text.includes(safeCase(candidate)) ) ?? ""; return { caseName, backend }; } function inferGroup(filePath, defaultGroup) { const text = normalizeSlashes(filePath); for (const segment of text.split("/")) { const match = segment.match(/^android-retrace-(?:result|fixture|work)-(.+)$/); if (!match) continue; const parts = match[1].split("-"); const serial = parts[parts.length - 1]; if (serial) return serial; } return defaultGroup; } function parseGpuLabel(logText) { const renderer = logText.match(/^\[[^\n]*\]\s*\[[^\n]*\]:\s*GL_RENDERER:\s*(.+?)\s*$/m) ?? logText.match(/^GL_RENDERER:\s*(.+?)\s*$/m); if (renderer?.[1]) return renderer[1].trim(); const vulkanDevice = logText.match(/^\[[^\n]*\]\s*\[[^\n]*\]:\s+(.+?)\s+\(Vulkan\s+[^,\n]+,\s*[^)\n]*GPU\)\s*$/m) ?? logText.match(/^\s+(.+?)\s+\(Vulkan\s+[^,\n]+,\s*[^)\n]*GPU\)\s*$/m); if (vulkanDevice?.[1]) return vulkanDevice[1].trim(); return ""; } async function collectGroupLabels(files, defaultGroup) { const labels = new Map(); const logFiles = files.filter((file) => { const name = path.basename(file).toLowerCase(); return name === "mobilegl.log" || name === "logcat.txt"; }); for (const logFile of logFiles) { const group = inferGroup(logFile, defaultGroup); try { const gpuLabel = parseGpuLabel(await readFile(logFile, "utf8")); if (gpuLabel) labels.set(group, gpuLabel); } catch { // Diagnostics should not make summary rendering fail. } } return labels; } function scoreImage(filePath, caseName, backend, kind) { const text = normalizeSlashes(filePath); const name = path.basename(filePath).toLowerCase(); const fixtureGoldenPrefix = `${caseName}.`.toLowerCase(); const lowerKind = kind.toLowerCase(); if (lowerKind === "golden" && name.includes("alternate-golden")) return 0; const matchesKind = name === `${lowerKind}.png` || name.endsWith(`-${lowerKind}.png`) || name.endsWith(`_${lowerKind}.png`); const matchesFixtureGolden = (lowerKind === "golden" || lowerKind === "alternate-golden") && name.startsWith(fixtureGoldenPrefix) && name.endsWith(".png"); if (!matchesKind && !matchesFixtureGolden) return 0; let score = 0; if (text.includes(caseName) || text.includes(safeCase(caseName))) score += 4; if (text.includes(backend)) score += 3; score += 3; if (matchesFixtureGolden) { score += 4; if (lowerKind === "alternate-golden" && name.includes("-mali.")) score += 3; if (lowerKind === "golden" && !name.includes("-mali.")) score += 2; } if (name === `${caseName}-${backend}-${kind}.png`.toLowerCase()) score += 4; if (name === `${safeCase(caseName)}-${backend}-${kind}.png`.toLowerCase()) score += 4; if (name === `${kind}.png`) score += 4; return score; } async function findImage(pngs, caseName, backend, kind, nearFile) { const exactNames = [ `${caseName}-${backend}-${kind}.png`, `${safeCase(caseName)}-${backend}-${kind}.png`, `${caseName}-${kind}.png`, `${safeCase(caseName)}-${kind}.png`, `${kind}.png`, ]; if (nearFile) { let current = path.dirname(nearFile); for (let depth = 0; depth < 5; depth += 1) { for (const name of exactNames) { const candidate = path.join(current, name); if (await isUsablePng(candidate)) return candidate; } const parent = path.dirname(current); if (parent === current) break; current = parent; } } const scored = []; const nearGroup = nearFile ? inferGroup(nearFile, "") : ""; for (const png of pngs) { const text = normalizeSlashes(png); if ((kind === "actual" || kind === "diff") && backend && !text.includes(backend)) continue; const pngGroup = inferGroup(png, ""); if (nearGroup && pngGroup && pngGroup !== nearGroup) continue; const score = scoreImage(png, caseName, backend, kind); if (score >= 7) scored.push({ score, png }); } scored.sort((a, b) => b.score - a.score || a.png.length - b.png.length); return scored[0]?.png ?? ""; } async function collectRows(inputs, defaultGroup) { const files = []; for (const input of inputs) files.push(...await walk(input)); const groupLabels = await collectGroupLabels(files, defaultGroup); const fixtureFiles = await walk(DEFAULT_FIXTURE_ROOT); const pngs = []; for (const file of [...files, ...fixtureFiles]) { if (await isUsablePng(file)) pngs.push(file); } const rows = new Map(); for (const resultJson of files.filter((file) => path.basename(file) === "result.json" || file.endsWith("-result.json"))) { let { caseName, backend } = inferCaseBackend(resultJson); let result = {}; try { result = JSON.parse(await readFile(resultJson, "utf8")); backend = backend || result.backend || ""; } catch (error) { result = { passed: false, message: `failed to parse result.json: ${error.message}` }; } if (!caseName || !BACKENDS.includes(backend)) continue; const group = inferGroup(resultJson, defaultGroup); const key = `${group}\0${backend}\0${caseName}`; const matchedGolden = result.matchedGoldenPath || ""; const alternateGoldens = Array.isArray(result.alternateGoldenPaths) ? result.alternateGoldenPaths : []; const useAlternateGolden = alternateGoldens.some((alternate) => path.basename(String(alternate)) === path.basename(String(matchedGolden)) ) || String(matchedGolden).includes("alternate-golden"); rows.set(key, { group, backend, caseName, status: result.passed ? "PASS" : "FAIL", ssim: result.ssim === undefined || result.ssim === null ? "" : String(result.ssim), message: result.message || "", actual: await findImage(pngs, caseName, backend, "actual", resultJson), golden: await findImage(pngs, caseName, backend, useAlternateGolden ? "alternate-golden" : "golden", resultJson), diff: await findImage(pngs, caseName, backend, "diff", resultJson), }); } // Preserve visibility for jobs that uploaded logs/diagnostics but never wrote result.json. const directories = new Set(files.map((file) => path.dirname(file))); for (const directory of directories) { const { caseName, backend } = inferCaseBackend(directory); if (!caseName || !BACKENDS.includes(backend)) continue; const group = inferGroup(directory, defaultGroup); const key = `${group}\0${backend}\0${caseName}`; if (rows.has(key)) continue; rows.set(key, { group, backend, caseName, status: "NO_RESULT", ssim: "", message: "result.json was not found", actual: await findImage(pngs, caseName, backend, "actual", directory), golden: await findImage(pngs, caseName, backend, "golden", directory), diff: await findImage(pngs, caseName, backend, "diff", directory), }); } return { rows: [...rows.values()], groupLabels }; } function htmlEscape(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } function jsonScriptText(value) { return JSON.stringify(value).replaceAll("<", "\\u003c"); } async function embedPng(source) { if (!source) return ""; if (!(await isUsablePng(source))) return ""; const data = await readFile(source); return `data:image/png;base64,${data.toString("base64")}`; } function statusText(row) { if (!row) return "NO RESULT"; const effective = effectiveStatus(row); if (effective === "FAIL" && row.status === "NO_RESULT") return `FAIL NO RESULT`; if (row.status === "NO_RESULT") return "NO RESULT"; if (row.status === "FIXTURE_MISSING") return "MISSING FIXTURE"; if (row.ssim) { const value = Number(row.ssim); const text = Number.isFinite(value) ? value.toFixed(4) : row.ssim; return `${row.status} SSIM ${htmlEscape(text)}`; } return row.status; } function effectiveStatus(row) { if (!row) return "NO_RESULT"; if (row.status === "PASS" || row.status === "FAIL") return row.status; return !row.actualSrc && !row.goldenSrc ? "NO_RESULT" : "FAIL"; } async function renderHtml(rows, groupLabels, outputDir, title, groupLabel, htmlName) { await mkdir(outputDir, { recursive: true }); const groups = [...new Set(rows.map((row) => row.group))].sort(); if (groups.length === 0) groups.push(groupLabel); const rowMap = new Map(rows.map((row) => [`${row.group}\0${row.backend}\0${row.caseName}`, row])); const cells = new Map(); for (const row of rows) { cells.set(`${row.group}\0${row.backend}\0${row.caseName}`, { ...row, actualSrc: await embedPng(row.actual), goldenSrc: await embedPng(row.golden), diffSrc: await embedPng(row.diff), }); } const imageTemplates = []; const imageTemplatesByGroup = new Map(); const imageTemplateForGroup = (group) => { let template = imageTemplatesByGroup.get(group); if (!template) { template = { id: `image-data-${imageTemplates.length}`, images: {}, nextImage: 0 }; imageTemplatesByGroup.set(group, template); imageTemplates.push(template); } return template; }; const registerImage = (group, src) => { if (!src) return ""; const template = imageTemplateForGroup(group); const id = `image-${template.nextImage}`; template.nextImage += 1; template.images[id] = src; return id; }; const thumb = (group, src, label) => { const imageId = registerImage(group, src); return `
${htmlEscape(label)}
${imageId ? `` : `
NO IMAGE
`}
`; }; const missingImages = (row) => !row.actualSrc || !row.goldenSrc || !row.diffSrc; const cellFor = (group, backend, caseName) => cells.get(`${group}\0${backend}\0${caseName}`) ?? { status: "NO_RESULT", actualSrc: "", goldenSrc: "", diffSrc: "", }; const backendCell = (group, backend, caseName) => { const row = cellFor(group, backend, caseName); const effective = effectiveStatus(row); const statusClass = effective === "PASS" ? "pass" : effective === "NO_RESULT" ? "missing" : "fail"; const imageClass = missingImages(row) ? "image-missing" : ""; return `
${backend} ${statusText(row)}
${thumb(group, row.actualSrc, "actual")} ${thumb(group, row.goldenSrc, "golden")} ${thumb(group, row.diffSrc, "diff")}
`; }; const output = path.join(outputDir, htmlName); const stream = createWriteStream(output, { encoding: "utf8" }); const write = (chunk) => new Promise((resolve, reject) => { stream.write(chunk, (error) => error ? reject(error) : resolve()); }); const finish = () => new Promise((resolve, reject) => { stream.end((error) => error ? reject(error) : resolve()); }); await write(` ${htmlEscape(title)}

${htmlEscape(title)}

Each backend cell shows actual / golden / diff. Failed cases are red; absent results are orange; incomplete image sets get an orange notch.

`); const caseTemplates = []; for (const group of groups) { const displayGroup = groupLabels.get(group) ?? group; const templateId = `case-template-${caseTemplates.length}`; const groupCells = CASES.flatMap((caseName) => BACKENDS.map((backend) => cellFor(group, backend, caseName))); const passed = groupCells.filter((row) => effectiveStatus(row) === "PASS").length; const failed = groupCells.filter((row) => effectiveStatus(row) === "FAIL").length; const noResult = groupCells.filter((row) => effectiveStatus(row) === "NO_RESULT").length; const passRateDenominator = passed + failed; const passRate = passRateDenominator === 0 ? "0.0%" : `${(passed * 100 / passRateDenominator).toFixed(1)}%`; const statusTotal = passed + noResult + failed; const passPercent = statusTotal === 0 ? "0" : (passed * 100 / statusTotal).toFixed(3); const noResultPercent = statusTotal === 0 ? "0" : (noResult * 100 / statusTotal).toFixed(3); const failPercent = statusTotal === 0 ? "0" : (failed * 100 / statusTotal).toFixed(3); let caseHtml = `
case
DirectGLES
DirectVulkan
`; for (const [index, caseName] of CASES.entries()) { caseHtml += `
${backendCell(group, "DirectGLES", caseName)} ${backendCell(group, "DirectVulkan", caseName)}
`; } caseTemplates.push({ id: templateId, html: caseHtml }); const imageTemplate = imageTemplatesByGroup.get(group); await write(` `); } for (const template of caseTemplates) { await write(` `); } await write(`
`); for (const template of imageTemplates) { await write(` `); } await write(` `); await finish(); return output; } async function main() { const args = parseArgs(process.argv.slice(2)); await mkdir(args.outputDir, { recursive: true }); const { rows, groupLabels } = await collectRows(args.inputs, args.groupLabel); const htmlPath = await renderHtml(rows, groupLabels, args.outputDir, args.title, args.groupLabel, args.htmlName); console.log(htmlPath); } main().catch((error) => { console.error(error.stack || error.message); process.exit(1); });