From 05e1e622623e60ee8a81eef808cc76ed19022630 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Mon, 17 Aug 2026 22:49:01 -0300 Subject: [PATCH] feat(cityflow): add retained Cityflow adapter --- .gitignore | 1 + package.json | 9 + src/adapters/cityflow/AGENTS.md | 10 + src/adapters/cityflow/README.md | 27 ++ src/adapters/cityflow/index.html | 57 +++ .../cityflow/notes/provenance-bible.md | 66 +++ .../notes/references/source-lock.json | 15 + .../cityflow/src/csscityflow/client.mjs | 90 ++++ .../src/csscityflow/preparedPlayback.mjs | 251 +++++++++++ .../cityflow/src/csscityflow/styles.css | 177 ++++++++ src/adapters/cityflow/src/main.mjs | 4 + .../src/prepare/csscityflow/model.mjs | 280 ++++++++++++ .../src/prepare/csscityflow/sourceModel.mjs | 328 ++++++++++++++ .../cityflow/test/csscityflow-model.test.mjs | 66 +++ .../test/csscityflow-runtime-surface.test.mjs | 25 ++ .../test/csscityflow-source-model.test.mjs | 42 ++ .../tools/check-prepare-determinism.mjs | 52 +++ .../tools/native/capture-cityflow-state.c | 228 ++++++++++ .../tools/native/headless/capture-cityflow.c | 168 +++++++ .../native/headless/include/screenhackI.h | 30 ++ .../tools/native/headless/include/utils.h | 6 + .../tools/native/headless/include/visual.h | 10 + .../tools/native/headless/include/xlockmore.h | 125 ++++++ .../tools/oracle/run-visual-oracle.mjs | 419 ++++++++++++++++++ .../cityflow/tools/prepare-csscityflow.mjs | 77 ++++ .../cityflow/tools/run-native-oracle.mjs | 83 ++++ src/adapters/cityflow/tools/smoke-browser.mjs | 113 +++++ src/adapters/cityflow/vite.config.mjs | 18 + 28 files changed, 2777 insertions(+) create mode 100644 src/adapters/cityflow/AGENTS.md create mode 100644 src/adapters/cityflow/README.md create mode 100644 src/adapters/cityflow/index.html create mode 100644 src/adapters/cityflow/notes/provenance-bible.md create mode 100644 src/adapters/cityflow/notes/references/source-lock.json create mode 100644 src/adapters/cityflow/src/csscityflow/client.mjs create mode 100644 src/adapters/cityflow/src/csscityflow/preparedPlayback.mjs create mode 100644 src/adapters/cityflow/src/csscityflow/styles.css create mode 100644 src/adapters/cityflow/src/main.mjs create mode 100644 src/adapters/cityflow/src/prepare/csscityflow/model.mjs create mode 100644 src/adapters/cityflow/src/prepare/csscityflow/sourceModel.mjs create mode 100644 src/adapters/cityflow/test/csscityflow-model.test.mjs create mode 100644 src/adapters/cityflow/test/csscityflow-runtime-surface.test.mjs create mode 100644 src/adapters/cityflow/test/csscityflow-source-model.test.mjs create mode 100644 src/adapters/cityflow/tools/check-prepare-determinism.mjs create mode 100644 src/adapters/cityflow/tools/native/capture-cityflow-state.c create mode 100644 src/adapters/cityflow/tools/native/headless/capture-cityflow.c create mode 100644 src/adapters/cityflow/tools/native/headless/include/screenhackI.h create mode 100644 src/adapters/cityflow/tools/native/headless/include/utils.h create mode 100644 src/adapters/cityflow/tools/native/headless/include/visual.h create mode 100644 src/adapters/cityflow/tools/native/headless/include/xlockmore.h create mode 100644 src/adapters/cityflow/tools/oracle/run-visual-oracle.mjs create mode 100644 src/adapters/cityflow/tools/prepare-csscityflow.mjs create mode 100644 src/adapters/cityflow/tools/run-native-oracle.mjs create mode 100644 src/adapters/cityflow/tools/smoke-browser.mjs create mode 100644 src/adapters/cityflow/vite.config.mjs diff --git a/.gitignore b/.gitignore index b7b0777..1eca78d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ sm64config.txt # Deterministic local prepare/build output build/generated/ +build/oracles/ build/package-inputs/ build/release/ build/reports/ diff --git a/package.json b/package.json index cdc50de..d47fe7e 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "dev": "vite --host 127.0.0.1", "dev:3dpipes": "vite --config src/adapters/3dpipes/vite.config.mjs --host 127.0.0.1", "dev:flowerbox": "vite --config src/adapters/flowerbox/vite.config.mjs --host 127.0.0.1", + "dev:cityflow": "vite --config src/adapters/cityflow/vite.config.mjs --host 127.0.0.1", "dev:gears": "vite --config src/adapters/gears/vite.config.mjs --host 127.0.0.1", "dev:gravitywell": "vite --config src/adapters/gravitywell/vite.config.mjs --host 127.0.0.1", "dev:electropaint": "vite --config src/adapters/electropaint/vite.config.mjs --host 127.0.0.1", @@ -50,6 +51,8 @@ "build:3dpipes": "vite build --config src/adapters/3dpipes/vite.config.mjs", "build:3dpipes:full": "pnpm prepare:3dpipes && pnpm build:3dpipes", "build:flowerbox": "vite build --config src/adapters/flowerbox/vite.config.mjs", + "build:cityflow": "vite build --config src/adapters/cityflow/vite.config.mjs", + "build:cityflow:full": "pnpm prepare:cityflow && pnpm build:cityflow", "build:flowerbox:full": "pnpm prepare:flowerbox:artifact && pnpm build:flowerbox", "build:gears": "vite build --config src/adapters/gears/vite.config.mjs", "build:gears:full": "pnpm prepare:gears:source && pnpm build:gears", @@ -73,6 +76,8 @@ "prepare:3dpipes": "node src/adapters/3dpipes/tools/prepare-csspipes.mjs", "prepare:flowerbox:artifact": "node scripts/prepare/flowerbox-product-bank.mjs", "prepare:flowerbox:source": "node src/adapters/flowerbox/tools/prepare-cssflower.mjs && node src/adapters/flowerbox/tools/prepare-polycss-snapshot.mjs", + "prepare:cityflow": "node src/adapters/cityflow/tools/prepare-csscityflow.mjs", + "prepare:cityflow:check": "node src/adapters/cityflow/tools/check-prepare-determinism.mjs", "prepare:gears:artifact": "node scripts/prepare/cssgears-product-bank.mjs", "prepare:gears:source": "node src/adapters/gears/tools/prepare-cssgears.mjs && node src/adapters/gears/tools/prepare-polycss-snapshot.mjs", "prepare:gravitywell:artifact": "node scripts/prepare/cssgravitywell-product-bank.mjs", @@ -98,6 +103,10 @@ "verify:flowerbox:runtime": "node src/adapters/flowerbox/tools/audit-runtime-surface.mjs", "test:flowerbox:lighting-publication": "node --test src/adapters/flowerbox/test/visible-lighting-publication.test.mjs", "test:flowerbox:browser": "node src/adapters/flowerbox/tools/smoke-browser.mjs", + "test:cityflow": "node --test src/adapters/cityflow/test/*.test.mjs", + "test:cityflow:browser": "node src/adapters/cityflow/tools/smoke-browser.mjs", + "oracle:cityflow:native": "node src/adapters/cityflow/tools/run-native-oracle.mjs", + "oracle:cityflow:visual": "node src/adapters/cityflow/tools/oracle/run-visual-oracle.mjs", "test:flowerbox:deploy": "node src/adapters/flowerbox/tools/smoke-browser.mjs --deploy", "verify:gears:bank": "node src/adapters/gears/tools/verify-product-bank.mjs", "test:gears": "node --test src/adapters/gears/test/cssgears-route-state.test.mjs src/adapters/gears/test/cssgears-shell.test.mjs src/adapters/gears/test/cssgears-source-profile.test.mjs", diff --git a/src/adapters/cityflow/AGENTS.md b/src/adapters/cityflow/AGENTS.md new file mode 100644 index 0000000..6ba7943 --- /dev/null +++ b/src/adapters/cityflow/AGENTS.md @@ -0,0 +1,10 @@ +# Cityflow adapter contract + +- Read `notes/provenance-bible.md` before changing source simulation, box geometry, colors, lighting, camera, timing, or parity claims. +- Preserve the XScreenSaver simulation contract: six waves, 25 wave speed, 256 radius, 12-degree skew, 20 ms cadence, the source-supported `count` control set to 200, and only the source-emitted top, front, and right faces. The source default remains 800; do not describe 200 as that default. +- Preserve the seeded initialization, wave equation, palette, lighting, camera, and cadence. Cityflow has one prepared product bank and no runtime bank selection. +- Preparation owns the exact seeded `ya_random` initialization, palette, box census, wave phases, source lighting, native far-plane cutoff, retained Morph package, canonical transform dictionary, and typed transform/color frame tables. +- Runtime may mount the prepared graph, derive static face lighting from each prepared box transform, and apply changed prepared transform indices and palette classes. Playback must follow the merged DOMFORMAT `polycss-playback@0` precedent: timer wait, one paint-aligned callback when due, and `elapsed` catch-up. It must not calculate geometry, rasterize assets, grow the DOM, or rebuild topology during playback. +- Keep every box as one retained rigid root with exactly three solid `` leaves. No Canvas, WebGL, WebGPU, SVG scene geometry, masks, clip paths, runtime atlas work, or paint-heavy CSS effects. +- Native state claims require the pinned headless C source harness at revision `906693799e4fb7581436590cf84ecb2d3c9186ba`. Native visual claims require the independent CGL frame-sequence oracle and calibrated native/browser comparison. +- Keep source checkouts, native binaries, captures, traces, and generated packages ignored. diff --git a/src/adapters/cityflow/README.md b/src/adapters/cityflow/README.md new file mode 100644 index 0000000..1eaa4af --- /dev/null +++ b/src/adapters/cityflow/README.md @@ -0,0 +1,27 @@ +# css.graphics/cityflow + +Source-backed XScreenSaver Cityflow rendered as retained PolyCSS Morph boxes. +The single product bank uses the source's own `count` control at 200 boxes; +the XScreenSaver default is 800. Every box retains the source-emitted top, +front, and right faces. + +```bash +export CSSCITYFLOW_SOURCE_ROOT=/path/to/xscreensaver +pnpm prepare:cityflow +pnpm dev:cityflow +pnpm test:cityflow +pnpm prepare:cityflow:check +pnpm test:cityflow:browser +pnpm oracle:cityflow:native +pnpm oracle:cityflow:visual +pnpm build:cityflow +``` + +Preparation owns the seeded source initialization, smooth color map, box +geometry, source-light factors, OpenGL far-plane cutoff, Morph packages, and +source-cadence transform/color tables. Duplicate transforms are removed during +preparation. Runtime mounts one retained graph, derives its static face-light +factors from the prepared transforms, and sparsely publishes changed prepared +indices with the merged DOMFORMAT timer/rAF `elapsed` scheduler. Runtime does +no geometry calculation, atlas rasterization, topology rebuilding, or DOM +growth. diff --git a/src/adapters/cityflow/index.html b/src/adapters/cityflow/index.html new file mode 100644 index 0000000..1f2e59b --- /dev/null +++ b/src/adapters/cityflow/index.html @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + Cityflow - Powered by PolyCSS + + + + + + diff --git a/src/adapters/cityflow/notes/provenance-bible.md b/src/adapters/cityflow/notes/provenance-bible.md new file mode 100644 index 0000000..2a237d0 --- /dev/null +++ b/src/adapters/cityflow/notes/provenance-bible.md @@ -0,0 +1,66 @@ +# cssCityflow provenance + +## Authority + +- Project: XScreenSaver Cityflow +- Revision: `906693799e4fb7581436590cf84ecb2d3c9186ba` +- Primary source: `hacks/glx/cityflow.c` +- Primary SHA-256: `9113c9f3214ba6c1f350b3863c306e6015e47a856a17bbe24d597f909dfa027b` +- Configuration: `hacks/config/cityflow.xml` +- Configuration SHA-256: `0453c12e39e4b7d32e66a7780d69979aaaa42d0632a3b6757df9c1cee6c7c919` + +The primary file grants permission to use, copy, modify, distribute, and sell +the software and documentation provided its copyright and permission notices +are preserved. Cityflow has no external model or texture data. + +## Source contract + +The default mode initializes 800 randomly positioned and skewed boxes, six +moving interference sources, a radius of 256, wave speed 25, skew 12, and a +20 ms delay. Although each box is a solid, the source deliberately emits only +its top, front, and right quads; the left and back faces are compiled out and +there is no bottom face. The default retained census is therefore 800 roots +and 2,400 leaves. + +The product uses the source-supported `count` option at 200 boxes in one +prepared bank. The source default remains 800, and the source's +`1.8 / sqrt(count)` box scale is preserved for the selected count. + +The deterministic prepared seed is product authoring input because the native +screen saver normally seeds `ya_random` from process and wall-clock state. The +source equations, random stream, smooth color-map construction, draw cadence, +lighting, and camera remain authoritative. + +## Runtime boundary + +Preparation writes one static Morph package plus source-cadence typed transform +and color-index tables. Every box retains three solid quad leaves. +Because CSS perspective has no OpenGL far plane, preparation derives the side +face cutoff from the exact source model-view sequence and clips the source +`bottom = 5` faces conservatively at eye-space `z = -50`. Each prepared box +matrix carries real initial geometry rather than adapter metadata. Runtime +derives the exact static source-light factors from that matrix, then applies +only changed prepared transform indices and palette classes. Scheduling follows +the merged DOMFORMAT `polycss-playback@0` precedent: a timer waits for the next +deadline, one paint-aligned callback publishes the due state, and `elapsed` +catch-up collapses missed source ticks. There is no runtime geometry, +rasterization, topology rebuilding, or DOM growth. + +## Proof state + +- Source-state oracle: product ticks 0, 73, and 251 match the pinned + C implementation within explicit float tolerance. +- Browser retained-DOM smoke: desktop and mobile viewports mount the same + expected leaf census, keep DOM identity stable, publish prepared transforms, + and construct/redraw no atlases. +- Native visual capture: the headless macOS CGL harness executes the pinned + `cityflow.c` source directly; two 48-frame runs are byte-identical. +- Browser visual parity: 48 source-cadence frames pass the calibrated native / + retained-DOM comparison at mean delta `<= 1`, changed-pixel ratio `<= 0.05`, + and channel threshold `2`. The worst observed mean delta is `0.541659`; the + residual is confined to polygon-edge rasterization. +- FrameSleuth performance on the untouched 1280 x 720 product route records a + `19.728 ms` DrawFrame p50 and `34.213 ms` p95 against the source's `20 ms` + cadence. The only scheduled work is the DOMFORMAT-precedented timer / + paint-aligned callback; publications use prepared data and the retained graph + remains 200 roots / 600 leaves. diff --git a/src/adapters/cityflow/notes/references/source-lock.json b/src/adapters/cityflow/notes/references/source-lock.json new file mode 100644 index 0000000..64aa16a --- /dev/null +++ b/src/adapters/cityflow/notes/references/source-lock.json @@ -0,0 +1,15 @@ +{ + "schema": "csscityflow-source-lock@1", + "project": "XScreenSaver Cityflow", + "repository": "https://github.com/Zygo/xscreensaver", + "revision": "906693799e4fb7581436590cf84ecb2d3c9186ba", + "primary": { + "path": "hacks/glx/cityflow.c", + "sha256": "9113c9f3214ba6c1f350b3863c306e6015e47a856a17bbe24d597f909dfa027b" + }, + "config": { + "path": "hacks/config/cityflow.xml", + "sha256": "0453c12e39e4b7d32e66a7780d69979aaaa42d0632a3b6757df9c1cee6c7c919" + }, + "license": "permissive notice embedded in cityflow.c" +} diff --git a/src/adapters/cityflow/src/csscityflow/client.mjs b/src/adapters/cityflow/src/csscityflow/client.mjs new file mode 100644 index 0000000..6edb39d --- /dev/null +++ b/src/adapters/cityflow/src/csscityflow/client.mjs @@ -0,0 +1,90 @@ +import { createPolyPerspectiveCamera } from "@layoutit/polycss"; +import { loadPolyMorphPackage, mountPolyMorphModel } from "@layoutit/polycss-morph"; +import { + createCityflowPreparedPlayer, + loadCityflowPreparedPlayback, +} from "./preparedPlayback.mjs"; + +export function mountCityflow(host) { + const state = { ready: false, errors: [], bankId: null, mounted: null, player: null }; + globalThis.__csscityflow = state; + main().catch((error) => fail(error)); + + async function main() { + const bankId = "desktop"; + const modelId = "cityflow"; + const [loaded, playback] = await Promise.all([ + loadPolyMorphPackage("/csscityflow/", { modelId }), + loadCityflowPreparedPlayback(`/csscityflow/${modelId}.playback.json`), + loadPreparedStylesheet(`/csscityflow/${modelId}.css`), + ]); + const perspective = innerHeight / (2 * Math.tan(Math.PI / 12)); + const mounted = mountPolyMorphModel(host, loaded.model, { + resources: loaded.resources, + camera: createPolyPerspectiveCamera({ + perspective, + target: [0, 0, 0], + rotX: 0, + rotY: 0, + zoom: 50, + distance: -perspective, + }), + }); + const shapeElements = []; + for (const [index, box] of loaded.model.render.shapes.entries()) { + const element = mounted.shapeElements.get(box.id); + if (!element) throw new Error(`Missing retained Cityflow box ${box.id}`); + const source = readBoxState(loaded.model, index); + element.classList.add("csscityflow-box"); + shapeElements.push(element); + for (const [faceIndex, leaf] of [...element.children].entries()) { + leaf.style.setProperty("--csscityflow-light", source.lightFactors[faceIndex]); + } + } + const player = createCityflowPreparedPlayer({ playback, mounted, shapeElements }); + state.ready = true; + state.bankId = bankId; + state.mounted = mounted; + state.player = player; + document.body.classList.replace("loading", "ready"); + player.resume(); + } + + function fail(error) { + state.errors.push(String(error?.stack || error)); + document.body.classList.remove("loading"); + document.body.classList.add("error"); + console.error(error); + } + return state; +} + +function loadPreparedStylesheet(href) { + return new Promise((resolveLoad, rejectLoad) => { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = href; + link.addEventListener("load", resolveLoad, { once: true }); + link.addEventListener("error", () => rejectLoad(new Error(`Cityflow stylesheet failed: ${href}`)), { once: true }); + document.head.append(link); + }); +} + +function readBoxState(model, index) { + const shape = model.render.shapes[index]; + const matrix = shape?.matrix; + if (!shape || !Array.isArray(matrix) || matrix.length !== 16) { + throw new Error("Prepared Cityflow box transform is missing"); + } + const width = Math.hypot(matrix[0], matrix[1]); + const cth = matrix[0] / width; + const sth = -matrix[1] / width; + const lightLength = Math.hypot(0, 0.25, -1); + return Object.freeze({ + lightFactors: Object.freeze([ + 0.4 + 1 / lightLength, + 0.4 + Math.max(0, cth * 0.25 / lightLength), + 0.4 + Math.max(0, -sth * 0.25 / lightLength), + ]), + }); +} diff --git a/src/adapters/cityflow/src/csscityflow/preparedPlayback.mjs b/src/adapters/cityflow/src/csscityflow/preparedPlayback.mjs new file mode 100644 index 0000000..ffaf7a4 --- /dev/null +++ b/src/adapters/cityflow/src/csscityflow/preparedPlayback.mjs @@ -0,0 +1,251 @@ +import { createPolyMorphPreparedDomTarget } from "@layoutit/polycss-morph"; + +const MATRIX_PATTERN = /^matrix3d\(-?\d+(?:\.\d+)?(?:,-?\d+(?:\.\d+)?){15}\)$/u; +const decodedPackets = new WeakMap(); + +export function createCityflowPreparedPlayer({ playback, mounted, shapeElements, ...overrides }) { + const decoded = validatePlayback(playback, mounted, shapeElements); + const transforms = playback.transforms; + const transformIndices = decoded.transformIndices; + const colorIndices = decoded.colorIndices; + const frameCount = playback.frameCount; + const boxCount = playback.boxCount; + const frameMilliseconds = playback.tickIntervalUs[0] / playback.tickIntervalUs[1] / 1_000; + const requestFrame = overrides.requestFrame ?? globalThis.requestAnimationFrame.bind(globalThis); + const cancelFrame = overrides.cancelFrame ?? globalThis.cancelAnimationFrame.bind(globalThis); + const requestDelay = overrides.requestDelay ?? globalThis.setTimeout.bind(globalThis); + const cancelDelay = overrides.cancelDelay ?? globalThis.clearTimeout.bind(globalThis); + const readNow = overrides.readNow ?? globalThis.performance.now.bind(globalThis.performance); + const target = createPolyMorphPreparedDomTarget({ + model: { + element: mounted.modelElement, + writeTransform(transform) { + if (mounted.modelElement.style.transform === transform) return false; + mounted.modelElement.style.transform = transform; + return true; + }, + }, + shapes: shapeElements.map((element) => ({ element })), + leaves: [], + }); + const currentTransformIndices = transformIndices.slice(0, boxCount); + const currentColorIndices = colorIndices.slice(0, boxCount); + const colorClasses = Array.from({ length: 256 }, (_, index) => `csscityflow-color-${index}`); + let paused = true; + let timer = null; + let request = null; + let clockOrigin = readNow(); + let tick = 0; + let frameIndex = 0; + let timerCallbackCount = 0; + let animationFrameCallbackCount = 0; + let transformWrites = 0; + let colorWrites = 0; + let collapsedTickCount = 0; + let publicationCount = 0; + let lastTransformWrites = 0; + let lastColorWrites = 0; + + for (let index = 0; index < boxCount; index += 1) { + shapeElements[index].classList.add(colorClasses[currentColorIndices[index]]); + target.shapes[index].writeTransform(transforms[currentTransformIndices[index]]); + } + target.assertStableDomIdentity(); + + function publishFrame(nextFrameIndex) { + const offset = nextFrameIndex * boxCount; + let nextColorWrites = 0; + let nextTransformWrites = 0; + for (let index = 0; index < boxCount; index += 1) { + const nextColorIndex = colorIndices[offset + index]; + if (currentColorIndices[index] === nextColorIndex) continue; + shapeElements[index].classList.replace( + colorClasses[currentColorIndices[index]], + colorClasses[nextColorIndex], + ); + currentColorIndices[index] = nextColorIndex; + nextColorWrites += 1; + } + for (let index = 0; index < boxCount; index += 1) { + const nextTransformIndex = transformIndices[offset + index]; + if (currentTransformIndices[index] === nextTransformIndex) continue; + target.shapes[index].writeTransform(transforms[nextTransformIndex]); + currentTransformIndices[index] = nextTransformIndex; + nextTransformWrites += 1; + } + frameIndex = nextFrameIndex; + publicationCount += 1; + transformWrites += nextTransformWrites; + colorWrites += nextColorWrites; + lastTransformWrites = nextTransformWrites; + lastColorWrites = nextColorWrites; + return frameIndex; + } + + function cancelScheduled() { + if (timer !== null) cancelDelay(timer); + if (request !== null) cancelFrame(request); + timer = null; + request = null; + } + + function schedule() { + if (paused || timer !== null || request !== null) return; + const delay = Math.max(0, clockOrigin + frameMilliseconds - readNow() - 1); + timer = requestDelay(wake, delay); + } + + function wake() { + timer = null; + timerCallbackCount += 1; + if (!paused) request = requestFrame(loop); + } + + function loop(timestamp) { + request = null; + if (paused) return; + animationFrameCallbackCount += 1; + const due = Math.floor(Math.max(0, timestamp - clockOrigin + 0.5) / frameMilliseconds); + if (due > 0) { + tick += due; + collapsedTickCount += Math.max(0, due - 1); + publishFrame(tick % frameCount); + clockOrigin += due * frameMilliseconds; + } + schedule(); + } + + function snapshot() { + target.assertStableDomIdentity(); + mounted.assertStableDomIdentity(); + return Object.freeze({ + schema: "csscityflow-prepared-player-stats@1", + ready: true, + paused, + tick, + frameIndex, + frameCount, + boxCount, + frameMilliseconds, + catchUpPolicy: playback.catchUpPolicy, + timerCallbackCount, + animationFrameCallbackCount, + collapsedTickCount, + publicationCount, + transformWrites, + colorWrites, + initialTransformWrites: boxCount, + lastPublication: Object.freeze({ + frameIndex, + transformWrites: lastTransformWrites, + colorWrites: lastColorWrites, + }), + identityStable: true, + runtimeGeometryCalculationCount: 0, + runtimeAtlasRasterizationCount: 0, + runtimeDomGrowth: false, + }); + } + + return Object.freeze({ + get paused() { return paused; }, + get frameIndex() { return frameIndex; }, + pause() { + paused = true; + cancelScheduled(); + return snapshot(); + }, + resume() { + if (!paused) return snapshot(); + paused = false; + clockOrigin = readNow(); + schedule(); + return snapshot(); + }, + seekFrame(value) { + if (!Number.isSafeInteger(value) || value < 0 || value >= frameCount) { + throw new RangeError("Cityflow prepared frame is out of range"); + } + cancelScheduled(); + tick = value; + publishFrame(value); + clockOrigin = readNow(); + if (!paused) schedule(); + return snapshot(); + }, + assertStableDomIdentity() { + target.assertStableDomIdentity(); + mounted.assertStableDomIdentity(); + return true; + }, + stats: snapshot, + destroy() { + paused = true; + cancelScheduled(); + target.destroy(); + }, + }); +} + +export async function loadCityflowPreparedPlayback(url, fetchImpl = globalThis.fetch) { + const response = await fetchImpl(url, { cache: "no-store" }); + if (!response.ok) throw new Error(`Cityflow prepared playback failed to load: ${response.status}`); + const playback = await response.json(); + validatePacket(playback); + return playback; +} + +function validatePlayback(playback, mounted, shapeElements) { + const decoded = validatePacket(playback); + if (!mounted?.modelElement || typeof mounted.assertStableDomIdentity !== "function" || + !Array.isArray(shapeElements) || shapeElements.length !== playback.boxCount || + shapeElements.some((element) => !(element instanceof HTMLElement))) { + throw new Error("Cityflow retained playback targets drifted"); + } + return decoded; +} + +function validatePacket(playback) { + if (playback?.schema !== "csscityflow-prepared-playback@1" || + playback.precedent !== "domformat@0/polycss-playback@0@cc8da736" || + playback.catchUpPolicy !== "elapsed" || playback.frameCount !== 252 || + !Number.isSafeInteger(playback.boxCount) || playback.boxCount < 1 || + !Array.isArray(playback.tickIntervalUs) || playback.tickIntervalUs.length !== 2 || + playback.tickIntervalUs[0] !== 20_000 || playback.tickIntervalUs[1] !== 1 || + !Array.isArray(playback.transforms) || playback.transforms.length < playback.boxCount || + playback.transforms.some((transform) => typeof transform !== "string" || !MATRIX_PATTERN.test(transform)) || + typeof playback.transformIndicesBase64 !== "string" || + typeof playback.colorIndicesBase64 !== "string") { + throw new Error("Cityflow prepared playback packet drifted"); + } + const cached = decodedPackets.get(playback); + if (cached) return cached; + const expectedCells = playback.frameCount * playback.boxCount; + const transformIndices = decodeUint32(playback.transformIndicesBase64); + const colorIndices = decodeUint8(playback.colorIndicesBase64); + if (transformIndices.length !== expectedCells || colorIndices.length !== expectedCells || + transformIndices.some((index) => index >= playback.transforms.length)) { + throw new Error("Cityflow prepared playback table drifted"); + } + const decoded = Object.freeze({ transformIndices, colorIndices }); + decodedPackets.set(playback, decoded); + return decoded; +} + +function decodeUint8(value) { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; +} + +function decodeUint32(value) { + const bytes = decodeUint8(value); + if (bytes.byteLength % 4 !== 0) throw new Error("Cityflow uint32 playback table is truncated"); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const values = new Uint32Array(bytes.byteLength / 4); + for (let index = 0; index < values.length; index += 1) { + values[index] = view.getUint32(index * 4, true); + } + return values; +} diff --git a/src/adapters/cityflow/src/csscityflow/styles.css b/src/adapters/cityflow/src/csscityflow/styles.css new file mode 100644 index 0000000..10f45ca --- /dev/null +++ b/src/adapters/cityflow/src/csscityflow/styles.css @@ -0,0 +1,177 @@ +:root { + color-scheme: dark; + background: var(--csscityflow-background, #000); + color: #f2f2f2; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; +} + +body { + position: relative; + overflow: hidden; + background: var(--csscityflow-background, #000); +} + +body.loading::after { + content: ""; + position: fixed; + inset: 50% auto auto 50%; + z-index: 20; + width: 18px; + height: 18px; + margin: -9px 0 0 -9px; + border: 1px solid rgb(240 240 240 / 18%); + border-top-color: rgb(240 240 240 / 70%); + border-radius: 50%; +} + +.site-header { + position: fixed; + inset: 0 0 auto; + z-index: 21; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + height: 50px; + padding: 0 12px 0 16px; + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; + pointer-events: none; +} + +.site-wordmark-heading { + margin: 0; + font: inherit; +} + +.site-wordmark { + display: inline-flex; + align-items: center; + color: #aeb4bc; + opacity: 0.72; + pointer-events: auto; + text-decoration: none; +} + +.site-wordmark-svg { + display: block; + width: 218px; + height: 30px; + overflow: visible; +} + +.site-wordmark-svg text { + fill: currentColor; + font-family: inherit; + font-size: 24px; + font-weight: 400; + letter-spacing: 0.1px; +} + +.site-wordmark-css { font-weight: 500; } +.site-wordmark-graphics { font-weight: 200; } +.site-wordmark-path { font-weight: 100; } + +.site-action-icon-only { + display: inline-flex; + width: 36px; + min-width: 36px; + height: 50px; + align-items: center; + justify-content: center; + padding: 0; + color: #f0f0f0; + background: transparent; + border: 0; + font: 400 13px/1 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + letter-spacing: 0.01em; + cursor: pointer; + opacity: 0.72; + pointer-events: auto; + text-decoration: none; +} + +.site-action-icon { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.8; +} + +.site-action-icon-outline { + stroke: rgb(0 0 0 / 60%); + stroke-width: 4; +} + +.site-wordmark:hover, +.site-wordmark:focus-visible, +.site-action-icon-only:hover, +.site-action-icon-only:focus-visible { + opacity: 1; +} + +.site-wordmark:focus-visible { + outline: 1px solid currentColor; + outline-offset: 4px; +} + +.site-action-icon-only:focus-visible { + outline: 1px solid currentColor; + outline-offset: -5px; +} + +body > .polycss-camera { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: hidden; + contain: layout paint size style; + perspective: calc(50vh / .2679491924) !important; + perspective-origin: 50% 50%; +} + +body > .polycss-camera > .polycss-scene { + transform: translateZ(calc(50vh / .2679491924)) !important; +} + +.polycss-mesh { + position: absolute; + transform-style: preserve-3d; + transform-origin: 0 0 0; +} + +body.loading > .polycss-camera { + visibility: hidden; +} + +body.error::after { + content: "Cityflow failed to load"; + position: fixed; + inset: 50% auto auto 50%; + color: #f88; + translate: -50% -50%; +} + +@media (max-width: 520px) { + .site-header { padding-inline: 12px; } + .site-wordmark-svg { width: 176px; } +} + +@media (max-width: 390px) { + .site-wordmark-svg { width: 148px; } +} diff --git a/src/adapters/cityflow/src/main.mjs b/src/adapters/cityflow/src/main.mjs new file mode 100644 index 0000000..eb0fc4b --- /dev/null +++ b/src/adapters/cityflow/src/main.mjs @@ -0,0 +1,4 @@ +import "./csscityflow/styles.css"; +import { mountCityflow } from "./csscityflow/client.mjs"; + +mountCityflow(document.body); diff --git a/src/adapters/cityflow/src/prepare/csscityflow/model.mjs b/src/adapters/cityflow/src/prepare/csscityflow/model.mjs new file mode 100644 index 0000000..baec713 --- /dev/null +++ b/src/adapters/cityflow/src/prepare/csscityflow/model.mjs @@ -0,0 +1,280 @@ +import { Buffer } from "node:buffer"; +import { + CSSCITYFLOW_FACE_IDS, + CSSCITYFLOW_FRAME_MILLISECONDS, + buildCityflowSourceState, + cityflowFrameAt, +} from "./sourceModel.mjs"; + +const SOURCE_MODEL_MATRIX = Object.freeze([ + 29.600317554756, 6.483735104931, 8.604207095757, 0, + -10.773634514759, 17.813915794524, 23.639864707904, 0, + 0, 25.15701856649, -18.95717322929, 0, + -2.7, -2.7, -30, 1, +]); +export const CSSCITYFLOW_SOURCE_BOTTOM = 5; +export const CSSCITYFLOW_SOURCE_FAR_PLANE = -50; +const CSSCITYFLOW_SOURCE_VIEW = Object.freeze({ + cameraDistance: 30, + worldScale: 15, + floorRotationDegrees: -90, + floorOffsetX: -0.18, + floorOffsetZ: -0.18, + tiltDegrees: 37, + turnDegrees: 20, + boxScale: 2.1, +}); +const FACE_MATRICES = Object.freeze({ + top: Object.freeze([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.5, -0.5, 1, 1]), + front: Object.freeze([1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, -0.5, 0.5, 0, 1]), + right: Object.freeze([0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0.5, -0.5, 0, 1]), +}); + +export function buildCityflowMorphModel(options = {}) { + const state = buildCityflowSourceState(options); + const initialFrame = cityflowFrameAt(state, 0); + const shapeIds = state.boxes.map((box) => `box-${String(box.renderIndex).padStart(4, "0")}`); + const polygons = []; + const vertices = []; + const leaves = state.boxes.flatMap((box, boxIndex) => CSSCITYFLOW_FACE_IDS.map((faceId, faceIndex) => { + const leafIndex = boxIndex * CSSCITYFLOW_FACE_IDS.length + faceIndex; + const polygonId = `polygon-${String(leafIndex).padStart(4, "0")}`; + const vertexOffset = vertices.length; + vertices.push( + Object.freeze([0, 0, 0]), + Object.freeze([1, 0, 0]), + Object.freeze([1, 1, 0]), + Object.freeze([0, 1, 0]), + ); + polygons.push(Object.freeze({ + id: polygonId, + vertexIndices: Object.freeze([vertexOffset, vertexOffset + 1, vertexOffset + 2, vertexOffset + 3]), + normalIndices: Object.freeze([faceIndex, faceIndex, faceIndex, faceIndex]), + })); + return Object.freeze({ + id: `${shapeIds[boxIndex]}-${faceId}`, + polygonId, + shapeId: shapeIds[boxIndex], + materialId: "cityflow-face", + strategy: "solid-quad", + width: 1, + height: 1, + matrix: FACE_MATRICES[faceId], + atlas: null, + fallback: null, + }); + })); + return Object.freeze({ + state, + model: Object.freeze({ + schema: "polycss-morph.model@1", + identity: Object.freeze({ id: state.source.modelId, name: state.source.name, revision: "1.0.0" }), + profile: "static-prepared", + capabilities: Object.freeze(["retained-render"]), + budgets: Object.freeze({ + maxVertices: vertices.length, + maxPolygons: polygons.length, + maxLeaves: leaves.length, + maxFrames: 0, + maxJoints: 0, + maxResources: 1, + maxBytes: 16 * 1024 * 1024, + }), + topology: Object.freeze({ + vertices: Object.freeze(vertices), + normals: Object.freeze([ + Object.freeze([0, 0, -1]), + Object.freeze([0, 1, 0]), + Object.freeze([1, 0, 0]), + ]), + polygons: Object.freeze(polygons), + }), + materials: Object.freeze([Object.freeze({ + id: "cityflow-face", + color: Object.freeze([1, 1, 1, 1]), + })]), + render: Object.freeze({ + modelMatrix: SOURCE_MODEL_MATRIX, + shapes: Object.freeze(shapeIds.map((id, index) => Object.freeze({ + id, + matrix: boxStateMatrix(state.boxes[index], initialFrame.boxes[index]), + }))), + leaves: Object.freeze(leaves), + }), + deformation: Object.freeze({ kind: "none" }), + controls: Object.freeze([]), + springs: Object.freeze([]), + animations: Object.freeze([]), + playback: null, + provenance: Object.freeze({ + generator: "csscityflow-preparer", + generatorVersion: "1.0.0", + sources: Object.freeze([Object.freeze({ + id: "xscreensaver-cityflow", + kind: "open-data", + uri: `https://github.com/Zygo/xscreensaver/blob/${state.source.commit}/${state.source.primaryPath}`, + sha256: state.source.primarySha256, + license: "XScreenSaver cityflow.c permissive notice", + })]), + }), + }), + }); +} + +function boxStateMatrix(box, sample) { + const clippedBottoms = cityflowClippedBottoms(box); + return Object.freeze(boxPlaybackMatrixValues( + box, + -sample.height / 2, + Math.min(clippedBottoms.front, clippedBottoms.right), + )); +} + +export function cityflowClippedBottoms(box) { + const x = box.centerX; + const y = box.centerY; + const xw = box.cth * box.width / 2; + const xd = box.sth * box.depth / 2; + const yw = -box.sth * box.width / 2; + const yd = box.cth * box.depth / 2; + return Object.freeze({ + front: clippedBottom([ + [x + xw + xd, y + yw + yd], + [x - xw + xd, y - yw + yd], + ]), + right: clippedBottom([ + [x + xw - xd, y + yw - yd], + [x + xw + xd, y + yw + yd], + ]), + }); +} + +function clippedBottom(edge) { + return Math.min(CSSCITYFLOW_SOURCE_BOTTOM, ...edge.map(([x, y]) => { + const eyeZAtZero = cityflowEyeZ(x, y, 0); + const eyeZPerZ = cityflowEyeZ(x, y, 1) - eyeZAtZero; + return (CSSCITYFLOW_SOURCE_FAR_PLANE - eyeZAtZero) / eyeZPerZ; + })); +} + +export function cityflowEyeZ(x, y, z) { + let point = [ + x * CSSCITYFLOW_SOURCE_VIEW.boxScale, + y * CSSCITYFLOW_SOURCE_VIEW.boxScale, + z * CSSCITYFLOW_SOURCE_VIEW.boxScale, + ]; + point = rotateZ(point, CSSCITYFLOW_SOURCE_VIEW.turnDegrees); + point = rotateX(point, CSSCITYFLOW_SOURCE_VIEW.tiltDegrees); + point = [ + point[0] + CSSCITYFLOW_SOURCE_VIEW.floorOffsetX, + point[1], + point[2] + CSSCITYFLOW_SOURCE_VIEW.floorOffsetZ, + ]; + point = rotateX(point, CSSCITYFLOW_SOURCE_VIEW.floorRotationDegrees); + point = point.map((value) => value * CSSCITYFLOW_SOURCE_VIEW.worldScale); + point = rotateX(point, -180); + return point[2] - CSSCITYFLOW_SOURCE_VIEW.cameraDistance; +} + +function rotateX([x, y, z], degrees) { + const radians = degrees * Math.PI / 180; + const cosine = Math.cos(radians); + const sine = Math.sin(radians); + return [x, cosine * y - sine * z, sine * y + cosine * z]; +} + +function rotateZ([x, y, z], degrees) { + const radians = degrees * Math.PI / 180; + const cosine = Math.cos(radians); + const sine = Math.sin(radians); + return [cosine * x - sine * y, sine * x + cosine * y, z]; +} + +export function buildCityflowPreparedCss(state) { + const rules = [ + `:root{--csscityflow-background:${paletteColor(state.palette[0])}}`, + `.csscityflow-box>b{color:rgb(from var(--csscityflow-base) calc(r * var(--csscityflow-light)) calc(g * var(--csscityflow-light)) calc(b * var(--csscityflow-light)))!important}`, + `.csscityflow-box>b:first-child{backface-visibility:visible!important}`, + `.csscityflow-box>b:not(:first-child){backface-visibility:hidden!important}`, + ]; + for (let index = 0; index < state.palette.length; index += 1) { + rules.push(`.csscityflow-color-${index}{--csscityflow-base:${paletteColor(state.palette[index])}}`); + } + return rules.join(""); +} + +export function buildCityflowPreparedPlayback(state) { + const frameCount = 252; + const transformIndices = new Uint32Array(frameCount * state.boxes.length); + const colorIndices = new Uint8Array(frameCount * state.boxes.length); + const transforms = []; + const transformIndexByValue = new Map(); + const bottoms = state.boxes.map((box) => { + const clipped = cityflowClippedBottoms(box); + return Math.min(clipped.front, clipped.right); + }); + for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { + const frame = cityflowFrameAt(state, frameIndex); + for (let boxIndex = 0; boxIndex < state.boxes.length; boxIndex += 1) { + const sample = frame.boxes[boxIndex]; + const transform = boxPlaybackMatrix( + state.boxes[boxIndex], + -sample.height / 2, + bottoms[boxIndex], + ); + let transformIndex = transformIndexByValue.get(transform); + if (transformIndex === undefined) { + transformIndex = transforms.length; + transformIndexByValue.set(transform, transformIndex); + transforms.push(transform); + } + const offset = frameIndex * state.boxes.length + boxIndex; + transformIndices[offset] = transformIndex; + colorIndices[offset] = sample.colorIndex; + } + } + return Object.freeze({ + schema: "csscityflow-prepared-playback@1", + precedent: "domformat@0/polycss-playback@0@cc8da736", + catchUpPolicy: "elapsed", + frameCount, + tickIntervalUs: Object.freeze([CSSCITYFLOW_FRAME_MILLISECONDS * 1_000, 1]), + boxCount: state.boxes.length, + transforms: Object.freeze(transforms), + transformIndicesBase64: Buffer.from(transformIndices.buffer).toString("base64"), + colorIndicesBase64: Buffer.from(colorIndices.buffer).toString("base64"), + }); +} + +function boxPlaybackMatrix(box, top, bottom) { + return `matrix3d(${boxPlaybackMatrixValues(box, top, bottom).map(formatNumber).join(",")})`; +} + +function boxPlaybackMatrixValues(box, top, bottom) { + return [ + box.cth * box.width, + -box.sth * box.width, + 0, + 0, + box.sth * box.depth, + box.cth * box.depth, + 0, + 0, + 0, + 0, + top - bottom, + 0, + box.centerX, + box.centerY, + bottom, + 1, + ]; +} + +function paletteColor(color) { + return `#${color.map((channel) => Math.round(channel * 255 / 65536).toString(16).padStart(2, "0")).join("")}`; +} + +function formatNumber(value) { + return Number(value.toFixed(9)).toString(); +} diff --git a/src/adapters/cityflow/src/prepare/csscityflow/sourceModel.mjs b/src/adapters/cityflow/src/prepare/csscityflow/sourceModel.mjs new file mode 100644 index 0000000..168acf6 --- /dev/null +++ b/src/adapters/cityflow/src/prepare/csscityflow/sourceModel.mjs @@ -0,0 +1,328 @@ +const RANDOM_VECTOR = Object.freeze([ + 0o35340171546, 0o10401501101, 0o22364657325, 0o24130436022, 0o02167303062, + 0o37570375137, 0o37210607110, 0o16272055420, 0o23011770546, 0o17143426366, + 0o14753657433, 0o21657231332, 0o23553406142, 0o04236526362, 0o10365611275, + 0o07117336710, 0o11051276551, 0o02362132524, 0o01011540233, 0o12162531646, + 0o07056762337, 0o06631245521, 0o14164542224, 0o32633236305, 0o23342700176, + 0o02433062234, 0o15257225043, 0o26762051606, 0o00742573230, 0o05366042132, + 0o12126416411, 0o00520471171, 0o00725646277, 0o20116577576, 0o25765742604, + 0o07633473735, 0o15674255275, 0o17555634041, 0o06503154145, 0o21576344247, + 0o14577627653, 0o02707523333, 0o34146376720, 0o30060227734, 0o13765414060, + 0o36072251540, 0o07255221037, 0o24364674123, 0o06200353166, 0o10126373326, + 0o15664104320, 0o16401041535, 0o16215305520, 0o33115351014, 0o17411670323, +]); + +const SOURCE_COMMON = Object.freeze({ + commit: "906693799e4fb7581436590cf84ecb2d3c9186ba", + primaryPath: "hacks/glx/cityflow.c", + primarySha256: "9113c9f3214ba6c1f350b3863c306e6015e47a856a17bbe24d597f909dfa027b", + configPath: "hacks/config/cityflow.xml", + configSha256: "0453c12e39e4b7d32e66a7780d69979aaaa42d0632a3b6757df9c1cee6c7c919", + delayMicroseconds: 20_000, + skewDegrees: 12, + waveCount: 6, + waveSpeed: 25, + waveRadius: 256, + textureSize: 512, + paletteSize: 256, +}); + +export const CITYFLOW_BANKS = Object.freeze({ + desktop: createBank("desktop", "cityflow", "Cityflow", 200), +}); + +export const CITYFLOW_SOURCE = CITYFLOW_BANKS.desktop; +export const CSSCITYFLOW_SEED = 26081702; +export const CSSCITYFLOW_FRAME_MILLISECONDS = SOURCE_COMMON.delayMicroseconds / 1_000; +export const CSSCITYFLOW_PHASE_STEP = SOURCE_COMMON.waveSpeed / 1_000; +export const CSSCITYFLOW_PHASE_PERIOD = Math.PI * 2; +export const CSSCITYFLOW_FACE_IDS = Object.freeze(["top", "front", "right"]); + +function createBank(id, modelId, name, boxCount) { + return Object.freeze({ ...SOURCE_COMMON, id, modelId, name, boxCount }); +} + +export function resolveCityflowBank(bankId) { + const bank = CITYFLOW_BANKS[bankId]; + if (!bank) throw new RangeError(`Unknown Cityflow prepared bank: ${bankId}`); + return bank; +} + +export function buildCityflowSourceState({ + bankId = "desktop", + seed = CSSCITYFLOW_SEED, +} = {}) { + const source = resolveCityflowBank(bankId); + if (!Number.isSafeInteger(seed) || seed <= 0) throw new RangeError("Cityflow seed must be positive"); + const rng = createYaRandom(seed); + const palette = makeSmoothColorMap(rng, source.paletteSize); + const waves = Object.freeze(Array.from({ length: source.waveCount }, (_, index) => Object.freeze({ + index, + xTheta: rng.frand(Math.PI * 2), + yTheta: rng.frand(Math.PI * 2), + }))); + const scale = f32(1.8 / Math.sqrt(source.boxCount)); + const boxes = []; + let minX = 0; + let maxX = 0; + let minY = 0; + let maxY = 0; + for (let sourceIndex = 0; sourceIndex < source.boxCount; sourceIndex += 1) { + const theta = -rng.frand(source.skewDegrees) * Math.PI / 180; + const x = f32(rng.frand(1) - 0.5); + const y = f32(rng.frand(1) - 0.5); + const z = f32(rng.frand(0.12)); + const cth = f32(Math.cos(theta)); + const sth = f32(Math.sin(theta)); + const width = f32(scale * (rng.frand(1) + 0.2)); + const depth = f32(scale * (rng.frand(1) + 0.2)); + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + boxes.push({ sourceIndex, x, y, z, cth, sth, width, depth }); + } + boxes.sort((left, right) => Math.trunc(right.y * 10_000) - Math.trunc(left.y * 10_000)); + const preparedBoxes = boxes.map((box, renderIndex) => { + const fx = f32(f32(box.x - minX) / f32(maxX - minX)); + const fy = f32(f32(box.y - minY) / f32(maxY - minY)); + const sampleX = Math.trunc(f32(source.textureSize * fx)) % source.textureSize; + const sampleY = Math.trunc(f32(source.textureSize * fy)) % source.textureSize; + const centerX = f32(f32(box.cth * box.x) + f32(box.sth * box.y)); + const centerY = f32(f32(-box.sth * box.x) + f32(box.cth * box.y)); + return Object.freeze({ + ...box, + renderIndex, + centerX, + centerY, + sampleX, + sampleY, + lightFactors: cityflowLightFactors(box), + }); + }); + return Object.freeze({ + schema: "csscityflow-source-state@1", + seed, + source, + palette, + waves, + bounds: Object.freeze({ minX, maxX, minY, maxY }), + boxes: Object.freeze(preparedBoxes), + }); +} + +export function cityflowFrameAt(sourceState, tick) { + if (sourceState?.schema !== "csscityflow-source-state@1") { + throw new TypeError("Cityflow frame requires prepared source state"); + } + if (!Number.isSafeInteger(tick) || tick < 0) throw new RangeError("Cityflow tick must be non-negative"); + const wavePositions = sourceState.waves.map((wave) => { + let xTheta = wave.xTheta; + let yTheta = wave.yTheta; + for (let index = 0; index <= tick; index += 1) { + xTheta += CSSCITYFLOW_PHASE_STEP; + if (xTheta > CSSCITYFLOW_PHASE_PERIOD) xTheta -= CSSCITYFLOW_PHASE_PERIOD; + yTheta += CSSCITYFLOW_PHASE_STEP; + if (yTheta > CSSCITYFLOW_PHASE_PERIOD) yTheta -= CSSCITYFLOW_PHASE_PERIOD; + } + return Object.freeze({ + xTheta, + yTheta, + x: Math.trunc(sourceState.source.textureSize / 2 + + Math.cos(xTheta) * sourceState.source.textureSize / 2), + y: Math.trunc(sourceState.source.textureSize / 2 + + Math.cos(yTheta) * sourceState.source.textureSize / 2), + }); + }); + const heights = makeWaveHeightTable(sourceState.source); + const boxes = sourceState.boxes.map((box) => { + let value = 0; + for (const wave of wavePositions) { + const dx = box.sampleX - wave.x; + const dy = box.sampleY - wave.y; + const distance = Math.trunc(Math.sqrt(dx * dx + dy * dy)); + value += distance >= sourceState.source.waveRadius ? 0 : heights[distance]; + } + value = Math.trunc(value * 0.4); + if (value > 255) value = 255; + const height = f32(box.z + value / 256 / 2.5 + 0.1); + const colorIndex = Math.trunc(height * sourceState.source.paletteSize * 0.7) % + sourceState.source.paletteSize; + return Object.freeze({ renderIndex: box.renderIndex, sourceIndex: box.sourceIndex, height, colorIndex }); + }); + return Object.freeze({ tick, wavePositions: Object.freeze(wavePositions), boxes: Object.freeze(boxes) }); +} + +export function cityflowLightFactors(box) { + const lightLength = Math.hypot(0, 0.25, -1); + const top = 0.4 + 1 / lightLength; + const front = 0.4 + Math.max(0, box.cth * 0.25 / lightLength); + const right = 0.4 + Math.max(0, -box.sth * 0.25 / lightLength); + return Object.freeze([top, front, right].map((value) => Math.max(0, value))); +} + +export function makeWaveHeightTable(source = CITYFLOW_SOURCE) { + return Object.freeze(Array.from({ length: source.waveRadius }, (_, distance) => { + const maximum = source.paletteSize * (source.waveRadius - distance) / source.waveRadius; + return Math.trunc((maximum + maximum * Math.cos(distance / 50)) / 2); + })); +} + +export function createYaRandom(seed) { + const vector = RANDOM_VECTOR.map((value) => value >>> 0); + let value = seed >>> 0; + vector[0] = (vector[0] + value) >>> 0; + for (let index = 1; index < vector.length; index += 1) { + value = Math.imul(value, 999) >>> 0; + value = rotateLeft(value, 9); + value = (value + Math.imul(vector[index - 1], 1001)) >>> 0; + value = rotateLeft(value, 15); + vector[index] = (vector[index] + value) >>> 0; + } + let index1 = vector[0] % vector.length; + let index2 = (index1 + 24) % vector.length; + return Object.freeze({ + next() { + const result = (vector[index1] + vector[index2]) >>> 0; + vector[index1] = result; + index1 = (index1 + 1) % vector.length; + index2 = (index2 + 1) % vector.length; + return result; + }, + frand(maximum) { + return this.next() * maximum / 0xFFFFFFFF; + }, + }); +} + +function makeSmoothColorMap(rng, colorCount) { + let pointCount; + const selector = rng.next() % 20; + if (selector <= 5) pointCount = 2; + else if (selector <= 15) pointCount = 3; + else if (selector <= 18) pointCount = 4; + else pointCount = 5; + const hues = new Array(pointCount); + const saturations = new Array(pointCount); + const values = new Array(pointCount); + let totalSaturation = 0; + let totalValue = 0; + let loop = 0; + while (true) { + for (let index = 0; index < pointCount; index += 1) { + while (true) { + if (++loop > 10_000) throw new Error("Cityflow source palette selection did not converge"); + hues[index] = rng.next() % 360; + saturations[index] = rng.frand(1); + values[index] = rng.frand(0.8) + 0.2; + if (index === 0) break; + const other = index + 1 === pointCount ? 0 : index - 1; + let hueDistance = Math.abs(hues[other] / 360 - hues[index] / 360); + if (hueDistance > 0.5) hueDistance = 0.5 - (hueDistance - 0.5); + const distance = Math.sqrt(hueDistance * hueDistance + + (saturations[other] - saturations[index]) ** 2 + + (values[other] - values[index]) ** 2); + if (distance >= 0.2) break; + } + totalSaturation += saturations[index]; + totalValue += values[index]; + } + if (totalSaturation / pointCount >= 0.2 && totalValue / pointCount >= 0.3) break; + } + return Object.freeze(makeColorPath(hues, saturations, values, colorCount)); +} + +function makeColorPath(hues, saturations, values, colorCount) { + if (hues.length === 2) return makeColorRamp(hues, saturations, values, colorCount); + const count = hues.length; + const hueDistances = new Array(count); + const edges = new Array(count); + let circumference = 0; + for (let index = 0; index < count; index += 1) { + const next = (index + 1) % count; + let distance = Math.abs((hues[index] - hues[next]) / 360); + if (distance > 0.5) distance = 0.5 - (distance - 0.5); + hueDistances[index] = distance; + } + for (let index = 0; index < count; index += 1) { + const next = (index + 1) % count; + edges[index] = Math.sqrt(hueDistances[index] * hueDistances[next] + + (saturations[next] - saturations[index]) ** 2 + + (values[next] - values[index]) ** 2); + circumference += edges[index]; + } + if (circumference < 0.0001) throw new Error("Cityflow source palette path collapsed"); + const segmentCounts = edges.map((edge) => Math.trunc(colorCount * edge / circumference)); + const hueSteps = new Array(count); + const saturationSteps = new Array(count); + const valueSteps = new Array(count); + for (let index = 0; index < count; index += 1) { + const next = (index + 1) % count; + if (segmentCounts[index] > 0) { + hueSteps[index] = 360 * hueDistances[index] / segmentCounts[index]; + saturationSteps[index] = (saturations[next] - saturations[index]) / segmentCounts[index]; + valueSteps[index] = (values[next] - values[index]) / segmentCounts[index]; + } + } + const colors = []; + for (let index = 0; index < count; index += 1) { + const distance = hues[(index + 1) % count] - hues[index]; + let direction = distance >= 0 ? -1 : 1; + if (distance <= 180 && distance >= -180) direction = -direction; + for (let step = 0; step < segmentCounts[index]; step += 1) { + let hue = hues[index] + step * hueSteps[index] * direction; + if (hue < 0) hue += 360; + colors.push(hsvToRgb(Math.trunc(hue), + saturations[index] + step * saturationSteps[index], + values[index] + step * valueSteps[index])); + } + } + if (colors.length === 0) throw new Error("Cityflow source palette emitted no colors"); + while (colors.length < colorCount) colors.push(colors.at(-1)); + return colors.slice(0, colorCount); +} + +function makeColorRamp(hues, saturations, values, colorCount) { + const halfCount = Math.trunc(colorCount / 2) + 1; + const hueStep = (hues[1] - hues[0]) / halfCount; + const saturationStep = (saturations[1] - saturations[0]) / halfCount; + const valueStep = (values[1] - values[0]) / halfCount; + const colors = new Array(colorCount); + for (let index = 0; index < halfCount; index += 1) { + colors[index] = hsvToRgb( + Math.trunc(hues[0] + index * hueStep), + saturations[0] + index * saturationStep, + values[0] + index * valueStep, + ); + } + for (let index = halfCount; index < colorCount; index += 1) { + colors[index] = colors[colorCount - index]; + } + return colors; +} + +function hsvToRgb(hue, saturation, value) { + const s = Math.max(0, Math.min(1, saturation)); + const v = Math.max(0, Math.min(1, value)); + const h = ((hue % 360) + 360) % 360 / 60; + const sector = Math.trunc(h); + const fraction = h - sector; + const p1 = v * (1 - s); + const p2 = v * (1 - s * fraction); + const p3 = v * (1 - s * (1 - fraction)); + const channels = sector === 0 ? [v, p3, p1] + : sector === 1 ? [p2, v, p1] + : sector === 2 ? [p1, v, p3] + : sector === 3 ? [p1, p2, v] + : sector === 4 ? [p3, p1, v] + : [v, p1, p2]; + return Object.freeze(channels.map((channel) => Math.trunc(channel * 65535))); +} + +function rotateLeft(value, bits) { + return ((value << bits) | (value >>> (32 - bits))) >>> 0; +} + +function f32(value) { + return Math.fround(value); +} diff --git a/src/adapters/cityflow/test/csscityflow-model.test.mjs b/src/adapters/cityflow/test/csscityflow-model.test.mjs new file mode 100644 index 0000000..ab33833 --- /dev/null +++ b/src/adapters/cityflow/test/csscityflow-model.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + CSSCITYFLOW_SOURCE_FAR_PLANE, + buildCityflowMorphModel, + buildCityflowPreparedCss, + buildCityflowPreparedPlayback, + cityflowClippedBottoms, + cityflowEyeZ, +} from "../src/prepare/csscityflow/model.mjs"; + +test("builds one retained root and three solid quad leaves per source box", () => { + for (const [bankId, boxCount] of [["desktop", 200]]) { + const { state, model } = buildCityflowMorphModel({ bankId }); + assert.equal(model.profile, "static-prepared"); + assert.equal(model.render.shapes.length, boxCount); + assert.equal(model.render.leaves.length, boxCount * 3); + assert.equal(model.topology.polygons.length, boxCount * 3); + assert.equal(model.topology.vertices.length, boxCount * 12); + assert.ok(model.render.leaves.every((leaf) => + leaf.strategy === "solid-quad" && leaf.width === 1 && leaf.height === 1 && leaf.atlas === null)); + assert.deepEqual(model.materials, [{ id: "cityflow-face", color: [1, 1, 1, 1] }]); + } +}); + +test("prepares DOMFORMAT-precedented elapsed playback without author keyframes", () => { + const { state } = buildCityflowMorphModel(); + const css = buildCityflowPreparedCss(state); + const playback = buildCityflowPreparedPlayback(state); + assert.equal(playback.schema, "csscityflow-prepared-playback@1"); + assert.equal(playback.precedent, "domformat@0/polycss-playback@0@cc8da736"); + assert.equal(playback.catchUpPolicy, "elapsed"); + assert.deepEqual(playback.tickIntervalUs, [20_000, 1]); + assert.equal(playback.boxCount, 200); + assert.equal( + Buffer.from(playback.transformIndicesBase64, "base64").byteLength, + playback.frameCount * playback.boxCount * Uint32Array.BYTES_PER_ELEMENT, + ); + assert.equal( + Buffer.from(playback.colorIndicesBase64, "base64").byteLength, + playback.frameCount * playback.boxCount * Uint8Array.BYTES_PER_ELEMENT, + ); + assert.ok(playback.transforms.length >= playback.boxCount); + assert.doesNotMatch(css, /@keyframes|animation-/u); + assert.match(css, /\.csscityflow-color-255\{/u); + assert.match(css, /b:first-child\{backface-visibility:visible!important\}/u); + assert.equal((css.match(/hypot\(/gu) ?? []).length, 0); + assert.doesNotMatch(css, /filter|clip-path|mask|linear-gradient|radial-gradient/u); +}); + +test("clips source side faces at the native far plane during preparation", () => { + const { state } = buildCityflowMorphModel(); + for (const box of state.boxes) { + const x = box.centerX; + const y = box.centerY; + const xw = box.cth * box.width / 2; + const xd = box.sth * box.depth / 2; + const yw = -box.sth * box.width / 2; + const yd = box.cth * box.depth / 2; + const bottoms = cityflowClippedBottoms(box); + assert.ok(cityflowEyeZ(x + xw + xd, y + yw + yd, bottoms.front) >= CSSCITYFLOW_SOURCE_FAR_PLANE - 1e-9); + assert.ok(cityflowEyeZ(x - xw + xd, y - yw + yd, bottoms.front) >= CSSCITYFLOW_SOURCE_FAR_PLANE - 1e-9); + assert.ok(cityflowEyeZ(x + xw - xd, y + yw - yd, bottoms.right) >= CSSCITYFLOW_SOURCE_FAR_PLANE - 1e-9); + assert.ok(cityflowEyeZ(x + xw + xd, y + yw + yd, bottoms.right) >= CSSCITYFLOW_SOURCE_FAR_PLANE - 1e-9); + } +}); diff --git a/src/adapters/cityflow/test/csscityflow-runtime-surface.test.mjs b/src/adapters/cityflow/test/csscityflow-runtime-surface.test.mjs new file mode 100644 index 0000000..0df0be2 --- /dev/null +++ b/src/adapters/cityflow/test/csscityflow-runtime-surface.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +test("uses the DOMFORMAT-precedented elapsed deadline playback transport", async () => { + const [client, player] = await Promise.all([ + readFile(new URL("../src/csscityflow/client.mjs", import.meta.url), "utf8"), + readFile(new URL("../src/csscityflow/preparedPlayback.mjs", import.meta.url), "utf8"), + ]); + assert.match(client, /mountPolyMorphModel/u); + assert.match(player, /domformat@0\/polycss-playback@0@cc8da736/u); + assert.match(player, /requestAnimationFrame/u); + assert.match(player, /setTimeout/u); + assert.doesNotMatch(`${client}\n${player}`, /setInterval|canvas|getContext/u); +}); + +test("keeps the route shell minimal and source-named", async () => { + const html = await readFile(new URL("../index.html", import.meta.url), "utf8"); + assert.match(html, /site-wordmark-css/u); + assert.match(html, /site-wordmark-graphics/u); + assert.match(html, /\/cityflow/u); + assert.equal((html.match(/ { + assert.equal(CITYFLOW_BANKS.desktop.boxCount, 200); + assert.equal(CITYFLOW_BANKS.desktop.waveCount, 6); + assert.equal(CITYFLOW_BANKS.desktop.waveRadius, 256); + assert.equal(CITYFLOW_BANKS.desktop.textureSize, 512); + assert.equal(CSSCITYFLOW_FRAME_MILLISECONDS, 20); + assert.deepEqual(CSSCITYFLOW_FACE_IDS, ["top", "front", "right"]); + assert.ok(buildCityflowSourceState().boxes.every((box) => box.lightFactors[0] > 1)); +}); + +test("prepares deterministic source state and exact checkpoint frames", () => { + const left = buildCityflowSourceState(); + const right = buildCityflowSourceState(); + assert.deepEqual(left, right); + assert.equal(left.boxes.length, 200); + assert.equal(left.palette.length, 256); + assert.equal(left.waves.length, 6); + const frame = cityflowFrameAt(left, 73); + assert.equal(frame.boxes.length, 200); + assert.equal(frame.wavePositions.length, 6); + assert.ok(frame.boxes.every(({ height, colorIndex }) => + height >= 0.1 && height < 0.62 && colorIndex >= 0 && colorIndex < 256)); +}); + +test("keeps source front-to-back box ordering", () => { + const state = buildCityflowSourceState(); + for (let index = 1; index < state.boxes.length; index += 1) { + const previous = Math.trunc(state.boxes[index - 1].y * 10_000); + const current = Math.trunc(state.boxes[index].y * 10_000); + assert.ok(previous >= current); + } +}); diff --git a/src/adapters/cityflow/tools/check-prepare-determinism.mjs b/src/adapters/cityflow/tools/check-prepare-determinism.mjs new file mode 100644 index 0000000..ead2f2e --- /dev/null +++ b/src/adapters/cityflow/tools/check-prepare-determinism.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { readFile, readdir } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; +import { promisify } from "node:util"; + +const run = promisify(execFile); +const adapterRoot = resolve(import.meta.dirname, ".."); +const repositoryRoot = resolve(adapterRoot, "..", "..", ".."); +const outputRoot = resolve(repositoryRoot, "build/generated/public/csscityflow"); +const prepareScript = resolve(adapterRoot, "tools/prepare-csscityflow.mjs"); + +const first = await prepareAndHash(); +const second = await prepareAndHash(); +if (JSON.stringify(first) !== JSON.stringify(second)) { + throw new Error(`Cityflow preparation is nondeterministic:\n${JSON.stringify({ first, second }, null, 2)}`); +} +console.log(JSON.stringify({ status: "deterministic", ...second }, null, 2)); + +async function prepareAndHash() { + await run(process.execPath, [prepareScript], { + cwd: repositoryRoot, + env: process.env, + timeout: 300_000, + }); + const files = await walk(outputRoot); + const closure = createHash("sha256"); + let bytes = 0; + for (const path of files) { + const relativePath = relative(outputRoot, path); + const contents = await readFile(path); + closure.update(relativePath).update("\0").update(contents).update("\0"); + bytes += contents.length; + } + return Object.freeze({ + sha256: closure.digest("hex"), + bytes, + files: Object.freeze(files.map((path) => relative(outputRoot, path))), + }); +} + +async function walk(root) { + const paths = []; + for (const entry of (await readdir(root, { withFileTypes: true })) + .sort((left, right) => left.name.localeCompare(right.name))) { + const path = join(root, entry.name); + if (entry.isDirectory()) paths.push(...await walk(path)); + else if (entry.isFile()) paths.push(path); + } + return paths; +} diff --git a/src/adapters/cityflow/tools/native/capture-cityflow-state.c b/src/adapters/cityflow/tools/native/capture-cityflow-state.c new file mode 100644 index 0000000..a8fa1bd --- /dev/null +++ b/src/adapters/cityflow/tools/native/capture-cityflow-state.c @@ -0,0 +1,228 @@ +#include +#include +#include +#include +#include "yarandom.h" + +#undef ya_rand_init + +#define MAXPOINTS 50 +#define PALETTE_SIZE 256 +#define WAVE_COUNT 6 +#define WAVE_RADIUS 256 +#define TEXTURE_SIZE 512 +#define WAVE_STEP 0.025 +#define PI 3.14159265358979323846 + +typedef struct { unsigned short red, green, blue; } color; +typedef struct { double xth, yth; int x, y; } wave; +typedef struct { + int source_index; + float x, y, z, cth, sth, w, d; +} cube; + +static void hsv_to_rgb(int h, double s, double v, + unsigned short *r, unsigned short *g, unsigned short *b) { + if (s < 0) s = 0; + if (v < 0) v = 0; + if (s > 1) s = 1; + if (v > 1) v = 1; + double H = (h % 360) / 60.0; + int i = H; + double f = H - i; + double p1 = v * (1 - s); + double p2 = v * (1 - s * f); + double p3 = v * (1 - s * (1 - f)); + double R, G, B; + if (i == 0) { R = v; G = p3; B = p1; } + else if (i == 1) { R = p2; G = v; B = p1; } + else if (i == 2) { R = p1; G = v; B = p3; } + else if (i == 3) { R = p1; G = p2; B = v; } + else if (i == 4) { R = p3; G = p1; B = v; } + else { R = v; G = p1; B = p2; } + *r = R * 65535; + *g = G * 65535; + *b = B * 65535; +} + +static void make_color_ramp(int h1, double s1, double v1, + int h2, double s2, double v2, + color *colors, int color_count) { + int ncolors = color_count / 2 + 1; + double dh = (h2 - h1) / (double)ncolors; + double ds = (s2 - s1) / ncolors; + double dv = (v2 - v1) / ncolors; + for (int i = 0; i < ncolors; i++) + hsv_to_rgb((int)(h1 + i * dh), s1 + i * ds, v1 + i * dv, + &colors[i].red, &colors[i].green, &colors[i].blue); + for (int i = ncolors; i < color_count; i++) colors[i] = colors[color_count - i]; +} + +static void make_color_path(int npoints, int *h, double *s, double *v, + color *colors, int color_count) { + if (npoints == 2) { + make_color_ramp(h[0], s[0], v[0], h[1], s[1], v[1], colors, color_count); + return; + } + int ncolors[MAXPOINTS] = {0}; + double dh[MAXPOINTS] = {0}, ds[MAXPOINTS] = {0}, dv[MAXPOINTS] = {0}; + double hue_distance[MAXPOINTS] = {0}, edge[MAXPOINTS] = {0}; + double circumference = 0; + for (int i = 0; i < npoints; i++) { + int j = (i + 1) % npoints; + double distance = fabs((h[i] - h[j]) / 360.0); + if (distance > 0.5) distance = 0.5 - (distance - 0.5); + hue_distance[i] = distance; + } + for (int i = 0; i < npoints; i++) { + int j = (i + 1) % npoints; + edge[i] = sqrt(hue_distance[i] * hue_distance[j] + + (s[j] - s[i]) * (s[j] - s[i]) + + (v[j] - v[i]) * (v[j] - v[i])); + circumference += edge[i]; + } + for (int i = 0; i < npoints; i++) { + int j = (i + 1) % npoints; + ncolors[i] = color_count * edge[i] / circumference; + if (ncolors[i] > 0) { + dh[i] = 360 * hue_distance[i] / ncolors[i]; + ds[i] = (s[j] - s[i]) / ncolors[i]; + dv[i] = (v[j] - v[i]) / ncolors[i]; + } + } + int output = 0; + for (int i = 0; i < npoints; i++) { + int distance = h[(i + 1) % npoints] - h[i]; + int direction = distance >= 0 ? -1 : 1; + if (distance <= 180 && distance >= -180) direction = -direction; + for (int j = 0; j < ncolors[i]; j++, output++) { + double hue = h[i] + j * dh[i] * direction; + if (hue < 0) hue += 360; + else if (hue > 360) hue -= 0; + hsv_to_rgb((int)hue, s[i] + j * ds[i], v[i] + j * dv[i], + &colors[output].red, &colors[output].green, &colors[output].blue); + } + } + while (output < color_count) { + colors[output] = colors[output - 1]; + output++; + } +} + +static void make_smooth_colormap(color *colors) { + int selector = random() % 20; + int npoints = selector <= 5 ? 2 : selector <= 15 ? 3 : selector <= 18 ? 4 : 5; + int h[MAXPOINTS]; + double s[MAXPOINTS], v[MAXPOINTS]; + double total_s = 0, total_v = 0; + int loop = 0; +repick_all: + for (int i = 0; i < npoints; i++) { +repick_one: + if (++loop > 10000) abort(); + h[i] = random() % 360; + s[i] = frand(1.0); + v[i] = frand(0.8) + 0.2; + if (i > 0) { + int j = i + 1 == npoints ? 0 : i - 1; + double dh = fabs(h[j] / 360.0 - h[i] / 360.0); + if (dh > 0.5) dh = 0.5 - (dh - 0.5); + double distance = sqrt(dh * dh + (s[j] - s[i]) * (s[j] - s[i]) + + (v[j] - v[i]) * (v[j] - v[i])); + if (distance < 0.2) goto repick_one; + } + total_s += s[i]; + total_v += v[i]; + } + if (total_s / npoints < 0.2 || total_v / npoints < 0.3) goto repick_all; + make_color_path(npoints, h, s, v, colors, PALETTE_SIZE); +} + +static int compare_cubes(const void *left, const void *right) { + const cube *a = left, *b = right; + return (int)(b->y * 10000) - (int)(a->y * 10000); +} + +int main(int argc, char **argv) { + unsigned int seed = argc > 1 ? (unsigned int)strtoul(argv[1], 0, 10) : 26081702; + int count = argc > 2 ? atoi(argv[2]) : 800; + int tick = argc > 3 ? atoi(argv[3]) : 0; + ya_rand_init(seed); + color palette[PALETTE_SIZE] = {0}; + make_smooth_colormap(palette); + wave waves[WAVE_COUNT] = {0}; + for (int i = 0; i < WAVE_COUNT; i++) { + waves[i].xth = frand(2.0) * PI; + waves[i].yth = frand(2.0) * PI; + } + cube *cubes = calloc((size_t)count, sizeof(*cubes)); + float minimum_x = 0, maximum_x = 0, minimum_y = 0, maximum_y = 0; + float scale = 1.8 / sqrt(count); + for (int i = 0; i < count; i++) { + double theta = -frand(12) * PI / 180; + cube *item = &cubes[i]; + item->source_index = i; + item->x = frand(1) - 0.5; + item->y = frand(1) - 0.5; + item->z = frand(0.12); + item->cth = cos(theta); + item->sth = sin(theta); + item->w = scale * (frand(1) + 0.2); + item->d = scale * (frand(1) + 0.2); + if (item->x < minimum_x) minimum_x = item->x; + if (item->x > maximum_x) maximum_x = item->x; + if (item->y < minimum_y) minimum_y = item->y; + if (item->y > maximum_y) maximum_y = item->y; + } + qsort(cubes, (size_t)count, sizeof(*cubes), compare_cubes); + int heights[WAVE_RADIUS]; + for (int i = 0; i < WAVE_RADIUS; i++) { + float maximum = PALETTE_SIZE * (WAVE_RADIUS - i) / (float)WAVE_RADIUS; + heights[i] = (maximum + maximum * cos(i / 50.0)) / 2.0; + } + for (int step = 0; step <= tick; step++) { + for (int i = 0; i < WAVE_COUNT; i++) { + waves[i].xth += WAVE_STEP; + if (waves[i].xth > 2 * PI) waves[i].xth -= 2 * PI; + waves[i].yth += WAVE_STEP; + if (waves[i].yth > 2 * PI) waves[i].yth -= 2 * PI; + waves[i].x = TEXTURE_SIZE / 2 + cos(waves[i].xth) * TEXTURE_SIZE / 2; + waves[i].y = TEXTURE_SIZE / 2 + cos(waves[i].yth) * TEXTURE_SIZE / 2; + } + } + printf("{\"seed\":%u,\"count\":%d,\"tick\":%d,", seed, count, tick); + printf("\"palette\":["); + int palette_indices[] = {0, 1, 64, 128, 192, 255}; + for (int i = 0; i < 6; i++) { + color c = palette[palette_indices[i]]; + printf("%s[%u,%u,%u]", i ? "," : "", c.red, c.green, c.blue); + } + printf("],\"waves\":["); + for (int i = 0; i < WAVE_COUNT; i++) + printf("%s[%.17g,%.17g,%d,%d]", i ? "," : "", waves[i].xth, waves[i].yth, waves[i].x, waves[i].y); + printf("],\"boxes\":["); + int sample_count = count < 8 ? count : 8; + for (int i = 0; i < sample_count; i++) { + cube *item = &cubes[i]; + float fx = (item->x - minimum_x) / (maximum_x - minimum_x); + float fy = (item->y - minimum_y) / (maximum_y - minimum_y); + int sample_x = (int)(TEXTURE_SIZE * fx) % TEXTURE_SIZE; + int sample_y = (int)(TEXTURE_SIZE * fy) % TEXTURE_SIZE; + int value = 0; + for (int j = 0; j < WAVE_COUNT; j++) { + int dx = sample_x - waves[j].x, dy = sample_y - waves[j].y; + int distance = sqrt(dx * dx + dy * dy); + value += distance >= WAVE_RADIUS ? 0 : heights[distance]; + } + value *= 0.4; + if (value > 255) value = 255; + float height = item->z + value / 256.0 / 2.5 + 0.1; + int color_index = ((int)(height * PALETTE_SIZE * 0.7)) % PALETTE_SIZE; + printf("%s[%d,%.9g,%.9g,%.9g,%.9g,%.9g,%.9g,%.9g,%d,%d,%.9g,%d]", + i ? "," : "", item->source_index, item->x, item->y, item->z, + item->cth, item->sth, item->w, item->d, sample_x, sample_y, height, color_index); + } + printf("]}\n"); + free(cubes); + return 0; +} diff --git a/src/adapters/cityflow/tools/native/headless/capture-cityflow.c b/src/adapters/cityflow/tools/native/headless/capture-cityflow.c new file mode 100644 index 0000000..e3e9a33 --- /dev/null +++ b/src/adapters/cityflow/tools/native/headless/capture-cityflow.c @@ -0,0 +1,168 @@ +#include +#include +#include +#include + +char *progname = "csscityflow-headless-native-oracle"; + +#define USE_GL 1 +#include "cityflow.c" + +static CGLContextObj capture_context; +static CGLPixelFormatObj capture_pixel_format; +static GLuint capture_framebuffer; +static GLuint capture_colorbuffer; +static GLuint capture_depthbuffer; +static GLXContext capture_glx_context; + +static void create_offscreen_context(int width, int height); +static void destroy_offscreen_context(void); +static int write_ppm(const char *path, int width, int height); +static long parse_positive(const char *text, const char *label); + +int +main(int argc, char **argv) +{ + ModeInfo mi; + unsigned int seed; + int width; + int height; + int frame_count; + char path[4096]; + + if (argc != 7) { + fprintf(stderr, "usage: %s frames-directory seed count width height frames\n", argv[0]); + return 2; + } + seed = (unsigned int)parse_positive(argv[2], "seed"); + width = (int)parse_positive(argv[4], "width"); + height = (int)parse_positive(argv[5], "height"); + frame_count = (int)parse_positive(argv[6], "frames"); + if (mkdir(argv[1], 0777) != 0 && errno != EEXIST) { + perror("mkdir frames"); + return 3; + } + + memset(&mi, 0, sizeof(mi)); + mi.width = width; + mi.height = height; + mi.count = (int)parse_positive(argv[3], "count"); + mi.wireframe_p = False; + mi.fps_p = False; + + create_offscreen_context(width, height); + glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, capture_framebuffer); + glDrawBuffer(GL_COLOR_ATTACHMENT0_EXT); + glReadBuffer(GL_COLOR_ATTACHMENT0_EXT); + +#undef ya_rand_init + ya_rand_init(seed); + wave_count = 6; + wave_speed = 25; + wave_radius = 256; + skew = 12; + init_cube(&mi); + + for (int frame = 0; frame < frame_count; frame++) { + draw_cube(&mi); + snprintf(path, sizeof(path), "%s/frame_%04d.ppm", argv[1], frame); + if (!write_ppm(path, width, height)) return 4; + } + + free_cube(&mi); + free(ccs); + ccs = NULL; + destroy_offscreen_context(); + return 0; +} + +static int +write_ppm(const char *path, int width, int height) +{ + unsigned char *rgba = malloc((size_t)width * (size_t)height * 4); + unsigned char *row = malloc((size_t)width * 3); + FILE *stream; + if (!rgba || !row) abort(); + glFinish(); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadBuffer(GL_COLOR_ATTACHMENT0_EXT); + glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, rgba); + if (glGetError() != GL_NO_ERROR) return 0; + stream = fopen(path, "wb"); + if (!stream) return 0; + fprintf(stream, "P6\n%d %d\n255\n", width, height); + for (int y = height - 1; y >= 0; y--) { + const unsigned char *source = rgba + (size_t)y * (size_t)width * 4; + for (int x = 0; x < width; x++) { + row[x * 3] = source[x * 4]; + row[x * 3 + 1] = source[x * 4 + 1]; + row[x * 3 + 2] = source[x * 4 + 2]; + } + if (fwrite(row, 1, (size_t)width * 3, stream) != (size_t)width * 3) return 0; + } + free(row); + free(rgba); + return fclose(stream) == 0; +} + +static void +create_offscreen_context(int width, int height) +{ + CGLPixelFormatAttribute attributes[] = { + kCGLPFAOpenGLProfile, (CGLPixelFormatAttribute)kCGLOGLPVersion_Legacy, + kCGLPFAAccelerated, + kCGLPFAAllowOfflineRenderers, + kCGLPFAColorSize, (CGLPixelFormatAttribute)24, + kCGLPFAAlphaSize, (CGLPixelFormatAttribute)8, + kCGLPFADepthSize, (CGLPixelFormatAttribute)24, + (CGLPixelFormatAttribute)0, + }; + GLint count = 0; + if (CGLChoosePixelFormat(attributes, &capture_pixel_format, &count) != kCGLNoError || !capture_pixel_format) abort(); + if (CGLCreateContext(capture_pixel_format, NULL, &capture_context) != kCGLNoError || !capture_context) abort(); + if (CGLSetCurrentContext(capture_context) != kCGLNoError) abort(); + capture_glx_context = capture_context; + + glGenFramebuffersEXT(1, &capture_framebuffer); + glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, capture_framebuffer); + glGenRenderbuffersEXT(1, &capture_colorbuffer); + glBindRenderbufferEXT(GL_RENDERBUFFER_EXT, capture_colorbuffer); + glRenderbufferStorageEXT(GL_RENDERBUFFER_EXT, GL_RGBA8, width, height); + glFramebufferRenderbufferEXT(GL_FRAMEBUFFER_EXT, GL_COLOR_ATTACHMENT0_EXT, GL_RENDERBUFFER_EXT, capture_colorbuffer); + glGenRenderbuffersEXT(1, &capture_depthbuffer); + glBindRenderbufferEXT(GL_RENDERBUFFER_EXT, capture_depthbuffer); + glRenderbufferStorageEXT(GL_RENDERBUFFER_EXT, GL_DEPTH_COMPONENT24, width, height); + glFramebufferRenderbufferEXT(GL_FRAMEBUFFER_EXT, GL_DEPTH_ATTACHMENT_EXT, GL_RENDERBUFFER_EXT, capture_depthbuffer); + if (glCheckFramebufferStatusEXT(GL_FRAMEBUFFER_EXT) != GL_FRAMEBUFFER_COMPLETE_EXT) abort(); +} + +static void +destroy_offscreen_context(void) +{ + if (capture_depthbuffer) glDeleteRenderbuffersEXT(1, &capture_depthbuffer); + if (capture_colorbuffer) glDeleteRenderbuffersEXT(1, &capture_colorbuffer); + if (capture_framebuffer) glDeleteFramebuffersEXT(1, &capture_framebuffer); + CGLSetCurrentContext(NULL); + if (capture_context) CGLDestroyContext(capture_context); + if (capture_pixel_format) CGLDestroyPixelFormat(capture_pixel_format); +} + +static long +parse_positive(const char *text, const char *label) +{ + char *end = NULL; + errno = 0; + long value = strtol(text, &end, 10); + if (errno || !end || *end || value <= 0 || value > INT_MAX) { + fprintf(stderr, "%s must be a positive integer\n", label); + exit(2); + } + return value; +} + +GLXContext *init_GL(ModeInfo *mi) { (void)mi; return &capture_glx_context; } +Bool glXMakeCurrent(Display *display, Window window, GLXContext context) { (void)display; (void)window; (void)context; return True; } +void glXSwapBuffers(Display *display, Window window) { (void)display; (void)window; } +void do_fps(ModeInfo *mi) { (void)mi; } +Bool screenhack_event_helper(Display *display, Window window, XEvent *event) { (void)display; (void)window; (void)event; return False; } +double current_device_rotation(void) { return 0; } diff --git a/src/adapters/cityflow/tools/native/headless/include/screenhackI.h b/src/adapters/cityflow/tools/native/headless/include/screenhackI.h new file mode 100644 index 0000000..47d4505 --- /dev/null +++ b/src/adapters/cityflow/tools/native/headless/include/screenhackI.h @@ -0,0 +1,30 @@ +#ifndef CSSCITYFLOW_HEADLESS_SCREENHACK_I_H +#define CSSCITYFLOW_HEADLESS_SCREENHACK_I_H + +#include +#include +#include +#include +#include + +#define GL_GLEXT_PROTOTYPES 1 +#include +#include +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +#define countof(value) (sizeof(value) / sizeof(*(value))) + +typedef int Bool; + +#ifndef False +#define False 0 +#endif +#ifndef True +#define True 1 +#endif + +#endif diff --git a/src/adapters/cityflow/tools/native/headless/include/utils.h b/src/adapters/cityflow/tools/native/headless/include/utils.h new file mode 100644 index 0000000..8c24eeb --- /dev/null +++ b/src/adapters/cityflow/tools/native/headless/include/utils.h @@ -0,0 +1,6 @@ +#ifndef CSSCITYFLOW_HEADLESS_UTILS_H +#define CSSCITYFLOW_HEADLESS_UTILS_H + +#include "xlockmore.h" + +#endif diff --git a/src/adapters/cityflow/tools/native/headless/include/visual.h b/src/adapters/cityflow/tools/native/headless/include/visual.h new file mode 100644 index 0000000..a40c842 --- /dev/null +++ b/src/adapters/cityflow/tools/native/headless/include/visual.h @@ -0,0 +1,10 @@ +#ifndef CSSCITYFLOW_HEADLESS_VISUAL_H +#define CSSCITYFLOW_HEADLESS_VISUAL_H + +#include "xlockmore.h" + +static inline Bool has_writable_cells(Screen *screen, Visual *visual) { + (void)screen; (void)visual; return False; +} + +#endif diff --git a/src/adapters/cityflow/tools/native/headless/include/xlockmore.h b/src/adapters/cityflow/tools/native/headless/include/xlockmore.h new file mode 100644 index 0000000..8dedebc --- /dev/null +++ b/src/adapters/cityflow/tools/native/headless/include/xlockmore.h @@ -0,0 +1,125 @@ +#ifndef CSSCITYFLOW_HEADLESS_XLOCKMORE_H +#define CSSCITYFLOW_HEADLESS_XLOCKMORE_H + +#include "screenhackI.h" +#include "yarandom.h" + +typedef struct Display Display; +typedef struct Screen Screen; +typedef struct Visual Visual; +typedef unsigned long Window; +typedef unsigned long Colormap; +typedef unsigned long KeySym; +typedef void *GLXContext; + +typedef struct { + unsigned long pixel; + unsigned short red; + unsigned short green; + unsigned short blue; + unsigned char flags; + unsigned char pad; +} XColor; + +typedef union XEvent { + struct { int type; } xany; + struct { int type; } xkey; + struct { int type; unsigned int button; unsigned int state; int x; int y; } xbutton; + struct { int type; int x; int y; } xmotion; +} XEvent; + +typedef struct { + Colormap colormap; +} XWindowAttributes; + +typedef struct { + int screen; + int width; + int height; + int count; + Bool wireframe_p; + Bool fps_p; + int polygon_count; + int recursion_depth; + Display *display; + Display *dpy; + Window window; + XWindowAttributes xgwa; +} ModeInfo; + +typedef struct { + const char *option; + const char *specifier; + int arg_kind; + const char *value; +} XrmOptionDescRec; + +typedef struct { + void *var; + const char *name; + const char *class_name; + const char *default_value; + int type; +} argtype; + +typedef struct { + int option_count; + XrmOptionDescRec *options; + int variable_count; + argtype *variables; + void *defaults; +} ModeSpecOpt; + +enum { XrmoptionNoArg = 0, XrmoptionSepArg = 1 }; +enum { t_Bool = 1, t_Float = 2, t_Int = 3 }; +enum { + KeyPress = 2, ButtonPress = 4, ButtonRelease = 5, MotionNotify = 6, + Button1 = 1, + XK_Up = 1, XK_Right = 2, XK_Next = 3, XK_Down = 4, XK_Left = 5, XK_Prior = 6 +}; +enum { DoRed = 1, DoGreen = 2, DoBlue = 4 }; + +#define ENTRYPOINT +#define XSCREENSAVER_MODULE_2(name, symbol, prefix) + +#define MI_SCREEN(mi) ((mi)->screen) +#define MI_WIDTH(mi) ((mi)->width) +#define MI_HEIGHT(mi) ((mi)->height) +#define MI_COUNT(mi) ((mi)->count) +#define MI_IS_WIREFRAME(mi) ((mi)->wireframe_p) +#define MI_DISPLAY(mi) ((mi)->display) +#define MI_WINDOW(mi) ((mi)->window) +#define MI_INIT(mi, storage) do { \ + if (!(storage)) (storage) = calloc(1, sizeof(*(storage))); \ + if (!(storage)) abort(); \ +} while (0) + +#define DisplayOfScreen(screen) ((Display *)0) + +static inline int XLookupString(void *event, char *buffer, int length, KeySym *keysym, void *compose) { + (void)event; (void)keysym; (void)compose; if (length > 0) buffer[0] = 0; return 0; +} +static inline int XAllocColorCells(Display *display, Colormap map, Bool contiguous, unsigned long *planes, unsigned int planeCount, unsigned long *pixels, unsigned int count) { + (void)display; (void)map; (void)contiguous; (void)planes; (void)planeCount; (void)pixels; (void)count; return 0; +} +static inline int XAllocColor(Display *display, Colormap map, XColor *color) { + (void)display; (void)map; (void)color; return 0; +} +static inline void XStoreColors(Display *display, Colormap map, XColor *colors, int count) { + (void)display; (void)map; (void)colors; (void)count; +} +static inline void XFreeColors(Display *display, Colormap map, unsigned long *pixels, int count, unsigned long planes) { + (void)display; (void)map; (void)pixels; (void)count; (void)planes; +} +static inline void XFlush(Display *display) { (void)display; } + +GLXContext *init_GL(ModeInfo *mi); +Bool glXMakeCurrent(Display *display, Window window, GLXContext context); +void glXSwapBuffers(Display *display, Window window); +void do_fps(ModeInfo *mi); +Bool screenhack_event_helper(Display *display, Window window, XEvent *event); +char *get_string_resource(Display *display, char *name, char *class_name); +int XParseColor(Display *display, Colormap colormap, const char *specification, XColor *color); +double current_device_rotation(void); + +#endif diff --git a/src/adapters/cityflow/tools/oracle/run-visual-oracle.mjs b/src/adapters/cityflow/tools/oracle/run-visual-oracle.mjs new file mode 100644 index 0000000..ff5831b --- /dev/null +++ b/src/adapters/cityflow/tools/oracle/run-visual-oracle.mjs @@ -0,0 +1,419 @@ +#!/usr/bin/env node +import { spawn, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; +import { + access, + chmod, + copyFile, + mkdir, + readFile, + readdir, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { createServer } from "node:net"; +import { chromium } from "playwright"; +import sharp from "sharp"; +import { + CSSCITYFLOW_FRAME_MILLISECONDS, + CSSCITYFLOW_SEED, + CITYFLOW_SOURCE, +} from "../../src/prepare/csscityflow/sourceModel.mjs"; + +const adapterRoot = resolve(import.meta.dirname, "../.."); +const repositoryRoot = resolve(adapterRoot, "../../.."); +const sourceRoot = resolveRequiredSourceRoot(); +const outputRoot = resolve( + process.env.CSSCITYFLOW_VISUAL_ORACLE_OUT ?? + join(repositoryRoot, "bench/results/csscityflow/native-browser-visual"), +); +const width = 1280; +const height = 720; +const frameCount = readFrameCount(); +const diffFrames = [ + "worst", + ...new Set([0, 0.25, 0.5, 0.75, 1].map((ratio) => Math.round((frameCount - 1) * ratio))), +].join(","); +const sourceLock = JSON.parse(await readFile(join(adapterRoot, "notes/references/source-lock.json"), "utf8")); +const oracleScript = resolve( + process.env.CSS_FRAME_SEQUENCE_ORACLE_SCRIPT ?? + join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "skills/frame-sequence-oracle/scripts/frame-sequence.mjs"), +); + +await assertSourceIdentity(); +await assertReadable(oracleScript, "frame-sequence oracle"); +await rm(outputRoot, { recursive: true, force: true }); +await mkdir(outputRoot, { recursive: true }); + +const binary = await compileNativeCapture(); +const nativeA = await captureNativeRun(binary, join(outputRoot, "native/run-a")); +const nativeB = await captureNativeRun(binary, join(outputRoot, "native/run-b")); +const nativeAa = await compareSequences({ + expected: nativeA, + actual: nativeB, + out: join(outputRoot, "native/visual-aa"), + label: "csscityflow_native_visual_aa", + thresholds: ["0", "0", "0"], + diffFrames: "worst", +}); +if (!nativeAa.pass) throw new Error("Native Cityflow frame capture is not deterministic"); + +const server = await startBrowserServer(); +let browser; +try { + browser = await chromium.launch({ channel: "chrome", headless: true }); + const browserA = await captureBrowserRun(browser, server.url, join(outputRoot, "browser/run-a")); + const browserB = await captureBrowserRun(browser, server.url, join(outputRoot, "browser/run-b")); + const browserAa = await compareSequences({ + expected: browserA.framesDir, + actual: browserB.framesDir, + out: join(outputRoot, "browser/visual-aa"), + label: "csscityflow_browser_visual_aa", + thresholds: ["0", "0", "0"], + diffFrames: "worst", + }); + if (!browserAa.pass) throw new Error("Browser Cityflow frame capture is not deterministic"); + const nativeBrowser = await compareSequences({ + expected: nativeA, + actual: browserA.framesDir, + out: join(outputRoot, "native-browser-compare"), + label: "csscityflow_native_browser", + thresholds: ["1", "0.05", "2"], + diffFrames, + }); + const worstFrame = nativeBrowser.worst[0]?.frame ?? 0; + const preview = await writeTriptych({ + nativeDir: nativeA, + browserDir: browserA.framesDir, + comparison: nativeBrowser, + frame: worstFrame, + }); + const report = { + schema: "csscityflow-native-browser-visual-oracle@1", + status: nativeBrowser.pass ? "aligned" : "not-aligned", + source: { + revision: sourceLock.revision, + primaryPath: sourceLock.primary.path, + primarySha256: sourceLock.primary.sha256, + }, + seed: CSSCITYFLOW_SEED, + frames: frameCount, + sourceFrameMilliseconds: CSSCITYFLOW_FRAME_MILLISECONDS, + viewport: { width, height, deviceScaleFactor: 1 }, + native: { aaExact: nativeAa.pass, framesDir: nativeA }, + browser: { + aaExact: browserAa.pass, + framesDir: browserA.framesDir, + audit: browserA.audit, + rendererContract: "retained DOM / CSS transforms; no Canvas, WebGL, SVG scene, or runtime rasterization", + }, + comparison: { + pass: nativeBrowser.pass, + thresholds: nativeBrowser.thresholds, + reportPath: nativeBrowser.manifestPath, + worst: nativeBrowser.worst[0] ?? null, + }, + preview, + }; + const reportPath = join(outputRoot, "native-browser-visual-oracle.json"); + await writeJson(reportPath, report); + console.log(JSON.stringify({ ...report, reportPath }, null, 2)); + if (!nativeBrowser.pass) process.exitCode = 1; +} finally { + await browser?.close(); + server.process.kill("SIGTERM"); +} + +async function compileNativeCapture() { + if (process.platform !== "darwin") throw new Error("The Cityflow native visual oracle requires macOS CGL"); + const compiler = process.env.CC || "clang"; + const sdk = resolveSdk(); + const captureSource = join(adapterRoot, "tools/native/headless/capture-cityflow.c"); + const captureInclude = join(adapterRoot, "tools/native/headless/include"); + const inputs = [ + captureSource, + ...["screenhackI.h", "utils.h", "visual.h", "xlockmore.h"].map((name) => join(captureInclude, name)), + join(sourceRoot, sourceLock.primary.path), + ...["gltrackball.c", "gltrackball.h", "trackball.c", "trackball.h", "quaternion.c", "quaternion.h"].map((name) => join(sourceRoot, "hacks/glx", name)), + ...["yarandom.c", "yarandom.h", "colors.c", "colors.h", "hsv.c", "hsv.h"].map((name) => join(sourceRoot, "utils", name)), + ]; + const identity = createHash("sha256").update(compiler).update(sdk); + for (const path of inputs) identity.update(path).update(await readFile(path)); + const buildDir = join(outputRoot, "native-build", identity.digest("hex").slice(0, 20)); + const binary = join(buildDir, "capture-cityflow"); + await mkdir(buildDir, { recursive: true }); + for (const name of ["colors.c", "colors.h", "hsv.c", "hsv.h"]) { + await copyFile(join(sourceRoot, "utils", name), join(buildDir, name)); + } + const temporary = `${binary}.tmp-${process.pid}`; + const result = spawnSync(compiler, [ + "-std=gnu11", "-O2", "-Wno-deprecated-declarations", + "-DHAVE_UNISTD_H", "-DGETTIMEOFDAY_TWO_ARGS", + "-isysroot", sdk, + "-include", join(captureInclude, "xlockmore.h"), + "-I", captureInclude, + "-I", buildDir, + "-I", join(sourceRoot, "hacks/glx"), + "-I", join(sourceRoot, "utils"), + captureSource, + join(sourceRoot, "hacks/glx/gltrackball.c"), + join(sourceRoot, "hacks/glx/trackball.c"), + join(sourceRoot, "hacks/glx/quaternion.c"), + join(sourceRoot, "utils/yarandom.c"), + join(buildDir, "colors.c"), + join(buildDir, "hsv.c"), + "-framework", "OpenGL", "-lm", "-o", temporary, + ], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`Unable to compile native cityflow.c capture:\n${result.stderr || result.stdout}`); + await chmod(temporary, 0o755); + await rename(temporary, binary); + await writeJson(join(buildDir, "native-binding.json"), { + schema: "csscityflow-native-build-binding@1", + compiler, + sdk, + executableSha256: sha256(await readFile(binary)), + inputs: await Promise.all(inputs.map(async (path) => ({ path, sha256: sha256(await readFile(path)) }))), + }); + return binary; +} + +async function captureNativeRun(binary, runRoot) { + const framesDir = join(runRoot, "frames"); + await mkdir(framesDir, { recursive: true }); + const result = spawnSync(binary, [ + framesDir, + String(CSSCITYFLOW_SEED), + String(CITYFLOW_SOURCE.boxCount), + String(width), + String(height), + String(frameCount), + ], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`Native cityflow.c capture failed:\n${result.stderr || result.stdout}`); + const frames = (await readdir(framesDir)).filter((name) => /^frame_\d{4}\.ppm$/u.test(name)); + if (frames.length !== frameCount) throw new Error(`Native capture wrote ${frames.length} of ${frameCount} frames`); + return framesDir; +} + +async function captureBrowserRun(browser, url, runRoot) { + const framesDir = join(runRoot, "frames"); + await mkdir(framesDir, { recursive: true }); + const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 }); + const errors = []; + page.on("pageerror", (error) => errors.push(error.stack || error.message)); + page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); }); + await page.route("**/favicon.ico", (route) => route.fulfill({ status: 204, body: "" })); + try { + await page.goto(url, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => globalThis.__csscityflow?.ready || globalThis.__csscityflow?.errors?.length, null, { timeout: 30_000 }); + const initial = await page.evaluate(async () => { + document.querySelector(".site-header")?.remove(); + const roots = [...document.querySelectorAll(".csscityflow-box")]; + const leaves = [...document.querySelectorAll(".csscityflow-box>b")]; + globalThis.__csscityflowOracleIdentity = { roots, leaves }; + globalThis.__csscityflow.player.pause(); + globalThis.__csscityflow.player.seekFrame(1); + await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame))); + return { + roots: roots.length, + leaves: leaves.length, + animations: roots.flatMap((element) => element.getAnimations()).length, + canvas: document.querySelectorAll("canvas").length, + sceneSvg: document.querySelectorAll(".polycss-camera svg").length, + }; + }); + if (errors.length || initial.roots !== CITYFLOW_SOURCE.boxCount || + initial.leaves !== CITYFLOW_SOURCE.boxCount * 3 || initial.animations !== 0 || + initial.canvas !== 0 || initial.sceneSvg !== 0) { + throw new Error(`Cityflow browser oracle is invalid: ${JSON.stringify({ initial, errors })}`); + } + for (let frame = 0; frame < frameCount; frame += 1) { + await page.evaluate(async ({ frameIndex }) => { + globalThis.__csscityflow.player.seekFrame(frameIndex); + await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame))); + }, { frameIndex: frame }); + await page.screenshot({ path: join(framesDir, frameName(frame)) }); + } + const audit = await page.evaluate(() => { + const expected = globalThis.__csscityflowOracleIdentity; + const roots = [...document.querySelectorAll(".csscityflow-box")]; + const leaves = [...document.querySelectorAll(".csscityflow-box>b")]; + return { + stableRootIdentity: roots.every((element, index) => element === expected.roots[index]), + stableLeafIdentity: leaves.every((element, index) => element === expected.leaves[index]), + rootCount: roots.length, + leafCount: leaves.length, + canvasCount: document.querySelectorAll("canvas").length, + sceneSvgCount: document.querySelectorAll(".polycss-camera svg").length, + }; + }); + if (!audit.stableRootIdentity || !audit.stableLeafIdentity || errors.length) { + throw new Error(`Cityflow browser DOM identity drifted: ${JSON.stringify({ audit, errors })}`); + } + await writeJson(join(runRoot, "browser-capture.json"), { + schema: "csscityflow-browser-frame-sequence@1", + url, + frameCount, + viewport: { width, height, deviceScaleFactor: 1 }, + captureMode: "paused-prepared-elapsed-player-source-frame-seek", + audit, + }); + return { framesDir, audit }; + } finally { + await page.close(); + } +} + +async function compareSequences({ expected, actual, out, label, thresholds, diffFrames }) { + const [mean, changed, channel] = thresholds; + const result = spawnSync(process.execPath, [ + oracleScript, + "compare", + "--expected", expected, + "--actual", actual, + "--out", out, + "--replace", + "--label", label, + "--mean-threshold", mean, + "--changed-threshold", changed, + "--channel-threshold", channel, + "--diff-frames", diffFrames, + "--max-frames", String(frameCount), + ], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(result.stderr || result.stdout || `Frame comparison ${label} failed`); + return JSON.parse(await readFile(join(out, `${label}.json`), "utf8")); +} + +async function writeTriptych({ nativeDir, browserDir, comparison, frame }) { + const previewDir = join(outputRoot, "preview"); + await mkdir(previewDir, { recursive: true }); + const nativePath = join(nativeDir, `frame_${String(frame).padStart(4, "0")}.ppm`); + const browserPath = join(browserDir, frameName(frame)); + const diff = comparison.diffs.find((entry) => entry.frame === frame); + if (!diff?.png) throw new Error(`Missing diff for frame ${frame}`); + const nativePreviewPath = join(previewDir, `native-frame-${String(frame).padStart(4, "0")}.png`); + const conversion = spawnSync("sips", ["-s", "format", "png", nativePath, "--out", nativePreviewPath], { + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + }); + if (conversion.status !== 0) throw new Error(conversion.stderr || "Unable to convert native Cityflow PPM preview"); + const labels = ["NATIVE cityflow.c", "BROWSER retained DOM", "ABSOLUTE DIFF"]; + const panels = await Promise.all([nativePreviewPath, browserPath, diff.png].map((path) => sharp(path).png().toBuffer())); + const labelSvg = Buffer.from(`${labels.map((label, index) => `${label} ยท frame ${frame}`).join("")}`); + const triptychPath = join(previewDir, `native-browser-diff-frame-${String(frame).padStart(4, "0")}.png`); + await sharp({ create: { width: width * 3, height: height + 40, channels: 3, background: "#000" } }) + .composite([ + { input: labelSvg, left: 0, top: 0 }, + ...panels.map((input, index) => ({ input, left: index * width, top: 40 })), + ]) + .png() + .toFile(triptychPath); + return { frame, triptychPath }; +} + +async function startBrowserServer() { + const configured = process.env.CSSCITYFLOW_ORACLE_URL; + if (configured) return { url: configured, process: { kill() {} } }; + const port = await freePort(); + let output = ""; + const child = spawn("pnpm", [ + "exec", "vite", + "--config", join(adapterRoot, "vite.config.mjs"), + "--host", "127.0.0.1", + "--port", String(port), + "--strictPort", + ], { cwd: repositoryRoot, stdio: ["ignore", "pipe", "pipe"] }); + child.stdout.on("data", (chunk) => { output += chunk.toString(); }); + child.stderr.on("data", (chunk) => { output += chunk.toString(); }); + await waitFor(() => output.includes("Local:") || output.includes(`127.0.0.1:${port}`), 20_000, () => { + if (child.exitCode !== null) throw new Error(`Cityflow oracle Vite exited early:\n${output}`); + }); + return { url: `http://127.0.0.1:${port}/`, process: child }; +} + +async function assertSourceIdentity() { + const revision = git("rev-parse", "HEAD"); + const primaryBytes = await readFile(join(sourceRoot, sourceLock.primary.path)); + const configBytes = await readFile(join(sourceRoot, sourceLock.config.path)); + if (revision !== sourceLock.revision || sha256(primaryBytes) !== sourceLock.primary.sha256 || + sha256(configBytes) !== sourceLock.config.sha256) { + throw new Error("Cityflow source identity does not match the pinned source lock"); + } +} + +function resolveSdk() { + const configured = process.env.CSSCITYFLOW_MACOS_SDK; + if (configured) return resolve(configured); + const result = spawnSync("xcrun", ["--sdk", "macosx", "--show-sdk-path"], { encoding: "utf8" }); + if (result.status !== 0) throw new Error(result.stderr || "Unable to locate macOS SDK"); + return result.stdout.trim(); +} + +function resolveRequiredSourceRoot() { + const configured = process.env.CSSCITYFLOW_SOURCE_ROOT; + if (!configured) throw new Error("Set CSSCITYFLOW_SOURCE_ROOT to the pinned XScreenSaver checkout"); + return resolve(configured); +} + +function readFrameCount() { + const value = Number(process.env.CSSCITYFLOW_VISUAL_ORACLE_FRAMES ?? 48); + if (!Number.isSafeInteger(value) || value < 1 || value > 252) { + throw new RangeError("Cityflow visual oracle frame count must be an integer from 1 through 252"); + } + return value; +} + +function git(...args) { + const result = spawnSync("git", ["-C", sourceRoot, ...args], { encoding: "utf8" }); + if (result.status !== 0) throw new Error(result.stderr || `git ${args.join(" ")} failed`); + return result.stdout.trim(); +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function frameName(index) { + return `frame_${String(index).padStart(4, "0")}.png`; +} + +async function writeJson(path, value) { + await mkdir(resolve(path, ".."), { recursive: true }); + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +} + +async function assertReadable(path, label) { + try { + await access(path, fsConstants.R_OK); + } catch { + throw new Error(`Missing ${label}: ${path}`); + } +} + +function freePort() { + return new Promise((resolvePort, reject) => { + const instance = createServer(); + instance.listen(0, "127.0.0.1", () => { + const address = instance.address(); + const port = typeof address === "object" && address ? address.port : 0; + instance.close(() => resolvePort(port)); + }); + instance.on("error", reject); + }); +} + +async function waitFor(predicate, timeoutMilliseconds, onPoll) { + const started = Date.now(); + while (Date.now() - started < timeoutMilliseconds) { + onPoll(); + if (predicate()) return; + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + throw new Error("Timed out waiting for Cityflow oracle server"); +} diff --git a/src/adapters/cityflow/tools/prepare-csscityflow.mjs b/src/adapters/cityflow/tools/prepare-csscityflow.mjs new file mode 100644 index 0000000..0153919 --- /dev/null +++ b/src/adapters/cityflow/tools/prepare-csscityflow.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildPolyMorphCatalog, buildPolyMorphPackage } from "@layoutit/polycss-morph"; +import { + buildCityflowMorphModel, + buildCityflowPreparedCss, + buildCityflowPreparedPlayback, +} from "../src/prepare/csscityflow/model.mjs"; +import { CITYFLOW_BANKS } from "../src/prepare/csscityflow/sourceModel.mjs"; + +const adapterRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repositoryRoot = resolve(adapterRoot, "..", "..", ".."); +const outputRoot = join(repositoryRoot, "build/generated/public/csscityflow"); +const stagingRoot = join(repositoryRoot, `build/generated/.csscityflow-${process.pid}`); +const sourceRoot = resolveRequiredSourceRoot(); + +await verifySourceLock(); +await rm(stagingRoot, { recursive: true, force: true }); +await mkdir(stagingRoot, { recursive: true }); +const packages = []; +for (const bankId of Object.keys(CITYFLOW_BANKS)) { + const { state, model } = buildCityflowMorphModel({ bankId }); + const built = await buildPolyMorphPackage(model); + const packageRoot = join(stagingRoot, model.identity.id); + await mkdir(packageRoot, { recursive: true }); + for (const [path, bytes] of built.files) await writeBytes(join(packageRoot, path), bytes); + await writeBytes(join(packageRoot, "manifest.json"), built.manifestBytes); + packages.push({ + manifest: built.manifest, + manifestPath: `${model.identity.id}/manifest.json`, + manifestSha256: built.manifestSha256, + }); + await writeFile(join(stagingRoot, `${model.identity.id}.css`), `${buildCityflowPreparedCss(state)}\n`); + await writeFile( + join(stagingRoot, `${model.identity.id}.playback.json`), + `${JSON.stringify(buildCityflowPreparedPlayback(state))}\n`, + ); +} +const catalog = await buildPolyMorphCatalog("cityflow", packages); +await writeBytes(join(stagingRoot, "catalog.json"), catalog.bytes); +await rm(outputRoot, { recursive: true, force: true }); +await mkdir(dirname(outputRoot), { recursive: true }); +await rename(stagingRoot, outputRoot); +console.log(JSON.stringify({ + status: "prepared", + outputRoot, + banks: packages.map(({ manifest }) => ({ id: manifest.identity.id, resources: manifest.resources.length })), +}, null, 2)); + +async function verifySourceLock() { + const head = (await readFile(join(sourceRoot, ".git/HEAD"), "utf8")).trim(); + const revision = head.startsWith("ref: ") + ? (await readFile(join(sourceRoot, ".git", head.slice(5)), "utf8")).trim() + : head; + if (revision !== CITYFLOW_BANKS.desktop.commit) throw new Error(`Cityflow source commit drifted: ${revision}`); + for (const [path, expected] of [ + [CITYFLOW_BANKS.desktop.primaryPath, CITYFLOW_BANKS.desktop.primarySha256], + [CITYFLOW_BANKS.desktop.configPath, CITYFLOW_BANKS.desktop.configSha256], + ]) { + const actual = createHash("sha256").update(await readFile(join(sourceRoot, path))).digest("hex"); + if (actual !== expected) throw new Error(`Cityflow source bytes drifted: ${path}`); + } +} + +function resolveRequiredSourceRoot() { + const configured = process.env.CSSCITYFLOW_SOURCE_ROOT; + if (!configured) throw new Error("Set CSSCITYFLOW_SOURCE_ROOT to the pinned XScreenSaver checkout"); + return resolve(configured); +} + +async function writeBytes(path, bytes) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, bytes); +} diff --git a/src/adapters/cityflow/tools/run-native-oracle.mjs b/src/adapters/cityflow/tools/run-native-oracle.mjs new file mode 100644 index 0000000..2476a8c --- /dev/null +++ b/src/adapters/cityflow/tools/run-native-oracle.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node +import { execFile } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { buildCityflowSourceState, cityflowFrameAt } from "../src/prepare/csscityflow/sourceModel.mjs"; + +const run = promisify(execFile); +const adapterRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repositoryRoot = resolve(adapterRoot, "..", "..", ".."); +const sourceRoot = resolveRequiredSourceRoot(); +const outputRoot = join(repositoryRoot, "build/oracles/cityflow/native-state"); +const binary = join(outputRoot, "capture-cityflow-state"); +await mkdir(outputRoot, { recursive: true }); +await run("cc", [ + "-std=c11", "-O2", + "-DHAVE_UNISTD_H", "-DGETTIMEOFDAY_TWO_ARGS", + `-I${join(sourceRoot, "utils")}`, + join(adapterRoot, "tools/native/capture-cityflow-state.c"), + join(sourceRoot, "utils/yarandom.c"), + "-lm", "-o", binary, +]); + +const checks = []; +for (const bankId of ["desktop"]) { + for (const tick of [0, 73, 251]) { + const state = buildCityflowSourceState({ bankId }); + const frame = cityflowFrameAt(state, tick); + const { stdout } = await run(binary, [String(state.seed), String(state.source.boxCount), String(tick)]); + const native = JSON.parse(stdout); + compareState(state, frame, native); + checks.push({ bankId, tick, boxCount: state.boxes.length, status: "exact-with-float-tolerance" }); + await writeFile(join(outputRoot, `${bankId}-tick-${tick}.json`), `${JSON.stringify(native, null, 2)}\n`); + } +} +const report = { schema: "csscityflow-native-state-oracle@1", sourceRoot, checks }; +await writeFile(join(outputRoot, "report.json"), `${JSON.stringify(report, null, 2)}\n`); +console.log(JSON.stringify(report, null, 2)); + +function resolveRequiredSourceRoot() { + const configured = process.env.CSSCITYFLOW_SOURCE_ROOT; + if (!configured) throw new Error("Set CSSCITYFLOW_SOURCE_ROOT for the native Cityflow oracle"); + return resolve(configured); +} + +function compareState(state, frame, native) { + const indices = [0, 1, 64, 128, 192, 255]; + for (const [sampleIndex, paletteIndex] of indices.entries()) { + for (let channel = 0; channel < 3; channel += 1) { + equal(native.palette[sampleIndex][channel], state.palette[paletteIndex][channel], `palette ${paletteIndex}:${channel}`); + } + } + for (let index = 0; index < 6; index += 1) { + close(native.waves[index][0], frame.wavePositions[index].xTheta, 1e-12, `wave ${index} x theta`); + close(native.waves[index][1], frame.wavePositions[index].yTheta, 1e-12, `wave ${index} y theta`); + equal(native.waves[index][2], frame.wavePositions[index].x, `wave ${index} x`); + equal(native.waves[index][3], frame.wavePositions[index].y, `wave ${index} y`); + } + for (let index = 0; index < native.boxes.length; index += 1) { + const actual = native.boxes[index]; + const box = state.boxes[index]; + const rendered = frame.boxes[index]; + equal(actual[0], box.sourceIndex, `box ${index} source index`); + for (const [offset, key] of [[1, "x"], [2, "y"], [3, "z"], [4, "cth"], [5, "sth"], [6, "width"], [7, "depth"]]) { + close(actual[offset], box[key], 2e-7, `box ${index} ${key}`); + } + equal(actual[8], box.sampleX, `box ${index} sample x`); + equal(actual[9], box.sampleY, `box ${index} sample y`); + close(actual[10], rendered.height, 2e-7, `box ${index} height`); + equal(actual[11], rendered.colorIndex, `box ${index} color`); + } +} + +function equal(actual, expected, label) { + if (actual !== expected) throw new Error(`${label}: native ${actual}, prepared ${expected}`); +} + +function close(actual, expected, tolerance, label) { + if (Math.abs(actual - expected) > tolerance) { + throw new Error(`${label}: native ${actual}, prepared ${expected}, tolerance ${tolerance}`); + } +} diff --git a/src/adapters/cityflow/tools/smoke-browser.mjs b/src/adapters/cityflow/tools/smoke-browser.mjs new file mode 100644 index 0000000..cdc84b2 --- /dev/null +++ b/src/adapters/cityflow/tools/smoke-browser.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { resolve } from "node:path"; +import { chromium } from "playwright"; + +const adapterRoot = resolve(import.meta.dirname, ".."); +const repositoryRoot = resolve(adapterRoot, "..", "..", ".."); +const outputRoot = resolve(repositoryRoot, "bench/results/csscityflow/smoke"); +const externalUrl = process.env.CSSCITYFLOW_SMOKE_URL; +const port = externalUrl ? null : await freePort(); +const route = externalUrl ?? `http://127.0.0.1:${port}/`; +let server = null; +let serverOutput = ""; +if (!externalUrl) { + server = spawn("pnpm", [ + "exec", "vite", "--config", resolve(adapterRoot, "vite.config.mjs"), + "--host", "127.0.0.1", "--port", String(port), "--strictPort", + ], { cwd: repositoryRoot, stdio: ["ignore", "pipe", "pipe"] }); + server.stdout.on("data", (chunk) => { serverOutput += chunk.toString(); }); + server.stderr.on("data", (chunk) => { serverOutput += chunk.toString(); }); +} + +let browser; +try { + await mkdir(outputRoot, { recursive: true }); + if (server) await waitFor(() => serverOutput.includes("Local:"), 20_000); + browser = await chromium.launch({ channel: "chrome", headless: true }); + const desktop = await capture({ profile: "desktop", width: 1280, height: 720, bankId: "desktop", boxes: 200, leaves: 600 }); + const mobile = await capture({ profile: "mobile", width: 390, height: 844, bankId: "desktop", boxes: 200, leaves: 600 }); + const report = { schema: "csscityflow-browser-smoke@1", route, desktop, mobile }; + await writeFile(resolve(outputRoot, "report.json"), `${JSON.stringify(report, null, 2)}\n`); + console.log(JSON.stringify(report, null, 2)); +} finally { + await browser?.close(); + server?.kill("SIGTERM"); +} + +async function capture({ profile, width, height, bankId, boxes, leaves }) { + const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 }); + await page.route("**/favicon.ico", (request) => request.fulfill({ status: 204 })); + const errors = []; + page.on("pageerror", (error) => errors.push(error.stack || error.message)); + page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); }); + await page.goto(route, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => document.body.classList.contains("ready") || + document.body.classList.contains("error"), null, { timeout: 30_000 }); + const before = await page.evaluate(() => { + const state = globalThis.__csscityflow; + const shapes = [...document.querySelectorAll(".csscityflow-box")]; + let stable = true; + try { state?.mounted?.assertStableDomIdentity(); } catch { stable = false; } + return { + status: document.body.className, + ready: state?.ready, + bankId: state?.bankId, + errors: state?.errors ?? [], + stable, + shapeCount: shapes.length, + leafCount: document.querySelectorAll(".csscityflow-box > b").length, + canvasCount: document.querySelectorAll("canvas").length, + svgSceneCount: document.querySelectorAll(".polycss-camera svg").length, + animationName: getComputedStyle(shapes[0]).animationName, + transforms: shapes.slice(0, 40).map((shape) => getComputedStyle(shape).transform), + atlasConstructions: state?.mounted?.stats?.atlasConstructions, + atlasRedraws: state?.mounted?.stats?.atlasRedraws ?? 0, + player: state?.player?.stats(), + }; + }); + await page.waitForTimeout(250); + const after = await page.evaluate(() => { + const shapes = [...document.querySelectorAll(".csscityflow-box")]; + return { + transforms: shapes.slice(0, 40).map((shape) => getComputedStyle(shape).transform), + player: globalThis.__csscityflow?.player?.stats(), + }; + }); + const changedTransformCount = before.transforms.reduce((sum, transform, index) => + sum + Number(transform !== after.transforms[index]), 0); + if (errors.length || before.errors.length || before.status !== "ready" || !before.ready || + before.bankId !== bankId || !before.stable || before.shapeCount !== boxes || + before.leafCount !== leaves || before.canvasCount !== 0 || before.svgSceneCount !== 0 || + before.animationName !== "none" || before.player?.catchUpPolicy !== "elapsed" || + after.player?.timerCallbackCount < 1 || after.player?.animationFrameCallbackCount < 1 || + changedTransformCount === 0 || before.atlasConstructions !== 0 || before.atlasRedraws !== 0) { + throw new Error(`Cityflow ${profile} browser smoke failed: ${JSON.stringify({ before, after, changedTransformCount, errors })}`); + } + const screenshotPath = resolve(outputRoot, `${profile}.png`); + await page.screenshot({ path: screenshotPath }); + await page.close(); + return Object.freeze({ width, height, boxes, leaves, changedTransformCount, screenshotPath }); +} + +async function freePort() { + return new Promise((resolvePort, reject) => { + const socket = createServer(); + socket.unref(); + socket.on("error", reject); + socket.listen(0, "127.0.0.1", () => { + const address = socket.address(); + socket.close(() => resolvePort(address.port)); + }); + }); +} + +async function waitFor(predicate, timeoutMilliseconds) { + const started = Date.now(); + while (!predicate()) { + if (Date.now() - started > timeoutMilliseconds) throw new Error(`Cityflow server did not start:\n${serverOutput}`); + await new Promise((resolveDelay) => setTimeout(resolveDelay, 25)); + } +} diff --git a/src/adapters/cityflow/vite.config.mjs b/src/adapters/cityflow/vite.config.mjs new file mode 100644 index 0000000..ade8c62 --- /dev/null +++ b/src/adapters/cityflow/vite.config.mjs @@ -0,0 +1,18 @@ +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; + +const adapterRoot = fileURLToPath(new URL(".", import.meta.url)); +const repositoryRoot = resolve(adapterRoot, "..", "..", ".."); + +export default defineConfig({ + root: adapterRoot, + publicDir: resolve(repositoryRoot, "build/generated/public"), + server: { host: "127.0.0.1" }, + preview: { host: "127.0.0.1" }, + build: { + target: "es2022", + outDir: resolve(repositoryRoot, "dist/cityflow"), + emptyOutDir: true, + }, +});