From a16e99063be801a7d75347e1ea9fc43091256f57 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:47:02 +0000 Subject: [PATCH 1/6] Chain scene parity test: one recorded feed through the home fold, /live and the app's Inspect view, three frames each pixel-equal, in the gate (7 October 2026, 15:5x UK) tools/scene/parity.mjs serves site/ (tools/site-serve.mjs) and the app's UI (tools/ui-mock/server.mjs) on a build box, answers every page's /api/live from scene/fixtures/live-2026-10-07.json, freezes the clock at the fixture's instant (Date, timers, requestAnimationFrame) and reads each scene canvas at 900 by 420 px at the first push and the next two polls: the read is the push's own synchronous paint, so a frame depends on the fake time alone, never on how long a fetch took. Known-failed first: the app with --included moved by one unit must differ from /live at every instant (it does: 1,549 / 1,282 / 1,157 px). Then home fold = /live, app Inspect = /live, app with this machine's key = /live with the same key (the overlay is the same picture), and the app's overlay frame differs from its base frame (the overlay is drawn). The compact card is rendered and reported, not compared. A RED line names the differing pixels and their box. tools/scene/parity-remote.sh carries the files to the box (~/.config/igneum/build-server-2 by default) under this lane's prefix and runs it there; a plain CI runner with no box and no Playwright prints a skip line. One new line in tools/ci/pre-push.sh. First run on build-2 at 15:4x UK, release-0.3.21 tree plus scene-parity: every comparison equal at T+0, T+2 and T+4 s. Found on the way and fixed in the harness, not the renderer: lane order keeps the history of earlier layouts (the scene must be at the compared size before its first layout); the app's recorded mock card is a real devnet key (cleared for the base case). Co-Authored-By: Claude Fable 5.1 --- tools/ci/pre-push.sh | 1 + tools/scene/parity-remote.sh | 36 +++++++ tools/scene/parity.mjs | 184 +++++++++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100755 tools/scene/parity-remote.sh create mode 100644 tools/scene/parity.mjs diff --git a/tools/ci/pre-push.sh b/tools/ci/pre-push.sh index 1d05e2a69..bcebd3b70 100755 --- a/tools/ci/pre-push.sh +++ b/tools/ci/pre-push.sh @@ -101,6 +101,7 @@ tree_checks() { run "chain scene: the site's and the app's copies are scene/ byte for byte, the palette tokens live once (self-test, then the tree)" bash -c 'node tools/scene/sync.mjs --self-test && node tools/scene/sync.mjs --check' run "chain scene: a push paints with the document hidden and no animation frame (the blank /live of 7 Oct 2026; known-failed first)" node tools/scene/paint-test.cjs run "chain scene: the live feed contract (the recorded reply validates; a rewritten miner, a float now, a stray key refused)" node --test tools/scene/feed-contract.test.mjs + run "chain scene parity: one recorded feed through the home fold, /live and the app's Inspect view on build-2, three frames each pixel-equal apart from the app's own-key overlay (a changed token fails first; skipped with no box and no Playwright)" bash tools/scene/parity-remote.sh run "explorer, emission and public stats unit tests" node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/api/public-stats.test.mjs run "ship tool self-test" node tools/ship-app.mjs --self-test run "relay unit tests" node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs relay/test/ember.test.mjs diff --git a/tools/scene/parity-remote.sh b/tools/scene/parity-remote.sh new file mode 100755 index 000000000..b6ecd75a0 --- /dev/null +++ b/tools/scene/parity-remote.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# The scene parity test on a build box (the Mac runs no headless browser: standing rule, 7 October 2026). Carries the files the +# test needs to the box under this lane's prefix, runs tools/scene/parity.mjs there and prints its report; the box's own +# Playwright build and Chromium (root installed the libraries on 7 October 2026, 14:5x UK) do the rendering. +# +# tools/scene/parity-remote.sh [--box N] [--out ] default box 2; --out fetches the frames and the report back +# +# Where it runs: a box named by ~/.config/igneum/build-server[-N] (build@); on a machine with no box file but a local +# Playwright (a CI runner that is itself a box) it runs parity.mjs in place; with neither it prints a skip line and exits 0, +# so a plain GitHub runner stays green while every box-side and Mac-side gate renders for real. +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" || exit 1 +BOX=2; OUT="" +while [ $# -gt 0 ]; do case "$1" in --box) BOX="$2"; shift 2 ;; --out) OUT="$2"; shift 2 ;; *) echo "unknown argument $1" >&2; exit 2 ;; esac; done +HOSTFILE="$HOME/.config/igneum/build-server"; [ "$BOX" = 1 ] || HOSTFILE="$HOSTFILE-$BOX" +if [ ! -s "$HOSTFILE" ]; then + if [ -n "${PLAYWRIGHT_CORE:-}" ] || [ -f /srv/builds/_bin/overlap/node_modules/playwright-core/package.json ]; then exec node tools/scene/parity.mjs ${OUT:+--out "$OUT"}; fi + echo "scene-parity: skipped, no build box file at $HOSTFILE and no Playwright here"; exit 0 +fi +HOST="$(cat "$HOSTFILE")"; KEY="$HOME/.ssh/igneum_ed25519" +SSH=(ssh -i "$KEY" -o BatchMode=yes -o ConnectTimeout=10 "$HOST") +WT="$(basename "$(pwd)")"; DEST="/srv/builds/scene-parity/$WT" +"${SSH[@]}" "mkdir -p $DEST/site $DEST/tools/ui-mock $DEST/tools/scene $DEST/scene $DEST/app/igneum-app/ui" || { echo "scene-parity: box $HOST unreachable" >&2; exit 1; } +RS=(rsync -a --delete -e "ssh -i $KEY -o BatchMode=yes") +"${RS[@]}" --exclude img --exclude verify --exclude '*.png' --exclude '*.ico' site/ "$HOST:$DEST/site/" +"${RS[@]}" scene/ "$HOST:$DEST/scene/" +"${RS[@]}" tools/scene/ "$HOST:$DEST/tools/scene/" +"${RS[@]}" tools/ui-mock/ "$HOST:$DEST/tools/ui-mock/" +"${RS[@]}" tools/site-serve.mjs "$HOST:$DEST/tools/site-serve.mjs" +if [ -f app/igneum-app/ui/live-dag.js ]; then "${RS[@]}" --exclude '*.woff2' app/igneum-app/ui/ "$HOST:$DEST/app/igneum-app/ui/"; else "${SSH[@]}" "rm -rf $DEST/app"; fi +[ -f brand/igneum-coin-1024.png ] && "${SSH[@]}" "mkdir -p $DEST/brand" && "${RS[@]}" brand/igneum-coin-1024.png "$HOST:$DEST/brand/igneum-coin-1024.png" +# the copied-sources rule: re-stamped on the far side before anything runs (touch), so no stale mtime decides anything there +"${SSH[@]}" "cd $DEST && find . -type f -exec touch {} + && git init -q . 2>/dev/null; [ -f /srv/builds/_bin/overlap/env.sh ] && . /srv/builds/_bin/overlap/env.sh; cd $DEST && timeout 240 nice -n 10 node tools/scene/parity.mjs --out $DEST/_out" +RC=$? +if [ -n "$OUT" ]; then mkdir -p "$OUT"; rsync -a -e "ssh -i $KEY -o BatchMode=yes" "$HOST:$DEST/_out/" "$OUT/" 2>/dev/null; fi +exit $RC diff --git a/tools/scene/parity.mjs b/tools/scene/parity.mjs new file mode 100644 index 000000000..6c8a1d13c --- /dev/null +++ b/tools/scene/parity.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +// Scene parity: the same recorded feed through the site's home fold, the site's /live and the miner app's Inspect view, three +// frames each at the same three instants of a fake clock, compared pixel for pixel. Runs on a build box (tools/scene/parity-remote.sh +// carries the tree there and runs this; never a headless browser on the Mac). +// +// node tools/scene/parity.mjs [--out ] the report on stdout, one line per comparison; exit 1 on any difference +// +// What it does, in order (the known-failed case first, as the watcher-trust rule asks): +// 1. serves site/ through tools/site-serve.mjs and app/igneum-app/ui through tools/ui-mock/server.mjs on private ports; +// every page's /api/live is answered from scene/fixtures/live-2026-10-07.json by a Playwright route, the clock is frozen +// at the fixture's `now` (Date, timers, requestAnimationFrame), the viewport is 1280 by 900 at DPR 1, dark; +// 2. KNOWN-FAILED: the app page with one palette token changed (--included) must differ from /live at every instant; +// 3. base: no own key anywhere; home fold, /live and the app's Inspect scene byte-equal at T, T+2 s, T+5 s; +// 4. overlay: the same vote key named on every surface (localStorage on the site, the mock's card ids on the app); the +// three surfaces still byte-equal, and the app's overlay frame differs from its base frame (the overlay is drawn); +// 5. the app's compact card (its own mode, no site counterpart) is rendered once and reported, not compared. +// Every scene canvas is forced to 900 by 420 CSS px so the comparison is of the renderer, the tokens, the options and the +// feed, not of the box the page gives the scene. A surface whose files are absent (master carries no app UI scene) is +// reported as skipped and the remaining surfaces are compared. +import { createRequire } from 'node:module'; +import { spawn } from 'node:child_process'; +import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; +import { homedir } from 'node:os'; + +const here = dirname(fileURLToPath(import.meta.url)), repo = join(here, '..', '..'); +const out = process.argv.includes('--out') ? process.argv[process.argv.indexOf('--out') + 1] : ''; +if (out) mkdirSync(out, { recursive: true }); +const PW = [process.env.PLAYWRIGHT_CORE, '/srv/builds/_bin/overlap/node_modules/playwright-core/package.json', '/srv/builds/scene-parity/pw/node_modules/playwright-core/package.json', join(homedir(), '.cache/igneum-pw/node_modules/playwright-core/package.json')].filter(Boolean).find(p => existsSync(p)); +const CHROME = [process.env.PLAYWRIGHT_CHROMIUM, join(homedir(), '.cache/ms-playwright/chromium_headless_shell-1194/chrome-linux/headless_shell'), join(homedir(), '.cache/ms-playwright/chromium-1194/chrome-linux/chrome')].filter(Boolean).find(p => existsSync(p)); +if (!PW || !CHROME) { console.log('scene-parity: skipped, no Playwright Chromium here (PLAYWRIGHT_CORE, PLAYWRIGHT_CHROMIUM)'); process.exit(0); } +const { chromium } = createRequire(PW)('playwright-core'); + +const fixture = JSON.parse(readFileSync(join(repo, 'scene/fixtures/live-2026-10-07.json'), 'utf8')); +const T0 = new Date(fixture.now).getTime(); +const KEY = fixture.miners[2].id; // a real key of the recording with a few blocks: the overlay case names it on every surface +const SIZE = { w: 900, h: 420 }, INSTANTS = [0, 2000, 4000]; // the first push and the next two polls (every surface polls every 2 s) +const hasApp = existsSync(join(repo, 'app/igneum-app/ui/live-dag.js')) && existsSync(join(repo, 'tools/ui-mock/server.mjs')); +const problems = [], lines = []; +const say = s => { lines.push(s); console.log(s); }; + +// ---- servers ---- +function serve(cmd, args, env, ready) { + return new Promise((resolve, reject) => { + const p = spawn(process.execPath, [cmd, ...args], { env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'] }); + let log = ''; const t = setTimeout(() => reject(new Error('server did not start: ' + cmd + '\n' + log)), 15000); + const probe = async () => { try { const r = await fetch(ready); if (r.ok || r.status === 404) { clearTimeout(t); resolve(p); return; } } catch { } setTimeout(probe, 200); }; + p.stdout.on('data', d => { log += d; }); p.stderr.on('data', d => { log += d; }); + p.on('exit', c => { clearTimeout(t); reject(new Error(cmd + ' exited ' + c + '\n' + log)); }); + setTimeout(probe, 300); + }); +} +const port = base => base + Math.floor(Math.random() * 200); +const sitePort = port(4600), appPort = port(4900); +const servers = []; +servers.push(await serve(join(repo, 'tools/site-serve.mjs'), [], { PORT: String(sitePort), DATABASE_URL: 'postgres://none' }, `http://127.0.0.1:${sitePort}/live`)); +if (hasApp) servers.push(await serve(join(repo, 'tools/ui-mock/server.mjs'), [String(appPort)], {}, `http://127.0.0.1:${appPort}/t/mock/api/state`)); +const stop = () => { for (const s of servers) try { s.kill(); } catch { } }; +process.on('exit', stop); + +// ---- the browser ---- +const browser = await chromium.launch({ executablePath: CHROME, args: ['--renderer-process-limit=1', '--font-render-hinting=none', '--disable-lcd-text'] }); +// the mount shim: every mounted scene is listed with a push counter, so a capture can wait for a push to have landed +const capture = () => { let real; Object.defineProperty(window, 'IgneumDag', { configurable: true, get() { return real; }, set(v) { real = Object.assign({}, v, { mount(c, o) { const api = v.mount(c, o); const entry = { canvas: c, api, opts: o, pushes: 0 }; const push = api.push; api.push = function (d) { const ok = push(d); if (ok) entry.pushes++; return ok; }; (window.__dags = window.__dags || []).push(entry); return api; } }); } }); }; + +/** One surface: opens the page with the fixture as its feed and the clock frozen at T0; returns frame hashes at the instants. */ +async function surface(name, url, { ownKey, mutate, selectCanvas, prepare } = {}) { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1, colorScheme: 'dark', reducedMotion: 'no-preference' }); + const page = await ctx.newPage(); + const errors = []; page.on('pageerror', e => errors.push(String(e.message))); + await ctx.clock.install({ time: T0 }); + await ctx.clock.pauseAt(T0 + 1); // frozen: only runFor moves it, so every surface is read at the same three instants + await page.addInitScript(capture); + // the scene's box before any script runs (lane order keeps the history of earlier layouts, so the first layout must already + // be at the compared size): a style appended the moment exists; no border or padding inside the canvas box + await page.addInitScript(({ sel, w, h, extra }) => { const css = sel + '{width:' + w + 'px!important;height:' + h + 'px!important;max-width:none!important;border:0!important;padding:0!important;box-sizing:content-box!important}' + extra; const add = () => { if (!document.head) return false; const st = document.createElement('style'); st.textContent = css; document.head.appendChild(st); return true; }; if (!add()) new MutationObserver((m, o) => { if (add()) o.disconnect(); }).observe(document, { childList: true, subtree: true }); }, { sel: selectCanvas, w: SIZE.w, h: SIZE.h, extra: mutate || '' }); + if (ownKey) await page.addInitScript(k => { try { localStorage.setItem('igneum-my-key', k); localStorage.setItem('igneum-theme', 'dark'); } catch { } }, ownKey); + else await page.addInitScript(() => { try { localStorage.removeItem('igneum-my-key'); localStorage.setItem('igneum-theme', 'dark'); } catch { } }); + await page.route('**/api/live*', r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(fixture) })); + // the app's own keys come from its cards: none in the base case (the mock's recorded card is a real devnet key that is in the + // fixture), exactly the named key in the overlay case + if (name.startsWith('app')) await page.route('**/api/state*', async r => { const res = await r.fetch(); const s = await res.json(); if (s.mining && s.mining.cards) s.mining.cards.forEach((c, i) => { c.ids = ownKey && i === 0 ? [ownKey] : []; }); r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(s) }); }); + await page.goto(url, { waitUntil: 'load' }); + // the mount, then the first push (the page's own poll runs at load; the fetch resolves in real time) + if (prepare) await prepare(page); + await page.waitForFunction(sel => !!(window.__dags || []).find(x => x.canvas.matches(sel)), selectCanvas, { timeout: 15000 }).catch(() => { }); + // the canvas in view, so the IntersectionObserver reports it and the motion loop runs on every surface alike (a canvas below + // the fold paints on push alone since 2.0.3, which is right for a viewer but not a comparison of the loop) + await page.evaluate(sel => { const c = document.querySelector(sel); if (c) c.scrollIntoView({ block: 'center' }); }, selectCanvas); + await page.waitForTimeout(200); + // the capture protocol, the same on every surface: advance the frozen clock exactly to the next poll, wait (in real time) + // for that push to land, read the canvas as the push's own synchronous paint left it. No animation frame runs between the + // push and the read, so the frame depends on the fake time alone, never on how long the fetch took. + const pushes = () => page.evaluate(sel => { const d = (window.__dags || []).find(x => x.canvas.matches(sel)); return d ? d.pushes : -1; }, selectCanvas); + const frames = []; + let at = 0, want = 1; + for (const ms of INSTANTS) { + if (ms > at) { await ctx.clock.runFor(ms - at); at = ms; } + const t0 = Date.now(); while (Date.now() - t0 < 8000 && (await pushes()) < want) { await page.waitForTimeout(50); if ((await pushes()) < want && Date.now() - t0 > 1500) { await ctx.clock.runFor(1); at += 1; } } + if ((await pushes()) < want) say(' push ' + want + ' never landed on ' + name + ' (' + (await pushes()) + ' so far)'); + want++; + const f = await page.evaluate(sel => { + const d = (window.__dags || []).find(x => x.canvas.matches(sel)); if (!d) return { error: 'no scene on ' + sel }; + const c = d.canvas, g = c.getContext('2d'), st = d.api.getStats(); + const px = g.getImageData(0, 0, c.width, c.height).data; let h = 0, lit = 0; + for (let i = 0; i < px.length; i += 4) { if (px[i] + px[i + 1] + px[i + 2] > 90) lit++; } + return { w: c.width, h: c.height, lit, blocks: st.blocks, lanes: st.lanes, window: st.window, narrow: st.narrow, state: st.state, frames: st.frames, png: c.toDataURL('image/png') }; + }, selectCanvas); + if (f.error) { frames.push(f); continue; } + f.hash = createHash('sha256').update(f.png).digest('hex').slice(0, 16); + if (out) writeFileSync(join(out, `${name}-${ms}.png`), Buffer.from(f.png.split(',')[1], 'base64')); + f.pngData = f.png; delete f.png; frames.push(f); + } + await ctx.close(); + return { name, frames, errors }; +} +const describe = s => s.frames.map((f, i) => f.error ? f.error : `T+${INSTANTS[i] / 1000}s ${f.hash} ${f.w}x${f.h} lit ${f.lit} blocks ${f.blocks} lanes ${f.lanes} win ${f.window}${f.narrow ? ' narrow' : ''} ${f.state}`).join('; '); +async function compare(label, a, b, mustDiffer) { + const diffs = []; + for (let i = 0; i < INSTANTS.length; i++) { + const fa = a.frames[i], fb = b.frames[i]; + if (fa.error || fb.error) { diffs.push('absent'); continue; } + if (fa.hash === fb.hash) { diffs.push('equal'); continue; } + const d = (fa.w === fb.w && fa.h === fb.h) ? await pixelDiff(fa.pngData, fb.pngData) : { n: -1 }; + diffs.push('DIFFERS' + (d.n >= 0 ? ' (' + d.n + ' of ' + d.total + ' px, box ' + d.box.join(',') + ')' : ' (sizes differ)')); + } + const bad = mustDiffer ? diffs.some(d => !d.startsWith('DIFFERS')) : diffs.some(d => d !== 'equal'); + say(` ${bad ? 'RED ' : 'ok '} ${label}: ${diffs.map((d, i) => `T+${INSTANTS[i] / 1000}s ${d}`).join(', ')}`); + if (bad) problems.push(label); +} +const dims = (a, b) => a.frames[0].w === b.frames[0].w && a.frames[0].h === b.frames[0].h; +async function pixelDiff(aPng, bPng) { + const ctx = await browser.newContext(); const page = await ctx.newPage(); + const r = await page.evaluate(async ([a, b]) => { + const load = src => new Promise(res => { const i = new Image(); i.onload = () => res(i); i.src = src; }); + const [ia, ib] = await Promise.all([load(a), load(b)]); + const c = document.createElement('canvas'); c.width = ia.width; c.height = ia.height; const g = c.getContext('2d'); + g.drawImage(ia, 0, 0); const pa = g.getImageData(0, 0, c.width, c.height).data; g.clearRect(0, 0, c.width, c.height); g.drawImage(ib, 0, 0); const pb = g.getImageData(0, 0, c.width, c.height).data; + let n = 0, x0 = 1e9, y0 = 1e9, x1 = -1, y1 = -1; + for (let i = 0; i < pa.length; i += 4) { if (pa[i] !== pb[i] || pa[i + 1] !== pb[i + 1] || pa[i + 2] !== pb[i + 2] || pa[i + 3] !== pb[i + 3]) { n++; const p = i / 4, x = p % c.width, y = (p / c.width) | 0; if (x < x0) x0 = x; if (y < y0) y0 = y; if (x > x1) x1 = x; if (y > y1) y1 = y; } } + return { n, box: n ? [x0, y0, x1 - x0 + 1, y1 - y0 + 1] : null, total: c.width * c.height }; + }, [aPng, bPng]); + await ctx.close(); return r; +} + +const SITE_LIVE = `http://127.0.0.1:${sitePort}/live`, SITE_HOME = `http://127.0.0.1:${sitePort}/`, APP = `http://127.0.0.1:${appPort}/t/mock/?scenario=live&theme=dark`; +const openInspect = async page => { await page.waitForSelector('#dag-inspect', { timeout: 15000 }); await page.waitForFunction(() => (window.__dags || []).length > 0, null, { timeout: 15000 }).catch(() => { }); await page.waitForTimeout(300); await page.click('#dag-inspect'); await page.waitForTimeout(300); }; +try { + say(`scene-parity: fixture ${fixture.now}, ${fixture.blocks.length} blocks, key ${KEY} for the overlay case, canvases ${SIZE.w}x${SIZE.h}, instants ${INSTANTS.join('/')} ms`); + const live = await surface('live', SITE_LIVE, { selectCanvas: '#dag' }); + say(' /live base: ' + describe(live)); + const home = await surface('home', SITE_HOME, { selectCanvas: '#dag' }); + say(' home fold base: ' + describe(home)); + for (const s of [live, home]) for (const e of s.errors) say(' page error on ' + s.name + ': ' + e); + if (live.frames.some(f => !f.lit)) problems.push('/live drew nothing'); + await compare('home fold = /live (same renderer, same feed, same options)', home, live, false); + if (hasApp) { + // known-failed first: one token changed on the app side must fail + const mutated = await surface('app-mutated', APP, { selectCanvas: '#dag-full', prepare: openInspect, mutate: ':root{--included:#3B7DD9!important}' }); + say(' app Inspect with --included changed by one unit: ' + describe(mutated)); + await compare('KNOWN-FAILED: app with a changed palette token differs from /live', mutated, live, true); + const app = await surface('app', APP, { selectCanvas: '#dag-full', prepare: openInspect }); + say(' app Inspect base: ' + describe(app)); + for (const e of app.errors) say(' page error on app: ' + e); + if (!dims(app, live)) problems.push('canvas buffers differ in size'); + await compare('app Inspect = /live', app, live, false); + // the overlay: the same key on every surface + const liveK = await surface('live-key', SITE_LIVE, { selectCanvas: '#dag', ownKey: KEY }); + const appK = await surface('app-key', APP, { selectCanvas: '#dag-full', prepare: openInspect, ownKey: KEY }); + say(' /live with the key: ' + describe(liveK)); + say(' app Inspect with the key: ' + describe(appK)); + await compare('app Inspect with this machine\'s key = /live with the same key (the overlay is the same picture)', appK, liveK, false); + await compare('the app\'s overlay frame differs from its base frame (the overlay is drawn)', appK, app, true); + const compact = await surface('app-compact', APP, { selectCanvas: '#dag-live', prepare: async page => { await page.waitForFunction(() => (window.__dags || []).length > 0, null, { timeout: 15000 }).catch(() => { }); } }); + say(' app compact card (its own mode, reported, not compared): ' + describe(compact)); + } else { + say(' app surface: skipped (no app/igneum-app/ui/live-dag.js in this tree; the app lives on the release branches)'); + } +} finally { await browser.close(); stop(); } +say(problems.length ? `scene-parity: RED, ${problems.length} problem(s): ${problems.join('; ')}` : 'scene-parity: ok'); +if (out) writeFileSync(join(out, 'report.txt'), lines.join('\n') + '\n'); +process.exit(problems.length ? 1 : 0); From 3cca93b157ea0c449ece680b71dc2e545cdfb4e8 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:55:27 +0000 Subject: [PATCH 2/6] Counter ASIC 3.0 status: the 64-seed gate on sub-version 2 heading to FAIL (8 of 39 over 1.2x, worst 4.82x), the earlier none-over line withdrawn Co-Authored-By: Claude Fable 5.1 --- docs/plans/counter-asic-3-status.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/counter-asic-3-status.md b/docs/plans/counter-asic-3-status.md index 2ac31041a..7a85c0ccc 100644 --- a/docs/plans/counter-asic-3-status.md +++ b/docs/plans/counter-asic-3-status.md @@ -397,7 +397,7 @@ Self-test PASS on each (FNV-1a 448274a57f508cbc); rates 120 to 142 MH/s with the | v4-era-4 | dd8fdf6ff4f59eed | 115.2 | yes | | v4-era-5 | 8bf40f5cb858d835 | 117.1 | yes | -Self-test PASS on every pack (cache FNV 448274a57f508cbc); both rows in the ledger entry 43c5bf5b. G1 FOR SUB-VERSION 2 IS COMPLETE on three platforms (Metal, Linux CUDA, Windows CUDA), nothing owed. Per tier: the 5090 rate on sub-version 2 is the same band as sub-version 1 (115 to 130 MH/s), so a miner's rate does not move with the class amendment. The lines left for byte 7 by 19:00Z: the attack-pass lane's two gates and 10^6 count on 8bdcbdd8, the node lane's re-pin and the pairing. PC 2 DOWN AGAIN (main, 16:5x UK): the project lead takes PC 2 down for cable work (PC 1 back but his desk); both PCs out of the sweep's waves, each updates on its poller on return; no PC job to PC 1; the Windows G1 completed before the outage, nothing reruns. 0.3.21's SECOND GATE LINE on 55768f88 (sha256 279b1b690e854fc9): the ten-minute mixed-version gate beside the 5899f603 pair, 13:37:40Z to 13:47:52Z, SUMMARY PASS (one digest b0afb2ee on five nodes; 223 new and 381 old blocks accepted by the old hub, 0 rejected; counts equal at 319, 486 and 604 through both clean joins and the restart step at 13:45:22Z; no panic); the node lane's two lines on 0.3.21's first candidate complete, in plan 6.9 on ca3-v4-node; the fleet's set on it (the bare-child 12 GB line, the wipe, the kept read, the cases) is the fleet's. 0.3.21's FIRST GATE LINE on 55768f88 (sha256 279b1b690e854fc9, the string read back; pairing igneum-pow 8c728ca3 at byte 5): the digest gate 13:35:41Z to 13:37:19Z SUMMARY PASS (a89be8a7 on both binaries with the peers; db9a85f9 refused, no peer; the live file's eada4bda unmoved); the ten-minute mixed-version gate from 13:37:40Z, line about 13:50Z. The 0.3.21 order as the shipper sent it: 55768f88; f067f7c1 and 70e4601e; b0444f51; 6eb21fc9; db28d331; then the re-pin from 8bdcbdd8 on the coordinator's word; suites between, the digest read after every one; the mirror's release-0.3.20-node back at the pin c4459193, release-0.3.21-node open at 55768f88. THE LATE-JOIN COMMIT (N9's second half, the node lane): 70e4601e on the box mirror as branch proof-hold-fix, from c4459193, two files (igneum/exec/src/proving.rs, protocol/flows/src/v10/proving.rs); the gap was the fetch side on the joiner (the served record ran the native check against the joiner's trailing exec state before anything was stored, the check refused it, the proof was never held, the body rule read "not held" for 20 s and failed the IBD); the fix holds the proof by hash before the checks (the pool entry still needs them) and the serve side says when it holds fewer than asked; the exec suite 32 passed at 13:26Z with the known-failed shape first, the flows check green 13:28Z, igneumd on build-1 at the 0321 worktree path built 13:32Z, sha256 17649eeb2f7d1290, string read back; with the testnet lane (the resume form, B alone); it joins the 0.3.21 staging as its own commit. THE WIPE CANARY ON c19-1, c4459193 (sha 45be9b02d1b002f5, string read back): FORM END rc 0 at 13:50:53Z. Wipe synced 13:35:50Z (57 minutes, inside the 98-minute class); mining 13:36:00Z to 13:47:07Z, 66 mined, 66 accepted, 0 rejected, isSynced true at the tip throughout; the hub holds 41 of its blocks in its last 700 with 0 rejects (13:47:09Z); the restart on its kept datadir at 13:47:15Z: the old process stopped at once (the new process's first lock line seven seconds after the marker; the watchdog held nothing, the b7cc37e7 fault closed), synced again at 13:48:39Z after 84 s, 109 templates read with max 3,432 ms and 0 timeouts; the kept read on pool-1's 0.3.17 copy on the same pod passed at 13:38Z (the rewrite line once, a clean second start). The pin's set on c4459193: the digest gate PASS, the mixed-version gate PASS, the wipe canary PASS, the kept read PASS, the restart PASS, the 12 GB line proves and verifies (paid is a race, not a gate); CASES END from c20-1 (about 14:50Z) is the last pin line. THE INTEROP FACT stands from the void run: the 5899f603 hub accepted 235 object-byte-5 blocks from the 8097d600 node with 0 rejected, one digest on all five nodes on the live sixteen-field file. The gates: the digest test and the kaspa-pow vector test (the amended devnet epoch-0 id 1a4230699a6b9c60 must equal, c120d7963abdcd96 must differ, the v3 control unchanged) on the box; the mixed-version Devnet 2 gate (the amended 0.3.20 node beside a 5899f603 node for ten minutes on the live file without the v4 fields) after the Mac build; the fresh-join canary the 0.3.20 cut's | +Self-test PASS on every pack (cache FNV 448274a57f508cbc); both rows in the ledger entry 43c5bf5b. G1 FOR SUB-VERSION 2 IS COMPLETE on three platforms (Metal, Linux CUDA, Windows CUDA), nothing owed. Per tier: the 5090 rate on sub-version 2 is the same band as sub-version 1 (115 to 130 MH/s), so a miner's rate does not move with the class amendment. The lines left for byte 7 by 19:00Z: the attack-pass lane's two gates and 10^6 count on 8bdcbdd8, the node lane's re-pin and the pairing. THE 64-SEED GATE ON SUB-VERSION 2 IS HEADING TO FAIL (the attack-pass lane, 13:55Z; its earlier "none over 1.2x at 13 of 64" was not read from the log and is withdrawn): 39 of 64 seeds read, 8 over 1.2x of the window model, worst p23 at 4.82x; 44 minutes for 39 seeds, the finish about 14:25Z; the eight seeds' hottest items and predicted sources being read (a residual constant through a writer the freshness rule still admits, or the window model's tail). The 10^6 exhaustion count at 8bdcbdd8: 630,000 drawn, finish about 14:05Z; the 07a809a7 control 880,000 drawn, finish about 13:59Z; the exhaustion and past-31 counts with the ends. The hot-set gate is F8's 64-seed census alone (F9's table serves the attempt histogram and the exhaustion count only). Sub-version 2 is NOT GREEN; whether it can be by 19:00Z depends on the eight sources. PC 2 DOWN AGAIN (main, 16:5x UK): the project lead takes PC 2 down for cable work (PC 1 back but his desk); both PCs out of the sweep's waves, each updates on its poller on return; no PC job to PC 1; the Windows G1 completed before the outage, nothing reruns. 0.3.21's SECOND GATE LINE on 55768f88 (sha256 279b1b690e854fc9): the ten-minute mixed-version gate beside the 5899f603 pair, 13:37:40Z to 13:47:52Z, SUMMARY PASS (one digest b0afb2ee on five nodes; 223 new and 381 old blocks accepted by the old hub, 0 rejected; counts equal at 319, 486 and 604 through both clean joins and the restart step at 13:45:22Z; no panic); the node lane's two lines on 0.3.21's first candidate complete, in plan 6.9 on ca3-v4-node; the fleet's set on it (the bare-child 12 GB line, the wipe, the kept read, the cases) is the fleet's. 0.3.21's FIRST GATE LINE on 55768f88 (sha256 279b1b690e854fc9, the string read back; pairing igneum-pow 8c728ca3 at byte 5): the digest gate 13:35:41Z to 13:37:19Z SUMMARY PASS (a89be8a7 on both binaries with the peers; db9a85f9 refused, no peer; the live file's eada4bda unmoved); the ten-minute mixed-version gate from 13:37:40Z, line about 13:50Z. The 0.3.21 order as the shipper sent it: 55768f88; f067f7c1 and 70e4601e; b0444f51; 6eb21fc9; db28d331; then the re-pin from 8bdcbdd8 on the coordinator's word; suites between, the digest read after every one; the mirror's release-0.3.20-node back at the pin c4459193, release-0.3.21-node open at 55768f88. THE LATE-JOIN COMMIT (N9's second half, the node lane): 70e4601e on the box mirror as branch proof-hold-fix, from c4459193, two files (igneum/exec/src/proving.rs, protocol/flows/src/v10/proving.rs); the gap was the fetch side on the joiner (the served record ran the native check against the joiner's trailing exec state before anything was stored, the check refused it, the proof was never held, the body rule read "not held" for 20 s and failed the IBD); the fix holds the proof by hash before the checks (the pool entry still needs them) and the serve side says when it holds fewer than asked; the exec suite 32 passed at 13:26Z with the known-failed shape first, the flows check green 13:28Z, igneumd on build-1 at the 0321 worktree path built 13:32Z, sha256 17649eeb2f7d1290, string read back; with the testnet lane (the resume form, B alone); it joins the 0.3.21 staging as its own commit. THE WIPE CANARY ON c19-1, c4459193 (sha 45be9b02d1b002f5, string read back): FORM END rc 0 at 13:50:53Z. Wipe synced 13:35:50Z (57 minutes, inside the 98-minute class); mining 13:36:00Z to 13:47:07Z, 66 mined, 66 accepted, 0 rejected, isSynced true at the tip throughout; the hub holds 41 of its blocks in its last 700 with 0 rejects (13:47:09Z); the restart on its kept datadir at 13:47:15Z: the old process stopped at once (the new process's first lock line seven seconds after the marker; the watchdog held nothing, the b7cc37e7 fault closed), synced again at 13:48:39Z after 84 s, 109 templates read with max 3,432 ms and 0 timeouts; the kept read on pool-1's 0.3.17 copy on the same pod passed at 13:38Z (the rewrite line once, a clean second start). The pin's set on c4459193: the digest gate PASS, the mixed-version gate PASS, the wipe canary PASS, the kept read PASS, the restart PASS, the 12 GB line proves and verifies (paid is a race, not a gate); CASES END from c20-1 (about 14:50Z) is the last pin line. THE INTEROP FACT stands from the void run: the 5899f603 hub accepted 235 object-byte-5 blocks from the 8097d600 node with 0 rejected, one digest on all five nodes on the live sixteen-field file. The gates: the digest test and the kaspa-pow vector test (the amended devnet epoch-0 id 1a4230699a6b9c60 must equal, c120d7963abdcd96 must differ, the v3 control unchanged) on the box; the mixed-version Devnet 2 gate (the amended 0.3.20 node beside a 5899f603 node for ten minutes on the live file without the v4 fields) after the Mac build; the fresh-join canary the 0.3.20 cut's | | Main's rulings (7 October, morning) | no generator change to v4 on the live devnet; the record's null is the window model with numbers, sent by the hash lane to the attack-pass lane so AP-F8-1 re-gates against it; a fault beyond the model (a low-entropy source at site 15) stops at the coordinator with the two options priced (a 0.3.19 class amendment before the flip, or the flip held at the floor), nothing shipping without the project lead's word; the tighter tail, an acceptance bound on the hot-set share, is a CLASS V5 item (sent to the v5 lane a6410f3b8abefb762 with the 64-seed census as its gate; the bound's number follows from the model) | ### AP-F4-1, the weak-day MUL draw (the attack-pass lane, 7 October, morning): PASS against v4, a class v5 rule From 83dd90bc0533037f7870438a145c03c25bc40846 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:57:32 +0000 Subject: [PATCH 3/6] site-ui-5, deploy 2: every remaining route in the apps' look, /live's scene box fits its lanes with the key at the top (7 October 2026, 15:0x UK) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /live (the project lead: "make it more efficient, key at the top"): the key (Included, Excluded, Selected chain, Your block, Proven, Locked checkpoint), the "Click to inspect · Drag to explore" hint and the window stepper sit on one row above the scene (hint left, key centred, stepper right; on phones the key wraps and the stepper goes under it); the blue-score range stays as a small line under the canvas; the canvas height comes from the lane count (one lane per key in view plus the others lane, capped at the renderer's 7, 4 on phones: padT 58 + lanes x 46 (40 narrow) + padB 38, read from scene/live-dag.js 2.0.3 layout(); a sizing hook was asked of the parity lane, which owns that file); the graph column no longer stretches to the inspector; the inspector keeps its rows with tighter spacing and Clear is a small button beside its title. - Every other route carries the slim bar, the app's buttons, pills and cards from site.css section 10 with its content untouched: /app (OS marks on its platform line), /miner (the download tabs take the OS wells), /miners and the bench table (the vendor badge in front of every card name), /explorer, /block, /address (hash titles wrap), /journey, /bench, /evidence (status pills wrap in their cell), /litepaper, /ledger (status badges wrap at 390), /claims, /randomx, /provenance, /dev-fee, /faucet, /metamask, /404. - Data tables fill their frame on desktop and scroll inside it on phones; no column squeezed to a sliver. - The overlap lane's detector over all 23 pages at 390 and 1440, dark and light, on build-2: 0 overlaps. Co-Authored-By: Claude Fable 5.1 --- site/app.html | 2 +- site/live.html | 57 +++++++++++++++++++++++++++---------------------- site/miner.html | 6 +++--- site/site.css | 12 +++++++++-- 4 files changed, 46 insertions(+), 31 deletions(-) diff --git a/site/app.html b/site/app.html index 39cd0b7b1..d9e828df3 100644 --- a/site/app.html +++ b/site/app.html @@ -210,7 +210,7 @@

The app you mine with.

Your rate, what it costs a day, and the chain drawn live with your own blocks ringed. One window, four pages, every card one row.

-
WindowsmacOSLinux · HiveOSlight and dark
+
WindowsmacOSLinuxHiveOSlight and dark
Overview
diff --git a/site/live.html b/site/live.html index 23f37b3a9..be846a346 100644 --- a/site/live.html +++ b/site/live.html @@ -86,13 +86,20 @@ .tb[aria-pressed="true"]{color:var(--bone);border-color:var(--line-2);background:var(--graphite)} .tb.on{color:var(--ember-text);border-color:var(--ember-40)} .sep{width:1px;height:18px;background:var(--line)} -.graph{display:grid;grid-template-columns:minmax(0,1fr) 295px} +.graph{display:grid;grid-template-columns:minmax(0,1fr) 295px;align-items:start} @media (max-width:1000px){.graph{grid-template-columns:1fr}} .gcol{padding:0;min-width:0;border-right:0;position:relative} @media (max-width:1000px){.gcol{border-right:0;border-bottom:var(--hair)}} .gcol .state{position:absolute;left:25px;top:14px;z-index:3;font-size:10px} .scene{position:relative} -#dag{display:block;width:100%;height:420px;background:var(--row);touch-action:pan-y} +#dag{display:block;width:100%;height:418px;background:var(--row);touch-action:pan-y} +.gtop{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;padding:10px 25px;border-bottom:var(--hair);font:10px/1.7 var(--mono);color:var(--ash)} +.gtop .hint{white-space:nowrap} +.gtop .lg{display:flex;flex-wrap:wrap;gap:6px 10px;flex:1 1 auto;justify-content:center} +.gtop .lg span{display:inline-flex;align-items:center;gap:6px;color:var(--ink-2);font-family:var(--f-sans);font-size:11px;white-space:nowrap} +.gtop .zoom{margin-left:4px} +.gunder{padding:8px 25px 10px;border-top:var(--hair);font:10px/1.7 var(--mono);color:var(--ash)} +@media (max-width:700px){.gtop{padding:10px 16px;gap:8px 12px}.gtop .lg{flex-basis:100%;justify-content:flex-start}.gtop .zoom{margin-left:auto}.gunder{padding:8px 16px 10px}} .tip{position:absolute;left:0;top:0;pointer-events:none;background:var(--graphite);border:1px solid var(--line-2);border-radius:6px;padding:10px 12px;font:400 12px/1.5 var(--f-mono);color:var(--ink-2);white-space:nowrap;z-index:5} .tip[hidden]{display:none} .gfoot{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:12px;padding:17px 25px;border-top:var(--hair);font:10px/1.7 var(--mono);color:var(--ash)} @@ -100,14 +107,14 @@ .zoom button{background:transparent;border:0;color:var(--ink-2);font:400 13px/1 var(--f-mono);padding:8px 12px;cursor:pointer} .zoom button:hover{background:var(--hover)} .zoom span{font:400 11px/1 var(--f-mono);color:var(--ash);padding:0 6px;min-width:44px;text-align:center} -.icol{padding:22px;min-width:0;font-size:12px;border-left:var(--hair)} -.icol .ih{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:14px} +.icol{padding:16px 18px;min-width:0;font-size:12px;border-left:var(--hair)} +.icol .ih{display:flex;align-items:center;gap:8px;margin-bottom:10px}.icol .ih .chip{margin-left:auto} .icol .ih .eyebrow{margin:0} .icol .num{font:500 22px/1.2 var(--f-sans);color:var(--bone);margin:6px 0 4px;overflow-wrap:anywhere} .icol .hash{font:400 11px/1.5 var(--f-mono);color:var(--ash);overflow-wrap:anywhere;margin-bottom:14px} .insp{display:grid;grid-template-columns:auto minmax(0,1fr);gap:0 10px;margin:0;border-top:var(--hair);padding-top:8px} -.insp dt{font:11px var(--mono);color:var(--ash);padding:10px 0;border-bottom:var(--hair)} -.insp dd{margin:0;font:11px var(--mono);color:var(--bone);font-weight:500;text-align:right;padding:10px 0;border-bottom:var(--hair);overflow-wrap:anywhere;min-width:0} +.insp dt{font:11px var(--mono);color:var(--ash);padding:7px 0;border-bottom:var(--hair)} +.insp dd{margin:0;font:11px var(--mono);color:var(--bone);font-weight:500;text-align:right;padding:7px 0;border-bottom:var(--hair);overflow-wrap:anywhere;min-width:0} .insp dd.hot{color:var(--molten-text)} .icol .note{font-size:12px;margin-top:14px;padding-top:12px;border-top:var(--hair)} .insp-empty{color:var(--ash);font-size:13px;line-height:1.5;padding:6px 0 2px} @@ -125,7 +132,7 @@ .tab{background:transparent;border:0;color:var(--ash);font:400 12px/1.2 var(--f-sans);padding:6px 10px;border-radius:4px;cursor:pointer} .tab.on{background:var(--row);color:var(--bone)} .ititle{font:500 22px/1.2 var(--f-sans);color:var(--bone);margin:6px 0 4px;letter-spacing:-.01em} -.proof-scene{height:200px;border:var(--hair);border-radius:6px;background:var(--row);margin:12px 0;overflow:hidden} +.proof-scene{height:170px;border:var(--hair);border-radius:6px;background:var(--row);margin:12px 0;overflow:hidden} .proof-scene canvas{display:block;width:100%;height:100%} .proof-head{display:flex;justify-content:space-between;align-items:baseline;font:500 13px/1.3 var(--f-sans);color:var(--bone);margin-bottom:8px} .track{display:flex;gap:3px;height:3px;margin-bottom:10px} @@ -383,15 +390,23 @@ details.tablebar summary{display:flex;align-items:center}
-
connecting to the observer
-
-
- Click to inspect  ·  Drag to explore +
+ Click to inspect  ·  Drag to explore +
+ Included + Excluded + Selected chain + Your block + Proven + Locked checkpoint +
60 s
+
connecting to the observer
+
blue score pending
-
-
- Included - Excluded - Selected chain - Your block - Proven - Locked checkpoint -
- blue score pending -
@@ -623,6 +626,10 @@ details.tablebar summary{display:flex;align-items:center} function uninspect(){sel=null;$('insp-close').hidden=true;$('i-kind').textContent='select a block';$('i-title').textContent='Select a block';$('i-hash').textContent='Click the graph, or a row of the table below.';$('i-shard-count').textContent='not set';$('i-track').innerHTML='';$('i-shards').innerHTML='
  • No block selected.
  • ';['i-incl','i-miner','i-blue','i-daa','i-parents','i-time','i-cp','i-lock'].forEach(function(id){$(id).textContent='not set';});if(proof)proof.setBlock(null);} $('insp-close').addEventListener('click',function(){if(dag)dag.select(null,'clear');uninspect();}); // the four stats above the graph, from the module's own window + // the scene box fits its lanes: the renderer places lanes at 46 px (40 on phones) under a 58 px top band and over a 38 px axis + // (scene/live-dag.js 2.0.3 layout()); one lane per key in view plus the others lane, capped at the renderer's 7 (4 on phones) + var fitH=0; + function fit(st){var c=$('dag');if(!c||!st)return;var cap=st.narrow?4:7,laneH=st.narrow?40:46,keys=Math.max(2,st.minerKeys||0),lanes=Math.min(cap,keys>cap?cap:keys+(keys>cap?1:0));var h=58+lanes*laneH+38;if(h!==fitH){fitH=h;c.style.height=h+'px';}} function miner(st){if(!dag)return;var cps=dag.getCheckpoints(),cp=cps.length?cps[cps.length-1]:null; $('m-blocks').textContent=fmtN(st.blocks);$('m-win').textContent='last '+st.window+' s';$('m-keys').textContent=st.minerKeys+' miner keys · not a card count';$('lane-caption').textContent=st.minerKeys+' keys'; $('m-mine').textContent=mine?fmtN(st.own):'0';$('m-mine').classList.toggle('dim',!mine);$('m-key').textContent=mine||'not set'; @@ -635,7 +642,7 @@ details.tablebar summary{display:flex;align-items:center} onState:function(st,reason){var el=$('dag-state');el.className='state '+(st==='live'?'live':st==='failed'?'bad':'');el.innerHTML=''+(st==='live'?'live devnet':esc(reason||st));if(proof)proof.setPaused(st!=='live'||dag.getStats().paused);}, onPause:function(p){$('dag-follow').classList.toggle('on',!p);if(proof)proof.setPaused(p||dag.getState().state!=='live');}, onFollow:function(f){$('dag-follow').classList.toggle('on',f);}, - onStats:miner, + onStats:function(st){miner(st);fit(st);}, onSelect:function(b,why){if(b){var full=lastD&&(lastD.blocks||[]).filter(function(x){return x.hash===b.hash;})[0];inspect(Object.assign({},b,full?{number:full.number}:{}));}else uninspect();}}); if(window.IgneumProof&&!proof)proof=IgneumProof.mount($('proof')); if(lastD)feed(lastD);} diff --git a/site/miner.html b/site/miner.html index a70321b33..6e07efec8 100644 --- a/site/miner.html +++ b/site/miner.html @@ -251,19 +251,19 @@ pre b{color:var(--molten-text);font-weight:500}
    -

    Ember for Windows

    the installer, from the signed manifest

    +

    Ember for Windows

    the installer, from the signed manifest

    Run the installer, press Start. NVIDIA and AMD cards both mine. Setting an NVIDIA power cap asks for administrator rights once.

    Download for Windows v0.3.14 · 45.4 MB
    from dl.igneum.networksha256 08e75effc732f88bca11006b3b7cdf2bc533576f56819f79509fee47a8f5dd59