From 2c2018d716c226cfe67566cc83d15cc85f83e8a5 Mon Sep 17 00:00:00 2001 From: Justin Icenhour Date: Mon, 12 Oct 2026 22:43:32 -0500 Subject: [PATCH 1/5] fix(readme): a reload lands where the reader was, not 2 px past it Two restores ran on every reload of an about page with its README open: the fold's own and Chromium's. Nuxt's router plugin leaves history.scrollRestoration at `auto` for the first document, so the browser still restored the entry after the fold had opened and scrolled, and landed 2 px further on; the next reload saved that and crept on. That is the check:navigation flake that could turn CI red. Traced frame by frame: the fold's scrollTo({ top: 4200 }) read 4200, the first scroll event 4202, and no element moved in between, so it was never layout or scroll anchoring. Chromium settles it in the task that sets `auto`, and the entry must also leave the previous document as `manual`: - app/plugins/readme-restore.client.ts runs before Nuxt's router plugin (order -25) and holds `manual` through its write, only for a reload or Back/Forward of an entry whose README was open and whose place is stored. - app/composables/useReadmeFold.ts sets `manual` when the fold opens and hands back what it took when folded again; foldWillRestore() and a stale stored place is dropped once folded. onyums at 390 px: 14 of 42 reloads drifted before, 0 of 42 after (DPR 1 and CI's DPR 3 phone profile). Reloads of other pages land as on main's build, side by side. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 50 +++++++++++++++----------- app/composables/useReadmeFold.ts | 36 ++++++++++++++++++- app/plugins/readme-restore.client.ts | 52 ++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 21 deletions(-) create mode 100644 app/plugins/readme-restore.client.ts diff --git a/ROADMAP.md b/ROADMAP.md index c3d397a..e719bd0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1089,26 +1089,36 @@ record. There is no run log. sufficient technique C43. -- [ ] A reload of an unfolded README lands 2–9 px past the reader's place, - now and then, in Chromium at 390 px — and creeps: twelve reloads of - onyums ran +2, +2, +2, +4 … +6 (2026-10-11, normal CPU; none at 4× - throttle). The same 9 px failure hit `check:navigation` once on main's - build and once on this run's, of seven runs, so CI can go red on it. - Not fonts: waiting on `document.fonts.ready` before the restore made no - difference, and the page's block heights are identical before and - after; the restore's `scrollTo` itself reports the extra pixels in its - first scroll event. Scroll anchoring looked involved: a style of - `* { overflow-anchor: none !important }` added at `DOMContentLoaded` - took the drift to 0 over 2 × 12 reloads — but setting it only on - `` for the second around the fold's `scrollTo` did not (+2 … +10 - over 12, four pages), and neither did `none` on `html`, `body`, - `details` or `.readme *` alone from `DOMContentLoaded` (each still - drifted, +6 to +12 over 10) — nor did `* { overflow-anchor: none }` - without `!important` (+6 over 10), nor `main *`, `header *, footer *` - or the root chain. So anchoring is not shown to be the cause; the two - clean runs with `!important` may have been luck. Next: record - `scrollY` in every frame between the fold opening and the restore, - and whether vue-router's own `scrollTo` lands before or after it. +- [x] A reload of an unfolded README lands where the reader was, not 2 px + past it — and no longer creeps. Two restores ran on every such reload: + the fold's own, and Chromium's. Nuxt's router plugin sets + `history.scrollRestoration = 'auto'` for the first document (it sets + `manual` only at the first client navigation), so the browser still + restored the entry itself, after the fold had opened the README and + scrolled, and landed 2 px further on; the next reload saved that and + added 2 more. Traced frame by frame: the fold's `scrollTo({ top: 4200 })` + reported 4200, the first scroll event 4202, and no element on the page + moved in between — which is why it never looked like layout or anchoring, + and why last night's `overflow-anchor` bisect gave contradictory answers + (injecting any style at `DOMContentLoaded`, a no-op included, suppressed + it by moving the timing). Chromium settles it in the task in which `auto` + is set: `manual` restored in a microtask after Nuxt's write, no drift; + in the next task, from a plugin after the router's or once mounted, the + drift stays (13 of 28, 16 of 42, 17 of 42). And the entry has to leave + the previous document as `manual` too. So the fold sets `manual` when + it opens (and hands `auto` back when folded again), and + `app/plugins/readme-restore.client.ts` runs before Nuxt's router plugin + and holds `manual` through its write — only for a reload or Back/Forward + of an entry whose README was open and whose place is stored; every other + page keeps the browser's restore. onyums at 390 px: 14 of 42 reloads + drifted before, 0 of 42 after, at DPR 1 and at CI's phone profile. + Other pages' reloads land as on main's build, side by side. +- [ ] A reload of `/projects`, or of an about page with its README folded, + lands at the top (0–2 px) instead of where the reader was, at 390 px in + Chromium — on main's build as on this one; the home page restores (900 → + 900). Seen while checking the fix above, not yet looked into. Nuxt's + initial `scrollTo({ left: 0, top: 0 })`, at ~40 ms, is the first + suspect. - [ ] Look at the masthead in a real Firefox and in Tor Browser, scrolled. Headless Firefox drew no blur at all behind it, not even the plain `blur()` the CSS gives every other browser, so its screenshots show body diff --git a/app/composables/useReadmeFold.ts b/app/composables/useReadmeFold.ts index 00d3c42..16e0499 100644 --- a/app/composables/useReadmeFold.ts +++ b/app/composables/useReadmeFold.ts @@ -52,10 +52,11 @@ export function useReadmeFold(readmeHtml: Readonly>) { // while the page unloads — so the position goes to session storage, keyed // by the history entry. A convenience: if storage is refused, a reload // just lands where it did before. - const entry = () => `readme-top:${history.state?.position ?? ''}:${route.path}` + const entry = () => readmeTopKey(route.path) const remember = () => { try { if (open.value) sessionStorage.setItem(entry(), String(Math.round(window.scrollY))) + else sessionStorage.removeItem(entry()) } catch {} } @@ -86,6 +87,8 @@ export function useReadmeFold(readmeHtml: Readonly>) { }) onBeforeUnmount(() => window.removeEventListener('pagehide', remember)) + let handedOver: ScrollRestoration | null = null + /** The `
`'s own `toggle`, from a click or from the binding. */ function onToggle(event: Event) { const now = (event.target as HTMLDetailsElement).open @@ -93,6 +96,17 @@ export function useReadmeFold(readmeHtml: Readonly>) { // vue-router merges `history.state` into the entry when it next writes // it, so the key survives navigating away; a new entry starts without it. if (history.state?.[KEY] !== now) history.replaceState({ ...history.state, [KEY]: now }, '') + // An open fold restores this entry itself, so the entry leaves this + // document as `manual`; see `plugins/readme-restore.client.ts`. Folded + // again, the browser gets back what the fold took from it. + if (now && history.scrollRestoration !== 'manual') { + handedOver = history.scrollRestoration + history.scrollRestoration = 'manual' + } + else if (!now && handedOver) { + history.scrollRestoration = handedOver + handedOver = null + } } return { open, onToggle } @@ -111,6 +125,26 @@ function readmeHeading(hash: string): HTMLElement | null { return el?.closest('.readme') ? el : null } +/** Where the fold keeps the reading position of the current history entry at `path`. */ +function readmeTopKey(path: string): string { + return `readme-top:${history.state?.position ?? ''}:${path}` +} + +/** + * Whether this document will be put back by the fold rather than by the + * browser: a reload, or Back or Forward to it, of an entry whose README was + * left open. See `plugins/readme-restore.client.ts`. + */ +export function foldWillRestore(): boolean { + if (!returning() || history.state?.[KEY] !== true) return false + try { + return sessionStorage.getItem(readmeTopKey(location.pathname)) !== null + } + catch { + return false + } +} + /** Whether this document is a reload, or Back or Forward to it, rather than a new visit. */ function returning(): boolean { const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined diff --git a/app/plugins/readme-restore.client.ts b/app/plugins/readme-restore.client.ts new file mode 100644 index 0000000..2aa0845 --- /dev/null +++ b/app/plugins/readme-restore.client.ts @@ -0,0 +1,52 @@ +import { foldWillRestore } from '~/composables/useReadmeFold' + +/** + * A page the README fold puts back is not also put back by the browser. + * + * Nuxt's router plugin sets `history.scrollRestoration` to `auto` for the + * first document, so the browser restores a reload itself, and sets `manual` + * only at the first client navigation. On an about page reloaded with its + * README open that made two restores: the fold's own (`useReadmeFold`), once + * it has opened the README, and then Chromium's, still pending until the open + * fold made the page tall enough, which landed a couple of pixels past the + * fold's. The next reload saved that position and crept on: onyums at 390 px, + * +2 px on 14 of 42 reloads, and `check:navigation` red now and then. + * + * Chromium settles it in the very task that sets `auto`. `manual` set again + * in a microtask after Nuxt's write: no drift in 26 reloads; in the next + * task, from a plugin after the router's, or once mounted: 13 of 28, 16 of + * 42, 17 of 42. So this runs before the router plugin (`order` -25, ahead of + * its `pre`, -20) and holds `manual` through that write, for this document + * only and only when the fold will restore it; every other page keeps the + * browser's restore. Once the app has mounted the native setter is back, so + * the `manual` Nuxt sets at the first navigation lands as before. + * + * The other half is in `useReadmeFold`: the entry must also leave the + * previous document as `manual`, which the fold sets when it opens. With + * both, 0 of 42. + */ +export default defineNuxtPlugin({ + name: 'readme-restore', + order: -25, + setup(nuxtApp) { + if (!foldWillRestore()) return + const native = Object.getOwnPropertyDescriptor(History.prototype, 'scrollRestoration') + if (!native?.get || !native.set) return + const { get, set } = native + try { + Object.defineProperty(history, 'scrollRestoration', { + configurable: true, + get: () => get.call(history), + set: () => set.call(history, 'manual'), + }) + } + catch { + // Someone else already owns the property: leave theirs alone. + return + } + set.call(history, 'manual') + nuxtApp.hooks.hookOnce('app:mounted', () => { + Reflect.deleteProperty(history, 'scrollRestoration') + }) + }, +}) From 3a420e7a266bd766d60e462362bd6727561236c9 Mon Sep 17 00:00:00 2001 From: Justin Icenhour Date: Mon, 12 Oct 2026 23:27:51 -0500 Subject: [PATCH 2/5] fix(router): a reload puts the reader back on every page, not only / A reload of /projects at 1,200 px landed at 0, and so did an about page with its README folded, in Chromium and Firefox; in Chromium so did Back to /projects, /news or a blog tab from another site. Only / came back. Nuxt's router plugin runs one start-up navigation through the scroll behaviour from START_LOCATION, and with no saved position it answers { top: 0 }, cancelling the browser's own restore. / was spared only by Nuxt's same-path rule (START_LOCATION's path is /). vue-router's saved position can't step in: Chromium drops its beforeunload write, so history.state.scroll is false on every reload. - app/router.options.ts: on the page's own load, with neither a saved position nor a hash, leave the scroll alone. A fresh visit is at the top anyway; a hash is still Nuxt's to scroll to. - scripts/check-navigation.mjs: reload each folded about page halfway down and expect it to stay there. Main's build fails 16 of them; this build passes 160 in Chromium and in Firefox. - ROADMAP.md, README.md: the item, and the restore animation found on the way as a new open item. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- ROADMAP.md | 31 +++++++++++++++++++++++++------ app/router.options.ts | 13 +++++++++++++ scripts/check-navigation.mjs | 16 ++++++++++++++++ 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5a0251b..7f1196d 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,8 @@ headless Firefox, driven over WebDriver BiDi (set `FIREFOX_PATH` if it is not on `PATH`): the onion service is reached in Tor Browser, which is Firefox, and the two engines do differ. `npm run check:navigation` drives what a visitor's own navigation does to each about page's folded README: Back, Forward and a reload -return a reader to the place they were reading, a visit by link starts folded, +return a reader to the place they were reading, whether the README is open or +folded, a visit by link starts folded, and a link to a README heading lands on it, below the masthead, at both widths, in Chromium or (`--firefox`) Firefox. A reload it fails prints a frame-by-frame trace of the page's first seconds — every scroll call, scroll position and page height — diff --git a/ROADMAP.md b/ROADMAP.md index e719bd0..1bffa62 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1113,12 +1113,31 @@ record. There is no run log. page keeps the browser's restore. onyums at 390 px: 14 of 42 reloads drifted before, 0 of 42 after, at DPR 1 and at CI's phone profile. Other pages' reloads land as on main's build, side by side. -- [ ] A reload of `/projects`, or of an about page with its README folded, - lands at the top (0–2 px) instead of where the reader was, at 390 px in - Chromium — on main's build as on this one; the home page restores (900 → - 900). Seen while checking the fix above, not yet looked into. Nuxt's - initial `scrollTo({ left: 0, top: 0 })`, at ~40 ms, is the first - suspect. +- [x] A reload puts the reader back on every page, not only on `/`. A + reload of `/projects` at 1,200 px landed at 0, and so did a folded + about page, in Chromium and Firefox alike; in Chromium so did Back from + another site to `/projects`, `/news` or a blog tab. The home page alone + came back. Nuxt's router plugin runs one start-up navigation + through the scroll behaviour, from `START_LOCATION`, and with no saved + position its answer is the top of the page; that `scrollTo` cancelled the + browser's own restore, still under way. `/` was spared only by Nuxt's + same-path rule, `START_LOCATION`'s path being `/`. vue-router's saved + position cannot step in: it is written on `beforeunload`, and Chromium + drops that write (`history.state.scroll` is `false` on every reload). + `app/router.options.ts` now leaves the scroll alone on the page's own + load when there is neither a saved position nor a hash; a fresh visit is + at the top anyway. Before → after, at 390 px: Chromium `/projects` 0 → + 1,200, folded onyums 0 → 1,500; Firefox the same; Chromium Back from + another origin to `/projects`, `/news` and a blog tab 0 → where the + reader was (Firefox already restored those from its back-forward cache). + `check:navigation` now reloads each folded about page halfway down and + expects it to stay there; main's build fails it. +- [ ] The browser's own restore animates. A reload of the home page at + 1,200 px scrolls from the top to 1,200 over ~600 ms in Chromium, because + the site's `scroll-behavior: smooth` applies to it; vue-router's restores + were made instant (`app/router.options.ts`), the browser's cannot be + told so. The page would have to hold `scroll-behavior: auto` until the + restore has finished, and when that is is not something the page is told. - [ ] Look at the masthead in a real Firefox and in Tor Browser, scrolled. Headless Firefox drew no blur at all behind it, not even the plain `blur()` the CSS gives every other browser, so its screenshots show body diff --git a/app/router.options.ts b/app/router.options.ts index 973dfa7..e48f12b 100644 --- a/app/router.options.ts +++ b/app/router.options.ts @@ -1,4 +1,5 @@ import type { RouterConfig } from 'nuxt/schema' +import { START_LOCATION } from 'vue-router' // Nuxt's own router options, which this file would otherwise replace. Not an // export of the package, so it is reached by path: if Nuxt moves it, the // build fails here rather than the site quietly losing Nuxt's scrolling. @@ -26,6 +27,18 @@ import nuxt from '../node_modules/nuxt/dist/pages/runtime/router.options.js' */ export default { async scrollBehavior(to, from, saved) { + // The page's own load. Nuxt runs one navigation through here as the app + // starts, from `START_LOCATION`, and with no saved position its answer + // is the top of the page. A fresh visit is already there; a reload, or + // Back to the page from another site, is not — the browser is + // restoring the reader's place (Nuxt leaves `scrollRestoration` at + // `auto` for this document), and the jump to the top cancelled it. So + // a reload of `/projects` at 1,200 px landed at 0. Only `/` was spared, + // by Nuxt's same-path rule, because `START_LOCATION`'s path is `/`. + // vue-router's own saved position never helps here: it is written on + // `beforeunload`, and Chromium drops that write (`history.state.scroll` + // is `false` on every reload). A hash is still Nuxt's to scroll to. + if (from === START_LOCATION && !saved && !to.hash) return false const position = await nuxt.scrollBehavior?.(to, from, saved) return position && saved && position === saved ? { ...saved, behavior: 'instant' } : position }, diff --git a/scripts/check-navigation.mjs b/scripts/check-navigation.mjs index a13318b..b71fa70 100644 --- a/scripts/check-navigation.mjs +++ b/scripts/check-navigation.mjs @@ -15,6 +15,7 @@ * fragment by itself. `app/composables/useReadmeFold.ts` fixed both. This * holds them fixed, on every about page whose README has a heading: * + * - folded, read down and reload: still folded, same place * - unfold, read down, go to the blog tab, Back: unfolded, same place * - Forward and Back again: the same * - reload: unfolded, same place @@ -157,6 +158,21 @@ try { // Unfold and read down past where the folded page ends, so a page that // came back folded could not land there. const foldedBottom = await page.evaluate('document.documentElement.scrollHeight - innerHeight') + + // The browser's own restore, on a page the fold leaves alone. Nuxt's + // start-up scroll to the top used to cancel it on every page but `/` + // (see app/router.options.ts), so this landed at 0 in both engines. + const halfway = Math.round(foldedBottom / 2) + await page.evaluate(`scrollTo({ top: ${halfway}, behavior: 'instant' })`) + await arrive(about) + const read = (await state()).y + await page.reload() + await arrive(about) + s = await state() + expect(`a reload of the folded page stays at y ${read}`, s.open === false && Math.abs(s.y - read) <= SLACK, s) + await page.evaluate(`scrollTo({ top: 0, behavior: 'instant' })`) + await arrive(about) + await click('details > summary') await sleep(300) const target = await page.evaluate(`Math.min(${foldedBottom} + 1500, document.documentElement.scrollHeight - innerHeight - 10)`) From 7c4d76ee58c0da0b89fd05ab4f035ffa15b022f9 Mon Sep 17 00:00:00 2001 From: Justin Icenhour Date: Tue, 13 Oct 2026 00:19:08 -0500 Subject: [PATCH 3/5] fix(scroll): the browser's own restore is a jump, not an animation Chromium applies the site's `scroll-behavior: smooth` to its own restore, so a reload of / at 1,200 px scrolled from the top to 1,200 over ~550 ms (34 scroll events); Firefox jumps. vue-router's restores were already made instant in app/router.options.ts, but the browser's can't be asked. app/plugins/restore-jump.client.ts holds `scroll-behavior: auto` on a reload or Back/Forward to a document until scrolling has been still for 300 ms (3 s at most), then hands smooth scrolling back. Chromium, / and /projects at 1,200 px: 34 scroll events -> 1, landing at 1,200; Firefox unchanged. A fresh visit and a page without script are untouched. ROADMAP.md: the item; an upstream report of Nuxt's reload-to-top for the owner (nuxt/nuxt#36346 was closed as an i18n duplicate, and this site has no i18n); and an intermittent cold-load #heading landing 2 px short, seen once in three Chromium runs on a path this change does not touch. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 35 +++++++++++++++++++----- app/plugins/restore-jump.client.ts | 43 ++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 app/plugins/restore-jump.client.ts diff --git a/ROADMAP.md b/ROADMAP.md index 1bffa62..1bae570 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1132,12 +1132,35 @@ record. There is no run log. reader was (Firefox already restored those from its back-forward cache). `check:navigation` now reloads each folded about page halfway down and expects it to stay there; main's build fails it. -- [ ] The browser's own restore animates. A reload of the home page at - 1,200 px scrolls from the top to 1,200 over ~600 ms in Chromium, because - the site's `scroll-behavior: smooth` applies to it; vue-router's restores - were made instant (`app/router.options.ts`), the browser's cannot be - told so. The page would have to hold `scroll-behavior: auto` until the - restore has finished, and when that is is not something the page is told. +- [ ] Upstream: report Nuxt's reload-to-top to nuxt/nuxt — **owner's call**, + as it speaks for the organization in another project's tracker. Nuxt's + router plugin answers its start-up navigation, from `START_LOCATION` + with no saved position, with `{ top: 0 }`, which cancels the browser's + restore on every path but `/` (fixed here in `app/router.options.ts`, + above). nuxt/nuxt#36346, "Scroll is not restored after refresh", was + closed on 2026-10-11 as a duplicate of an `@nuxtjs/i18n` issue; this + site has no i18n and reproduces it on Nuxt 4.6.1, in Chromium and + Firefox. + +- [x] The browser's own restore is a jump, not an animation. A reload of the + home page at 1,200 px scrolled from the top to 1,200 over ~550 ms in + Chromium (34 scroll events), because the site's `scroll-behavior: + smooth` applies to the browser's restore too; Firefox jumps. vue-router's + restores were made instant in `app/router.options.ts`, but the browser's + cannot be asked. `app/plugins/restore-jump.client.ts` holds + `scroll-behavior: auto` on a reload or Back/Forward to a document until + scrolling has been still for 300 ms (3 s at most), then hands the + smooth scrolling back. Chromium, `/` and `/projects` at 1,200 px: 34 + scroll events → 1, landing at 1,200 every time; Firefox unchanged (1). + A fresh visit, and a page without script, are untouched. +- [ ] A cold load of `about#` can land a couple of pixels short of + the masthead's edge: once in three `check:navigation` runs in Chromium + on 2026-10-12, Nanna at 390 px, `#api-key-invalid` 16,253 px down, its + first line 2 px under the pane (the band is 0–200), so CI can go red on + it. A fresh `navigate` load, which none of that night's restore changes + touch (each returns early unless the load is a reload or Back/Forward), + and the other two runs passed all 160. Next: run that one case in a loop + with the check's frame trace, on main's build and this one. - [ ] Look at the masthead in a real Firefox and in Tor Browser, scrolled. Headless Firefox drew no blur at all behind it, not even the plain `blur()` the CSS gives every other browser, so its screenshots show body diff --git a/app/plugins/restore-jump.client.ts b/app/plugins/restore-jump.client.ts new file mode 100644 index 0000000..a2ef7cb --- /dev/null +++ b/app/plugins/restore-jump.client.ts @@ -0,0 +1,43 @@ +/** + * The browser's own restore is a jump, not an animation. + * + * The site scrolls smoothly (`scroll-behavior: smooth` in `main.css`), for a + * reader following a link down the page, and Chromium applies that to its own + * restore too: a reload of the home page at 1,200 px scrolled from the top to + * 1,200 over ~550 ms, 34 scroll events. Firefox puts the reader back in one. + * vue-router's restores were already made instant (`router.options.ts`), but + * the browser's cannot be asked to be: so on a reload, or Back or Forward to + * a document, the page holds `scroll-behavior: auto` until scrolling has been + * still for a moment, then gives the stylesheet's smooth scrolling back. A + * fresh visit is not touched; neither is a page without script, which keeps + * the stylesheet's behaviour throughout. + */ +export default defineNuxtPlugin({ + name: 'restore-jump', + order: -25, + setup() { + const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined + if (nav?.type !== 'reload' && nav?.type !== 'back_forward') return + + const root = document.documentElement + root.style.setProperty('scroll-behavior', 'auto') + + let still: ReturnType | undefined + const release = () => { + clearTimeout(still) + clearTimeout(limit) + removeEventListener('scroll', onScroll) + root.style.removeProperty('scroll-behavior') + } + // The restore can land in more than one step while the page is still + // growing, so wait for quiet rather than for the first scroll event. + const onScroll = () => { + clearTimeout(still) + still = setTimeout(release, 300) + } + addEventListener('scroll', onScroll, { passive: true }) + // A page that was at the top, or that the browser does not restore, + // never scrolls: give the smooth scrolling back regardless. + const limit = setTimeout(release, 3000) + }, +}) From 9a61f09fa0200441afa3abae3ae0ecc3321f3b78 Mon Sep 17 00:00:00 2001 From: Justin Icenhour Date: Tue, 13 Oct 2026 01:08:49 -0500 Subject: [PATCH 4/5] docs(roadmap): the onion probe's first runs, marked 18.1.1 held, the cold-load miss measured - The onion workflow's first scheduled runs reported (82.6 s on a second try, 11.1 s on the first); reading a few days of them is its own item, and GitHub's schedule ran twice in ~20 h, which it documents as possible. - marked 18.1.1 changes README output; held a night with a byte-diff plan. - The cold-load #heading miss: 1 in 6 Chromium runs on this build, none in 3 on main's, plus one stalled run. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 44 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1bae570..5dbbcc6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -412,11 +412,20 @@ record. There is no run log. itself. From this host's user `tor` on 2026-10-11: through the gate on the first try, in 7.0 s and 10.8 s. A made-up address and a dead SOCKS port each fail with exit 1; no address to probe, 2. - - [ ] …run every six hours: `.github/workflows/onion.yml` (a fresh Tor + - [x] …run every six hours: `.github/workflows/onion.yml` (a fresh Tor client on a GitHub runner, one tracking issue kept in sync, the - shape of `links.yml`). Landed with this slice; tick once its first - scheduled runs on `main` have reported, and read a few days of - them before weighing C Tor. + shape of `links.yml`). Its first scheduled runs on `main` have + reported, both green: 2026-10-12 15:25 UTC through the gate in + 82.6 s on the second try — the first timed out connecting after + 120 s — and 2026-10-13 00:39 UTC on the first, in 11.1 s. + - [ ] Read a few days of those runs before weighing C Tor: how many + first tries fail, and how long a reach takes. Each run's tries are + in its step summary. The schedule is thinner than it looks: two + scheduled runs in the first ~20 h, not three or four, and the + first landed three hours after its slot; GitHub documents that + `schedule` runs can be delayed under load and, if the load is high + enough, dropped. Count the runs that happened, not the slots. + - [ ] Move the runtime to distroless — **needs an owner decision, because the size case for it is gone.** Measured 2026-09-27: the same build on `gcr.io/distroless/nodejs24-debian13:nonroot` is 73.0 MB of image content @@ -482,6 +491,16 @@ record. There is no run log. Golar's ~2.7 s. +- [ ] marked 18.1.1 (published 2026-10-12 12:28 UTC; it renders every README + and post). Held a night: it changes output, not just speed — a + blockquote ends before a lazy line after a heading, hr or html block; + indented and type 3–5 html blocks may interrupt a paragraph; a backslash + in a code span no longer hides emphasis; list continuation respects tab + stops. Its `walkTokens` fix (quadratic → linear, marked#4120) does not + apply here: `shared/markdown/render.ts` registers no `walkTokens`. Take + it with a byte diff of all eight READMEs and every post, before and after. + + - [ ] Move the type checker to TypeScript 7 once 7.1 ships its API and vue-tsc adopts it — the 6.0 API is a bridge, not a destination. @@ -1112,7 +1131,10 @@ record. There is no run log. of an entry whose README was open and whose place is stored; every other page keeps the browser's restore. onyums at 390 px: 14 of 42 reloads drifted before, 0 of 42 after, at DPR 1 and at CI's phone profile. - Other pages' reloads land as on main's build, side by side. + Other pages' reloads land as on main's build, side by side. The HTML + standard keeps the scroll restoration mode on the session history + entry, which fits: the mode the entry leaves with is what counts. + - [x] A reload puts the reader back on every page, not only on `/`. A reload of `/projects` at 1,200 px landed at 0, and so did a folded about page, in Chromium and Firefox alike; in Chromium so did Back from @@ -1158,9 +1180,15 @@ record. There is no run log. on 2026-10-12, Nanna at 390 px, `#api-key-invalid` 16,253 px down, its first line 2 px under the pane (the band is 0–200), so CI can go red on it. A fresh `navigate` load, which none of that night's restore changes - touch (each returns early unless the load is a reload or Back/Forward), - and the other two runs passed all 160. Next: run that one case in a loop - with the check's frame trace, on main's build and this one. + touch (each returns early unless the load is a reload or Back/Forward). + Over six Chromium runs of that build: four passed all 160, one had this + miss, and one stalled on the same page (Nanna, phone) until its 15-minute + timeout, with the host at load 9 and other agents' browsers running. + Main's build: no such miss in three runs, so it is not shown to predate + the night. Alone, 25 cold loads of that URL landed at 16 or 12 px, never + out of band. Next: the same loop inside the check's own sequence (after + the reload and Back cases on that tab), with its frame trace, on both + builds, and find out what the stalled run was waiting for. - [ ] Look at the masthead in a real Firefox and in Tor Browser, scrolled. Headless Firefox drew no blur at all behind it, not even the plain `blur()` the CSS gives every other browser, so its screenshots show body From e6bb4e66ae26be2b6def634a8059a87650e6b6d1 Mon Sep 17 00:00:00 2001 From: Justin Icenhour Date: Tue, 13 Oct 2026 01:08:49 -0500 Subject: [PATCH 5/5] chore(data): refresh the fallback snapshot Skidbladnir moves to v1.4.3 in the snapshot. Co-Authored-By: Claude Opus 5.5 --- data/projects.generated.json | 187 ++++++++++++++++++----------------- 1 file changed, 97 insertions(+), 90 deletions(-) diff --git a/data/projects.generated.json b/data/projects.generated.json index 1759427..c8be978 100644 --- a/data/projects.generated.json +++ b/data/projects.generated.json @@ -1,10 +1,10 @@ { - "generatedAt": "2026-10-12T04:16:15.083Z", + "generatedAt": "2026-10-13T05:41:38.511Z", "org": "basic-automation", "repos": { "artiqwest": { "repo": "artiqwest", - "fetchedAt": "2026-10-12T04:16:06.037Z", + "fetchedAt": "2026-10-13T05:41:30.769Z", "description": "basic http request over tor using arti.", "htmlUrl": "https://github.com/basic-automation/artiqwest", "homepage": null, @@ -56,13 +56,13 @@ }, "download": null, "crateVersion": "0.5.0", - "crateDownloads": 15706, + "crateDownloads": 15708, "crateUrl": "https://crates.io/crates/artiqwest", "docsUrl": "https://docs.rs/artiqwest" }, "onyums": { "repo": "onyums", - "fetchedAt": "2026-10-12T04:16:07.446Z", + "fetchedAt": "2026-10-13T05:41:32.064Z", "description": "Onion-service server using axum.", "htmlUrl": "https://github.com/basic-automation/onyums", "homepage": null, @@ -121,13 +121,13 @@ }, "download": null, "crateVersion": "0.5.0", - "crateDownloads": 19755, + "crateDownloads": 19762, "crateUrl": "https://crates.io/crates/onyums", "docsUrl": "https://docs.rs/onyums" }, "nisaba": { "repo": "nisaba", - "fetchedAt": "2026-10-12T04:16:11.655Z", + "fetchedAt": "2026-10-13T05:41:36.098Z", "description": "Desktop app for managing one product catalog and syncing it across eBay, Squarespace, XMR Bazaar and Amazon. Rust + Tauri 2 + Nuxt 3, with sandboxed vendor plugins and optional P2P sync over Tor.", "htmlUrl": "https://github.com/basic-automation/nisaba", "homepage": null, @@ -163,7 +163,7 @@ }, "Skidbladnir": { "repo": "Skidbladnir", - "fetchedAt": "2026-10-12T04:16:12.535Z", + "fetchedAt": "2026-10-13T05:41:36.862Z", "description": "Free, open-source desktop app (Windows, Linux, macOS) that converts images to WebP, AVIF, JPEG XL and HEIC, with every still-image option of the official encoders (cwebp, avifenc, cjxl, heif-enc) as labelled controls.", "htmlUrl": "https://github.com/basic-automation/Skidbladnir", "homepage": "https://basicautomation.io/projects/skidbladnir/about", @@ -191,14 +191,21 @@ ], "stars": 2, "forks": 0, - "openIssues": 8, + "openIssues": 0, "license": "ISC", "defaultBranch": "master", "createdAt": "2019-03-10T04:37:55Z", - "pushedAt": "2026-10-07T06:45:52Z", + "pushedAt": "2026-10-12T20:20:43Z", "archived": false, - "readmeHtml": "

Skidbladnir

\n

\"CI\"\n\"Latest\n\"Platforms:

\n

Skidbladnir makes image files smaller by converting them to the modern formats:\nWebP, AVIF, JPEG XL and HEIC. It is for anyone who wants smaller images for\na website, an archive or a photo library without learning four command-line tools. Pick\nyour files, see the result beside the original, then convert one image, a batch or a\nwhole folder.

\n

Free and open source · Windows, macOS (experimental) and Linux · converts on your\ncomputer; the only network request is the in-app update check

\n

Download ·\nWhich file to pick ·\nProject page

\n

Each of these formats has an official encoder, and each one is a command-line tool with\ndozens of options that are easy to forget and easy to get wrong. Skidbladnir puts those\noptions in a window, each labelled with the flag it sets, instead of a single quality\nslider.

\n

For a still image, every option of cwebp, avifenc (with libaom), cjxl and\nheif-enc (with Kvazaar) is a control in the window, and Skidbladnir writes the same bytes\nas those tools built from the same library versions, checked in CI against the real tools,\nwith the exceptions listed under What "every option" means.

\n

\"The

\n

AVIF selected: avifenc's options, each labelled with its flag.

\n
\nWith WebP selected: the whole panel, and a preview

\"The

\n

WebP selected, with a file previewed: every cwebp option, each labelled with its flag.

\n

Features

\n

Input formats

\n
    \n
  • PNG, JPEG (including Photoshop's CMYK), TIFF, AVIF, JPEG XL and HEIC, as\nstill images
  • \n
  • WebP and GIF, still or animated
  • \n
  • PNM: binary PGM, PPM and PAM (8 or 16 bits, with or without alpha), and PFM\nfloating-point images
  • \n
  • Y4M video frames (the first frame): any chroma format, 8 to 16 bits, with or without\nalpha
  • \n
  • PGX, the JPEG 2000 test format (gray, 1 to 16 bits)
  • \n
  • OpenEXR (.exr): half or float RGB, RGBA or gray, any channel layer, every compression\nbut HTJ2K
  • \n
  • Raw YUV (.yuv): bare 8-bit I420 planes, at the size you give, as cwebp -s reads them
  • \n
  • SVG (and gzip-compressed .svgz), drawn at its own size, or straight at the size a resize asks for (only the crop, if there is one) so its edges\nstay sharp, with its transparency kept; text in the system's fonts, and only images\nembedded in the file (one it links to is never opened)
  • \n
  • Each file's format is recognised from its contents, not its extension (except raw YUV,\nwhich has no header to recognise). An AVIF's crop,\nrotation and mirror are applied, so a sideways-stored portrait converts upright, and\ntiled (grid) AVIFs, as some cameras write large captures, are decoded whole.
  • \n
\n

Output formats, each with its reference encoder's whole command line for a still image

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
FormatReference toolEncoder in the appByte-for-byte parity, tested
WebPcwebp 1.6.0libwebp 1.6.03,091 cases: every option, its no-option defaults, through PNG (every colour type, gamma), JPEG, TIFF (including premultiplied alpha, and straight alpha read as cwebp reads it on request), WebP and PNM files, raw YUV (-s), and -metadata
Animated WebPimg2webp, gif2webp and webpmux 1.6.0libwebp 1.6.0215 cases: animated WebP and GIF input, every Skidbladnir setting those tools can express, their -mixed, -min_size, -kmin/-kmax, -loop, -loop_compatibility and -d for every frame, img2webp's frame options (-lossy/-lossless, -q, -m, -exact/-noexact, -d) changed from any frame on, and metadata (gif2webp -metadata, webpmux -set)
AVIFavifenc 1.4.2libavif 1.4.2 + libaom 3.15.1231 cases: every option, PNG inputs, JPEG and Y4M input
JPEG XLcjxl 0.12.0libjxl 0.12.0551 cases: every option, PNG, PNM, PFM, PGX, EXR, still-GIF and JPEG XL inputs, JPEG recompression and decoding
HEICheif-enc -e kvazaar 1.23.5libheif 1.23.5 + Kvazaar 2.3.2182 cases: every option, PNG, JPEG, TIFF (RGB, straight alpha, and premultiplied alpha read as heif-enc reads it on request), WebP, HEIC and 8-bit 4:2:0 Y4M inputs
\n

That is 4,270 cases in all. CI runs every one of them on Linux x86-64, Linux ARM64,\nmacOS with Apple silicon and Windows x64, for every pull request and every push to\nmaster.

\n

The GPL edition writes HEIC with x265 instead, adding heif-enc's -L\nlossless, 4:4:4 and 4:2:2 chroma, 10-bit output, x265's main tuning controls and any\nother x265 parameter (-p x265:KEY=VALUE); its HEIC is checked by decoding, not byte for\nbyte against a reference.

\n
    \n
  • WebP: lossless and near-lossless, -exact, the libwebp presets and -z lossless\nlevels, targets by size or PSNR, every lossy tuning control (SNS, segments, filter\ntype, strength, sharpness and auto-filter, passes, quality range, pre-processing,\npartition limit, JPEG-like), alpha quality, method and filtering, -noalpha,\n-blend_alpha, and -metadata.
  • \n
  • AVIF: colour and alpha quality (or leave them to avifenc), speed, lossless, 8,\n10 or 12 bits and the 16-bit depth extension, 4:4:4, 4:2:2, 4:2:0 or 4:0:0, sharp YUV,\npremultiplied alpha, CICP and range, target size, progressive layers, grids, tiling,\nscaling, quantizers, libaom's own options, pixel aspect, clean aperture, rotation,\nmirroring, content light level, and ICC, Exif and XMP from the input, left out or from a\nfile.
  • \n
  • JPEG XL: distance or quality, alpha distance, effort (up to 11), lossless JPEG\nrecompression — a JPEG repacked typically about 20% smaller (libjxl's figure) and\nrebuildable bit for bit, and the default for JPEG input — and every one of cjxl's\nmodular, progressive, filter, colour-space, metadata and container options.
  • \n
  • HEIC, the format iPhones use: quality, Kvazaar's lossless coding, chroma\ndownsampling, the colour-profile presets and custom code points, thumbnails, alpha and\npremultiplied alpha, rotation and mirroring, tiles, 360° projection, a description,\ncompatible brands and the compact mini format.
  • \n
  • Metadata: a photo's ICC colour profile, Exif and XMP are kept as each reference\ntool keeps them: in WebP when you ask, as cwebp -metadata does (off by default, as in\ncwebp); in AVIF, JPEG XL and HEIC by default, as avifenc, cjxl and heif-enc do,\nwith each kind switchable off.
  • \n
  • Crop and resize (with -resize_mode), shared by every format.
  • \n
\n

Workflow

\n
    \n
  • Preview the result beside the original, in any format, before anything is written to\ndisk. AVIF and JPEG XL previews are decoded in Rust, so they show on every platform.
  • \n
  • Animated WebP or GIF in, animated WebP out: every frame, its timing and its loop count\nkept (or a loop count of your own), every WebP setting applied to each frame, the\nanimation encoder's keyframe spacing, mixed lossy/lossless and minimum-size options,\nand a preview of the animation first.\nA GIF converts as gif2webp converts it, across disposal methods, transparency, timing\nand loop counts, keeping its ICC profile and XMP when you ask, as\ngif2webp -metadata does.
  • \n
  • Drop files onto the window (any it cannot read are set aside), or convert a whole\nfolder, optionally with its subfolders, recreating their structure in the destination.
  • \n
  • Batch conversion with progress per file, and a Cancel button that never leaves a\nhalf-written image behind.
  • \n
  • The original and converted sizes, and the dimensions produced, reported after each\nconversion; and what an existing WebP already is: size, lossy or lossless, alpha,\nanimated.
  • \n
  • Each format keeps its own settings while you try another. Settings and the destination\nare remembered between launches, and you can save named presets of your own.
  • \n
  • Every control shows the flag it sets, with the expert ones in folding sections. The\nwhole window works from the keyboard, and every control, text and number fields\nincluded, has a name and a role a screen reader can announce. CI checks both on every\nchange: it tabs through each format's panel and checks every control is reached, and\nreads the window the way a screen reader does, over AT-SPI on Linux and UI Automation\non Windows (and with axe in the webview window). It also drops a file onto the window\nas a file manager does, through X11 on Linux and by dragging it out of Explorer on\nWindows, and converts it.
  • \n
  • Your source image is never overwritten, and every write goes through a temporary file,\nso a failed conversion cannot damage a file that was already there. Before a run, the\nwindow warns when two queued files would be written to the same name (photo.png and\nphoto.jpg both become photo.webp) and when outputs are already in the destination.\nThose are replaced, as cwebp -o would replace them, unless you turn off Replace\nexisting files in the app settings, which keeps them and skips those inputs.
  • \n
  • About and licences, in the app settings, shows the version, the edition and its\nlicence, and the licence texts and third-party notices the app carries.
  • \n
\n

Install

\n

Download the file for your computer from the\nlatest release\n(<version> is the release's version number):

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Your computerFile to download
Windows 10 or 11, 64-bitSkidbladnir_<version>_x64-setup.exe
Mac with Apple silicon (M1 or later), experimentalSkidbladnir_<version>_aarch64.dmg
Mac with an Intel processor, experimentalSkidbladnir_<version>_x64.dmg
Ubuntu 22.04 or later, Debian 12 or later, Linux Mint 21 or later, and distributions based on themSkidbladnir_<version>_amd64.deb
Fedora, openSUSE and other RPM-based distributions with glibc 2.35 or laterSkidbladnir_<version>_x86_64.rpm
Any other 64-bit Linux with glibc 2.35 or laterSkidbladnir_<version>_amd64.AppImage
Linux on 64-bit ARM (a Raspberry Pi 4 or 5 on a 64-bit system, ARM laptops and servers), glibc 2.35 or laterSkidbladnir_<version>_arm64.deb, Skidbladnir_<version>_aarch64.rpm or Skidbladnir_<version>_aarch64.AppImage, as above
\n

Not sure which edition? Download the standard edition: the files above, named\nSkidbladnir_… with an underscore. Choose the GPL edition, the same files named\nSkidbladnir-GPL_…, only if you write HEIC and want the x265 encoder: heif-enc -L\nlossless, 4:4:4 and 4:2:2 chroma, 10-bit, x265's preset and tune, its main tuning\ncontrols, and any other x265 parameter. Both are free. The licence matters only if you\nredistribute the app; Editions has the details. On ARM Linux the GPL\nedition's x265 runs without its assembly (it cannot be linked into the shared libheif\nthere), so its HEIC encodes are slower; the files are the same.

\n

The release page lists other files too; they are not installers. The .app.tar.gz files\nare for the in-app updater on a Mac. Skidbladnir-<version>-source.tar.gz is the complete\nsource with every submodule; GitHub's own "Source code" archives leave the submodules out.

\n

There are no builds for 32-bit systems or Windows on ARM. Windows on ARM can run x64\nprograms through emulation, but nobody has tried Skidbladnir there.\nStatus says which builds have been run, and how.

\n

Opening it the first time

\n

The installers are not code-signed yet, so Windows and macOS warn before they open an app\nfrom a developer they cannot identify. This is what to expect, and how to get past it.

\n

Windows. Your browser may warn that the file is not commonly downloaded. Keep it\nanyway. In Edge, open the ... (More actions) menu beside the download, choose\nKeep, then Show more > Keep anyway. In Chrome, choose Keep or Download\nsuspicious file. When you run the installer, SmartScreen shows Windows protected your\nPC: click More info, then Run anyway. Skidbladnir installs for your account\nonly, in %LOCALAPPDATA%\\Skidbladnir, without administrator rights. If Microsoft's\nWebView2 runtime is missing (Windows 10 and 11 normally have it), the installer downloads\nit. If Smart App Control is turned on (Windows Security > App & browser control),\nWindows blocks unsigned apps and offers no exception, so Skidbladnir cannot be installed\non that PC until it is signed.

\n

macOS (experimental). The macOS builds are new: CI builds them, runs the test suite\non macOS and converts to every format through the window there, but nobody has opened the\napp on a real Mac yet, so please\nreport problems. Open the .dmg,\ndrag Skidbladnir into Applications, and open it from there.

\n
    \n
  • On macOS 15 (Sequoia) or later, the first time you open it, macOS says it cannot\nverify the app. Click Done, open System Settings > Privacy & Security, scroll\ndown, click Open Anyway beside the message about Skidbladnir, and confirm.

    \n
  • \n
  • On macOS 14 or earlier, right-click (or Control-click) Skidbladnir in Applications,\nchoose Open, then choose Open again.

    \n
  • \n
  • If macOS says that Skidbladnir is damaged and can't be opened, there is no Open Anyway\nbutton. Remove the quarantine flag your browser set, in Terminal, then open it again:

    \n
    xattr -dr com.apple.quarantine /Applications/Skidbladnir.app
  • \n
\n

Linux. Both packages need glibc 2.35 or later: Ubuntu 22.04, Debian 12 or newer.

\n
    \n
  • The .deb (Ubuntu, Debian, Linux Mint): in the folder you downloaded it to, run\nsudo apt install ./Skidbladnir_<version>_amd64.deb. apt installs what it needs\n(xkbcommon and the Vulkan loader for the window, and WebKitGTK and GTK for the webview\nwindow it falls back to) and adds Skidbladnir to your applications menu.
  • \n
  • The AppImage (any distribution): run chmod +x Skidbladnir_<version>_amd64.AppImage,\nthen ./Skidbladnir_<version>_amd64.AppImage. It adds no menu entry. It needs FUSE: a\nfusermount3 or fusermount program (the fuse3 or fuse package) and /dev/fuse;\nlibfuse2 is not needed. Where FUSE is not available, run it with\n--appimage-extract-and-run.
  • \n
\n

Checking a download

\n

The release page lists a SHA-256 checksum beside each file. Work out the checksum of the\nfile you downloaded and compare the two:

\n
    \n
  • Linux: sha256sum Skidbladnir_<version>_amd64.deb
  • \n
  • macOS: shasum -a 256 Skidbladnir_<version>_aarch64.dmg
  • \n
  • Windows, in PowerShell: Get-FileHash .\\Skidbladnir_<version>_x64-setup.exe (it uses\nSHA-256 unless told otherwise)
  • \n
\n

If they differ, the file is not the one the release published: delete it and download it\nagain.

\n

Updates

\n

Once installed, Skidbladnir checks for a newer release each time it starts and offers it\nin a banner; nothing is downloaded until you click Install and restart, and every\nupdate is verified against the project's signing key before it is installed. A .deb\ninstall updates with a .deb and an .rpm install with an .rpm (each asks for your\npassword to do it); an AppImage, the\nWindows installer and the macOS app update themselves in place. Each edition updates only\nto the same edition; to switch, install the other one over it. Releases before 0.8.0\nhave no updater, so moving off them is a manual download, once.

\n

Privacy

\n

Skidbladnir has no telemetry and no analytics, and your images never leave your\ncomputer. The app makes one network request of its own: when it starts, it fetches a\nsmall update manifest (latest.json, or latest-gpl.json for the GPL edition) from\nraw.githubusercontent.com. Like any web request, that shows GitHub your IP address;\nnothing about you or your files is sent. An update is downloaded from GitHub, and\ninstalled, only when you click Install and restart.

\n

Editions

\n

The two editions are the same app except for how they write HEIC, and so for their\nlicence:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Standard (Skidbladnir_*)GPL (Skidbladnir-GPL_*)
HEIC encoderKvazaar (BSD-3-Clause)x265 (GPL-2.0-or-later)
HEIC controlsevery heif-enc option Kvazaar honoursthose, plus -L lossless, 4:4:4 and 4:2:2 chroma, 10-bit, and x265's preset, tune, TU depth, AQ, psy-rd/psy-rdoq, deblock and SAO as controls, with any other -p x265: parameter by name
HEIC checkedbyte for byte against heif-enc -e kvazaarby decoding
Licence of the buildISC; ships libheif/libde265 as a separate, replaceable LGPL-3.0 libraryGPL-3.0-or-later, as a whole
\n

The GPL edition exists for people who want x265, the HEVC encoder a default heif-enc\nbuild uses, and x265 is GPL: an app that ships it is distributed under the GPL.\nSkidbladnir's own source is ISC either way. Everything but HEIC output — WebP, AVIF,\nJPEG XL, and HEIC input — is identical in both. In both, libheif ships as a separate\nshared library beside the app, with its codec plugins switched off, so the standard\nedition can never pick up x265 from your system.

\n

Each release attaches Skidbladnir-<version>-source.tar.gz, the complete source with\nevery submodule, which is what the GPL edition's licence requires to be offered with it.\nThe Rust crates and npm packages it compiles in are pinned by Cargo.lock and\nfrontend/package-lock.json in that archive, and published unmodified on crates.io and\nnpmjs.com.

\n

What "every option" means

\n

The claim is tested, not assumed: for each format, a test runs the reference tool and\nSkidbladnir on the same input with the same options, across the whole option surface and\nacross the inputs the tool reads differently (PNG colour types and chunks, JPEG chroma\nsamplings, CMYK, metadata), and requires identical bytes. CI builds each reference from the\nsame source as the library the app links (scripts/build-reference-tools.sh). Precisely:

\n
    \n
  • Still images, and animated WebP out of an animated WebP or a GIF, as img2webp and\ngif2webp write it, including their keyframe (-kmin/-kmax), -mixed, -min_size,\n-loop and -loop_compatibility options and img2webp -d for every frame. A different\nquality or duration for each frame is not offered yet. Neither are other options\nabout more than one image: animated AVIF, HEIC or JPEG XL, image sequences,\navifenc --layered and grids assembled from several files, heif-enc with several\ninputs or -T tiled input, and cjxl from an animated GIF or APNG. They are queued in\nROADMAP.md, Phase 8.
  • \n
  • The input formats Skidbladnir reads: PNG, JPEG, TIFF, WebP, AVIF, JPEG XL, HEIC,\nGIF, PNM/PAM, PFM, PGX, Y4M, SVG, EXR and raw YUV. Raw YUV is read as cwebp -s reads it:\ninto WebP as its planes are, byte for byte, and converted to RGB by libwebp for the other\nformats, whose tools do not read it. EXR is read as cjxl reads it — linear light, its\npremultiplied alpha, chromaticities and white luminance, and its other channels kept as\nnamed extra channels — and matched byte for byte. Only cjxl reads EXR, so for the other formats it is given the\nsRGB curve, clipped at white. A JPEG XL converted to JPEG XL is decoded by libjxl as\ncjxl decodes it — its own colour, extra channels, Exif (orientation reset, as the pixels\nare turned upright), XMP, IPTC and its other boxes — and matched byte for byte, except\nthat a JPEG XL of several frames or layers is converted from its pixels.\n(heif-enc --raw cannot write a HEIC in this build, so there is nothing of it to match.) None of them reads SVG, so an SVG has no reference to\nmatch: it is drawn by resvg. cjxl reads only 8- and 16-bit PGX at the right brightness\n(a 12-bit file comes out at a sixteenth of it), so those two depths are matched byte\nfor byte and the others are read as meant. A Y4M is read by avifenc's own reader and encoded to AVIF as\nits planes are, and an 8-bit 4:2:0 one goes to HEIC as heif-enc reads it. heif-enc\nreads every other Y4M as if it were 8-bit 4:2:0 too, garbling it, so those are\nconverted to RGB (BT.601) for HEIC instead, as they are for WebP and JPEG XL, which no\nreference tool reads Y4M for. PNM is read the way cwebp reads it for WebP and the way\ncjxl reads it for JPEG XL (at its own bit depth); PFM is read as cjxl reads it, and\nonly cjxl reads PFM, so PFM to another format has no reference to match. JPEG is read\nthrough libjpeg-turbo 3.2.0, the way each tool reads it — including avifenc's and\nheif-enc's copying of a JPEG's own YCbCr planes — and PNG by libpng 1.6's rules,\nincluding cwebp's gamma correction. For WebP, 16-bit files are reduced to 8 bits the\nway cwebp's libpng or libtiff reduces them, a WebP source takes cwebp's route\nstraight to YUV, and a TIFF with premultiplied (associated) alpha is un-multiplied as\ncwebp un-multiplies it. A tool linked against a different libjpeg or libpng can\ndecode the same file differently.
  • \n
  • The same encoders, built the same way: libwebp 1.6.0, libavif 1.4.2 with libaom\n3.15.1, libjxl 0.12.0, libheif 1.23.5 with Kvazaar 2.3.2. Another version of a tool\nwrites other bytes, as it would against itself. The app's libavif and the reference\navifenc are both built without libyuv. A packaged avifenc (Homebrew, most Linux\ndistributions) usually links libyuv, which converts RGB to YUV differently, so it\nwrites other bytes even at libavif 1.4.2.
  • \n
  • Tested on Linux (x86-64 and ARM64), macOS on Apple silicon and Windows x64. CI\nbuilds the reference tools and runs the parity tests on each, so the encoders' x86 and\nARM code paths, and their MSVC builds, are each held to the tools. One exception in\nwhat is compared: for an EXR whose data window does not cover its display window, cjxl\nencodes whatever memory it was given for the uncovered pixels (libjxl 0.12.0 never writes\nthem): zero on Linux and macOS by chance, other bytes on Windows. Skidbladnir makes them zero, and those\ncases are held to cjxl reading the same image with the zeros written out. The Intel Mac build\nlinks the same sources, but no reference tool runs there, so identical bytes there are\nexpected, not tested.
  • \n
  • AVIF with libaom, avifenc's default codec: -c rav1e and -c svt are not built\nin. JPEG gain-map conversion needs avifenc built with libxml2, which it is not by\ndefault, and is not offered.
  • \n
  • HEIC with Kvazaar in the standard edition, the BSD-licensed HEVC encoder; the\ndefault heif-enc build uses x265, which is GPL, and is the GPL edition's\nencoder. heif-enc -L fails with Kvazaar, so lossless is Kvazaar's own\n(-p lossless=true), and 16-bit PNGs, which heif-enc hands Kvazaar at 10 bits and\nKvazaar refuses, are encoded from their high bytes instead. The GPL edition's x265\noutput is checked by decoding it, not byte for byte.
  • \n
  • WebP's starting settings. Until you change them, the WebP controls start from\nSkidbladnir's own long-standing defaults (-af -alpha_filter best -pass 6 -mt), not\nfrom what cwebp does with no flags. To start where cwebp starts, click Start from\ncwebp's defaults in the WebP panel (tested against cwebp given no options at all),\nthen set the flags you use.
  • \n
  • Options that change how a tool runs but not the file — verbosity, timing, benchmarks,\nprinting statistics — have no control, and neither do cwebp's dump and map outputs.
  • \n
\n

Where a tool refuses an input — cwebp a CMYK JPEG, heif-enc an RGB-coded one —\nSkidbladnir converts it anyway. Two tools misread a TIFF's transparency, and by default\nSkidbladnir reads it correctly instead, keeping the colours the file means:

\n
    \n
  • Straight (unassociated) alpha, to WebP: cwebp reads such a TIFF premultiplied,\nthrough libtiff, and darkens its semi-transparent pixels.
  • \n
  • Premultiplied (associated) alpha, to HEIC: heif-enc takes that alpha as straight,\nso its HEIC is darker where the image is semi-transparent. (Skidbladnir un-multiplies\nit, as cwebp does.)
  • \n
\n

To get the tools' results instead, turn on TIFF input → Read transparency as the\nofficial tool does: both are then reproduced byte for byte (tested against cwebp and\nheif-enc). avifenc and cjxl do not read TIFF, so AVIF and JPEG XL are unaffected.

\n

Besides the byte comparisons, the output is decoded with each format's own tools. AVIF is\ndecoded with libavif's avifdec, and the tests measure how close the pixels come back and\ncheck that transparency survives. JPEG XL is decoded with libjxl's djxl, which must\ndecode it at the right size and alpha, a lossless file exactly, and rebuild a recompressed\nJPEG byte for byte. HEIC is decoded with libheif's heif-dec and heif-info, from a\nseparate build; that is how the GPL edition's x265 output is checked. Metadata is checked\nagainst cwebp itself and with avifdec, djxl and heif-info. AVIF input is checked\nagainst avifdec across 17 colour encodings (bit depths, chroma subsampling, range,\ncolour matrices, alpha), and JPEG XL input against djxl.

\n

How it compares

\n

As each project describes itself on its own page, checked on 1 October 2026. "Not\nstated" means its page does not say, not that it cannot.

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Runs asBatchWritesEncoder optionsByte parity with the reference tools
Skidbladnirdesktop app: Windows, macOS, Linux (x86-64 and ARM64)yes, files or whole foldersWebP, AVIF, JPEG XL, HEICevery option of cwebp, avifenc, cjxl and heif-enc for a still imageyes, tested: 4,270 cases (above)
Squooshweb app in the browser (installable), nothing uploadednot statedMozJPEG, WebP, AVIF, JPEG XL, OxiPNG, QOI, WebP 2 (its codec list); no HEICnot statednot stated
XnConvertdesktop app: Windows, macOS, Linux; free for private or educational use, paid in a companyyesabout 70 formats, including WebP, AVIF, JPEG XL and HEICnot statednot stated
Converseendesktop app on ImageMagick: Windows, Linux, macOS, FreeBSD; GPL-3.0yesover 100 formats, including WebP, AVIF and HEIC; JPEG XL not mentionednot statednot stated
The reference command-line toolscommand lineby scriptingone format eachall of them, by definitionthey are the reference
\n

Build from source

\n

Requires a stable Rust toolchain, Node.js and npm, nasm — libaom, libjpeg-turbo and\nthe AV1 decoder assemble their SIMD code with it, and x265 in the GPL edition (Arch:\nnasm; Debian/Ubuntu: nasm; macOS: brew install nasm; Windows: choco install nasm)\n— and CMake with a C++ compiler, which build libjxl, libavif with libaom, and\nlibjpeg-turbo from source so they are linked statically. Clone with\n--recurse-submodules: those sources are pinned under third_party/. HEIC needs libheif,\nbuilt with scripts/build-libheif.sh from the same pinned sources into build/libheif;\nwithout that build, the system libheif is linked instead. On Linux you also need\nthe development headers of webkit2gtk-4.1, and of xkbcommon (with xkbcommon-x11), xcb,\nWayland, fontconfig and FreeType for the gpui window.

\n
npm --prefix frontend install        # also installs the Tauri CLI\ncd src-tauri && ../frontend/node_modules/.bin/tauri build --no-bundle

Run it with ../frontend/node_modules/.bin/tauri dev from src-tauri/, which starts the\nNuxt dev server and the window together. Release builds must go through tauri build\nrather than cargo build --release: the custom-protocol feature that embeds the\nfrontend is only enabled by the former.

\n

Without CMake, the build links the system libjxl, libavif and libjpeg through\npkg-config instead, and says so. Set SKIDBLADNIR_LIBJXL, SKIDBLADNIR_LIBAVIF or\nSKIDBLADNIR_LIBJPEG to vendored to insist on the static build, or to system to insist\non the system library.

\n

The GPL edition is the gpl feature (or full, every optional feature), with a libheif\nbuilt around x265 into build/libheif-gpl. The build refuses to link either edition\nagainst the other's libheif.

\n
scripts/build-libheif.sh --edition gpl\ncd src-tauri && ../frontend/node_modules/.bin/tauri build --features gpl --config tauri.gpl.conf.json

tauri.gpl.conf.json (and on Windows tauri.gpl.windows.conf.json too) bundles x265's\nlibheif, the GPL edition's notices and the GPL text, and points the updater at the GPL\nedition's releases.

\n

The encoders are the libraries themselves, linked into the binary — there is no command-line\ntool to download and no subprocess.

\n

Checking a build

\n
cargo test --workspace     # includes the encoder-parity tests\nscripts/smoke-test.sh      # drives the webview window: renders, IPC, a real conversion\nscripts/a11y-audit.sh      # axe-core against the live webview window\nscripts/gpui-smoke.sh      # opens the gpui window and converts to every format

The parity tests compare each encoder with its reference tool and need them built from\nthe same sources as the libraries the app links, reading JPEG through the same\nlibjpeg-turbo: scripts/build-reference-tools.sh builds cwebp, img2webp, gif2webp,\navifenc, cjxl and heif-enc and prints the SKIDBLADNIR_REFERENCE_* variables that\npoint the tests at them. The decoding checks use libavif's avifdec and avifenc,\nlibjxl's djxl and libheif's heif-dec and heif-info from PATH. Missing tools make\nthe tests say so and skip rather than pass quietly; set SKIDBLADNIR_REQUIRE_PARITY=1 to turn a skip\ninto a failure.

\n

The two scripts need tauri-driver (cargo install tauri-driver) and WebKitWebDriver\n(webkit2gtk-driver on Debian/Ubuntu, webkitgtk-6.0 on Arch). They skip, loudly, when\nthose are missing.

\n

Status

\n

Skidbladnir is a Rust + Tauri 2 app for Windows, Linux and macOS, and it builds and\npasses its tests on all three in CI. Since 1.3.0 its window is drawn natively with\ngpui (crates/skidbladnir-gpui): the same window, control for\ncontrol, in about a third of the memory and starting about 30% sooner. The Nuxt +\nTailwind webview window it replaces is still built in. It opens when the app is started\nwith SKIDBLADNIR_UI=webview, or by itself when the gpui window cannot start (on a\nmachine with no GPU driver it can use, say). Releases are\nstable from 1.0.0; the ones before it were prereleases. The work queue lives in\nROADMAP.md.

\n

Skidbladnir used to be an Electron app for Windows. That app has been retired: its last\nbinary release is v0.4.3\n(2019), and its last source is the\nelectron-final tag.

\n

Known gaps:

\n
    \n
  • macOS is experimental. The .dmgs (Apple silicon and Intel) are built by CI, and\nthe test suite runs on macOS, where CI also opens the window and converts to every\nformat with it, but nobody has yet opened the app on a real Mac. The\nstandard edition's Windows installer is installed and exercised by CI on every change,\nand the Linux app (both editions) is launched and driven by CI on every change, though\nnot from its .deb or AppImage. Each release's .deb is installed by CI on Ubuntu 22.04\nand Debian 12, and its .rpm on the latest Fedora, on x86-64 and ARM64 alike, and\nchecked to find every library it needs; the ARM64 app itself has not been launched. The\npublished 0.5.0 .deb and AppImage were run through that release's smoke test by hand.
  • \n
  • Nobody has used Skidbladnir with a screen reader yet (Orca, Narrator, NVDA or\nVoiceOver). What one is given is checked automatically on Linux and Windows, not on\nmacOS. Before 1.4.1 the window's text and number fields (sizes, crops, exact values)\nwere missing from it entirely.
  • \n
  • The app is not code-signed on any platform;\nOpening it the first time shows how to get past each\nsystem's warning.
  • \n
  • An AVIF, JPEG XL or HEIC encode cannot be cancelled mid-file: none of those encoders\nreports progress, so Cancel takes effect when the current file finishes (which is then\ndiscarded, not written).
  • \n
  • An animated AVIF, JPEG XL or HEIC is read as one still image: its primary image or\nfirst frame.
  • \n
  • Animated AVIF, JPEG XL and HEIC are not written (see below), so an animation's metadata\nis kept in animated WebP only: a GIF's ICC profile and XMP as gif2webp -metadata keeps\nthem, an animated WebP's ICC profile, Exif and XMP as webpmux -set would set them.
  • \n
  • An animation (animated WebP or GIF) converts to WebP only. AVIF, JPEG XL and HEIC\noutput refuse it by name rather than keeping just the first frame, because those\nencoders write still images here. A target size or PSNR applies to each frame of an\nanimation, not to the whole file.
  • \n
  • JPEG XL files open in Safari, and in Firefox and Chrome as each enables it by default;\nuntil then, most web pages cannot show them. The long-promised JPEG 2000 was dropped\nas a goal; it has no momentum outside medical and archival imaging.
  • \n
  • The standard edition writes HEIC 8-bit 4:2:0 only (Kvazaar), so its lossless coding is\nlossless after the conversion to 4:2:0, as with heif-enc; -L lossless, 4:4:4 and\n10-bit take the GPL edition. Neither writes 12-bit HEIC.
  • \n
  • HEVC patents. HEIC is HEVC (H.265) inside HEIF, and HEVC is covered by patents\nlicensed through patent pools such as Access Advance and Via LA. Skidbladnir includes\nopen-source HEVC software: Kvazaar (x265 in the GPL edition) to write HEIC, and libde265\nto read it. The project holds no HEVC patent licence and passes none on. Whether you\nneed one depends on where you are and what you do with the files; this is not legal\nadvice.
  • \n
\n

Acknowledgements

\n

Skidbladnir is a window around other people's encoders, and is only as good as they are:\nlibwebp (the WebM project, Google),\nlibavif and\nlibaom (the Alliance for Open Media),\nlibjxl (the JPEG XL authors),\nlibheif and\nlibde265 (struktur AG),\nKvazaar (Ultra Video Group, Tampere University),\nx265 (MulticoreWare) and\nlibjpeg-turbo (which includes the work of the Independent\nJPEG Group). The decoders are rav1d through\navif-decode, and\njxl-oxide. The window is\nTauri, Nuxt, Nuxt UI and\nTailwind CSS, set in Fira Code,\nwith icons from the collections THIRD-PARTY-NOTICES.md credits.

\n

Skidbladnir is an independent project. It is not affiliated with, sponsored or endorsed\nby Google, the Alliance for Open Media, the JPEG committee, Apple, Microsoft, or any of\nthe projects above. WebP, AVIF, JPEG XL, HEIC and the other names here are used only to\nsay which formats and tools it works with; they belong to their owners.

\n

License

\n

Skidbladnir's own code is ISC (LICENSE), as declared in the workspace\nCargo.toml, apart from code adapted from the encoders, which keeps its licence:\ncrates/skidbladnir-encode/native/avif_shim.c carries portions of libavif's avifenc\nunder BSD-2-Clause.

\n

The standard edition's own code is ISC; the libraries it ships keep their licences,\nincluding LGPL-3.0 for libheif and libde265, which ship as a separate shared library you\nmay replace with your own build. The GPL edition's builds include x265, so each of them\nis distributed as a whole under the GNU GPL, version 3 or later\n(LICENSES/GPL-3.0.txt); see Editions.

\n

The native libraries each edition ships, the JavaScript packages in its webview window and\nthe Rust crates compiled into it, with their licences, are listed in\nTHIRD-PARTY-NOTICES.md and\nTHIRD-PARTY-NOTICES-GPL.md, and every installer carries its\nown edition's copy with its licence texts.

\n", + "readmeHtml": "

Skidbladnir

\n

\"CI\"\n\"Latest\n\"Platforms:

\n

Skidbladnir makes image files smaller by converting them to the modern formats:\nWebP, AVIF, JPEG XL and HEIC. It is for anyone who wants smaller images for\na website, an archive or a photo library without learning four command-line tools. Pick\nyour files, see the result beside the original, then convert one image, a batch or a\nwhole folder.

\n

Free and open source · Windows, macOS (experimental) and Linux · converts on your\ncomputer; the only network request is the in-app update check

\n

Download ·\nWhich file to pick ·\nProject page

\n

Each of these formats has an official encoder, and each one is a command-line tool with\ndozens of options that are easy to forget and easy to get wrong. Skidbladnir puts those\noptions in a window, each labelled with the flag it sets, instead of a single quality\nslider.

\n

For a still image, every option of cwebp, avifenc (with libaom), cjxl and\nheif-enc (with Kvazaar) is a control in the window, and Skidbladnir writes the same bytes\nas those tools built from the same library versions, checked in CI against the real tools,\nwith the exceptions listed under What "every option" means.

\n

\"The

\n

AVIF selected: avifenc's options, each labelled with its flag.

\n
\nWith WebP selected: the whole panel, and a preview

\"The

\n

WebP selected, with a file previewed: every cwebp option, each labelled with its flag.

\n

Features

\n

Input formats

\n
    \n
  • PNG, JPEG (including Photoshop's CMYK), TIFF, AVIF, JPEG XL and HEIC, as\nstill images
  • \n
  • WebP and GIF, still or animated
  • \n
  • PNM: binary PGM, PPM and PAM (8 or 16 bits, with or without alpha), and PFM\nfloating-point images
  • \n
  • Y4M video frames (the first frame): any chroma format, 8 to 16 bits, with or without\nalpha
  • \n
  • PGX, the JPEG 2000 test format (gray, 1 to 16 bits)
  • \n
  • OpenEXR (.exr): half or float RGB, RGBA or gray, any channel layer, every compression\nbut HTJ2K
  • \n
  • Raw YUV (.yuv): bare 8-bit I420 planes, at the size you give, as cwebp -s reads them
  • \n
  • SVG (and gzip-compressed .svgz), drawn at its own size, or straight at the size a resize asks for (only the crop, if there is one) so its edges\nstay sharp, with its transparency kept; text in the system's fonts, and only images\nembedded in the file (one it links to is never opened)
  • \n
  • Each file's format is recognised from its contents, not its extension (except raw YUV,\nwhich has no header to recognise). An AVIF's crop,\nrotation and mirror are applied, so a sideways-stored portrait converts upright, and\ntiled (grid) AVIFs, as some cameras write large captures, are decoded whole.
  • \n
\n

Output formats, each with its reference encoder's whole command line for a still image

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
FormatReference toolEncoder in the appByte-for-byte parity, tested
WebPcwebp 1.6.0libwebp 1.6.03,091 cases: every option, its no-option defaults, through PNG (every colour type, gamma), JPEG, TIFF (including premultiplied alpha, and straight alpha read as cwebp reads it on request), WebP and PNM files, raw YUV (-s), and -metadata
Animated WebPimg2webp, gif2webp and webpmux 1.6.0libwebp 1.6.0215 cases: animated WebP and GIF input, every Skidbladnir setting those tools can express, their -mixed, -min_size, -kmin/-kmax, -loop, -loop_compatibility and -d for every frame, img2webp's frame options (-lossy/-lossless, -q, -m, -exact/-noexact, -d) changed from any frame on, and metadata (gif2webp -metadata, webpmux -set)
AVIFavifenc 1.4.2libavif 1.4.2 + libaom 3.15.1231 cases: every option, PNG inputs, JPEG and Y4M input
JPEG XLcjxl 0.12.0libjxl 0.12.0551 cases: every option, PNG, PNM, PFM, PGX, EXR, still-GIF and JPEG XL inputs, JPEG recompression and decoding
HEICheif-enc -e kvazaar 1.23.5libheif 1.23.5 + Kvazaar 2.3.2182 cases: every option, PNG, JPEG, TIFF (RGB, straight alpha, and premultiplied alpha read as heif-enc reads it on request), WebP, HEIC and 8-bit 4:2:0 Y4M inputs
\n

That is 4,270 cases in all. CI runs every one of them on Linux x86-64, Linux ARM64,\nmacOS with Apple silicon and Windows x64, for every pull request and every push to\nmaster.

\n

The GPL edition writes HEIC with x265 instead, adding heif-enc's -L\nlossless, 4:4:4 and 4:2:2 chroma, 10-bit output, x265's main tuning controls and any\nother x265 parameter (-p x265:KEY=VALUE); its HEIC is checked by decoding, not byte for\nbyte against a reference.

\n
    \n
  • WebP: lossless and near-lossless, -exact, the libwebp presets and -z lossless\nlevels, targets by size or PSNR, every lossy tuning control (SNS, segments, filter\ntype, strength, sharpness and auto-filter, passes, quality range, pre-processing,\npartition limit, JPEG-like), alpha quality, method and filtering, -noalpha,\n-blend_alpha, and -metadata.
  • \n
  • AVIF: colour and alpha quality (or leave them to avifenc), speed, lossless, 8,\n10 or 12 bits and the 16-bit depth extension, 4:4:4, 4:2:2, 4:2:0 or 4:0:0, sharp YUV,\npremultiplied alpha, CICP and range, target size, progressive layers, grids, tiling,\nscaling, quantizers, libaom's own options, pixel aspect, clean aperture, rotation,\nmirroring, content light level, and ICC, Exif and XMP from the input, left out or from a\nfile.
  • \n
  • JPEG XL: distance or quality, alpha distance, effort (up to 11), lossless JPEG\nrecompression — a JPEG repacked typically about 20% smaller (libjxl's figure) and\nrebuildable bit for bit, and the default for JPEG input — and every one of cjxl's\nmodular, progressive, filter, colour-space, metadata and container options.
  • \n
  • HEIC, the format iPhones use: quality, Kvazaar's lossless coding, chroma\ndownsampling, the colour-profile presets and custom code points, thumbnails, alpha and\npremultiplied alpha, rotation and mirroring, tiles, 360° projection, a description,\ncompatible brands and the compact mini format.
  • \n
  • Metadata: a photo's ICC colour profile, Exif and XMP are kept as each reference\ntool keeps them: in WebP when you ask, as cwebp -metadata does (off by default, as in\ncwebp); in AVIF, JPEG XL and HEIC by default, as avifenc, cjxl and heif-enc do,\nwith each kind switchable off.
  • \n
  • Crop and resize (with -resize_mode), shared by every format.
  • \n
\n

Workflow

\n
    \n
  • Preview the result beside the original, in any format, before anything is written to\ndisk. AVIF and JPEG XL previews are decoded in Rust, so they show on every platform.
  • \n
  • Animated WebP or GIF in, animated WebP out: every frame, its timing and its loop count\nkept (or a loop count of your own), every WebP setting applied to each frame, the\nanimation encoder's keyframe spacing, mixed lossy/lossless and minimum-size options,\nand a preview of the animation first.\nA GIF converts as gif2webp converts it, across disposal methods, transparency, timing\nand loop counts, keeping its ICC profile and XMP when you ask, as\ngif2webp -metadata does.
  • \n
  • Drop files onto the window (any it cannot read are set aside), or convert a whole\nfolder, optionally with its subfolders, recreating their structure in the destination.
  • \n
  • Batch conversion with progress per file, and a Cancel button that never leaves a\nhalf-written image behind.
  • \n
  • The original and converted sizes, and the dimensions produced, reported after each\nconversion; and what an existing WebP already is: size, lossy or lossless, alpha,\nanimated.
  • \n
  • Each format keeps its own settings while you try another. Settings and the destination\nare remembered between launches, and you can save named presets of your own.
  • \n
  • Every control shows the flag it sets, with the expert ones in folding sections. The\nwhole window works from the keyboard, and every control, text and number fields\nincluded, has a name and a role a screen reader can announce. CI checks both on every\nchange: it tabs through each format's panel and checks every control is reached, and\nreads the window the way a screen reader does, over AT-SPI on Linux and UI Automation\non Windows (and with axe in the webview window). It also drops a file onto the window\nas a file manager does, through X11 on Linux and by dragging it out of Explorer on\nWindows, and converts it.
  • \n
  • Your source image is never overwritten, and every write goes through a temporary file,\nso a failed conversion cannot damage a file that was already there. Before a run, the\nwindow warns when two queued files would be written to the same name (photo.png and\nphoto.jpg both become photo.webp) and when outputs are already in the destination.\nThose are replaced, as cwebp -o would replace them, unless you turn off Replace\nexisting files in the app settings, which keeps them and skips those inputs.
  • \n
  • About and licences, in the app settings, shows the version, the edition and its\nlicence, and the licence texts and third-party notices the app carries.
  • \n
\n

Install

\n

Download the file for your computer from the\nlatest release\n(<version> is the release's version number):

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Your computerFile to download
Windows 10 or 11, 64-bitSkidbladnir_<version>_x64-setup.exe
Mac with Apple silicon (M1 or later), experimentalSkidbladnir_<version>_aarch64.dmg
Mac with an Intel processor, experimentalSkidbladnir_<version>_x64.dmg
Ubuntu 22.04 or later, Debian 12 or later, Linux Mint 21 or later, and distributions based on themSkidbladnir_<version>_amd64.deb
Fedora, openSUSE and other RPM-based distributions with glibc 2.35 or laterSkidbladnir_<version>_x86_64.rpm
Any other 64-bit Linux with glibc 2.35 or laterSkidbladnir_<version>_amd64.AppImage
Linux on 64-bit ARM (a Raspberry Pi 4 or 5 on a 64-bit system, ARM laptops and servers), glibc 2.35 or laterSkidbladnir_<version>_arm64.deb, Skidbladnir_<version>_aarch64.rpm or Skidbladnir_<version>_aarch64.AppImage, as above
\n

Not sure which edition? Download the standard edition: the files above, named\nSkidbladnir_… with an underscore. Choose the GPL edition, the same files named\nSkidbladnir-GPL_…, only if you write HEIC and want the x265 encoder: heif-enc -L\nlossless, 4:4:4 and 4:2:2 chroma, 10-bit, x265's preset and tune, its main tuning\ncontrols, and any other x265 parameter. Both are free. The licence matters only if you\nredistribute the app; Editions has the details. On ARM Linux the GPL\nedition's x265 runs without its assembly (it cannot be linked into the shared libheif\nthere), so its HEIC encodes are slower; the files are the same.

\n

The release page lists other files too; they are not installers. The .app.tar.gz files\nare for the in-app updater on a Mac. Skidbladnir-<version>-source.tar.gz is the complete\nsource with every submodule; GitHub's own "Source code" archives leave the submodules out.

\n

There are no builds for 32-bit systems or Windows on ARM. Windows on ARM can run x64\nprograms through emulation, but nobody has tried Skidbladnir there.\nStatus says which builds have been run, and how.

\n

Opening it the first time

\n

The installers are not code-signed yet, so Windows and macOS warn before they open an app\nfrom a developer they cannot identify. This is what to expect, and how to get past it.

\n

Windows. Your browser may warn that the file is not commonly downloaded. Keep it\nanyway. In Edge, open the ... (More actions) menu beside the download, choose\nKeep, then Show more > Keep anyway. In Chrome, choose Keep or Download\nsuspicious file. When you run the installer, SmartScreen shows Windows protected your\nPC: click More info, then Run anyway. Skidbladnir installs for your account\nonly, in %LOCALAPPDATA%\\Skidbladnir, without administrator rights. If Microsoft's\nWebView2 runtime is missing (Windows 10 and 11 normally have it), the installer downloads\nit. If Smart App Control is turned on (Windows Security > App & browser control),\nWindows blocks unsigned apps and offers no exception, so Skidbladnir cannot be installed\non that PC until it is signed.

\n

macOS (experimental). The macOS builds are new: CI builds them, runs the test suite\non macOS and converts to every format through the window there, but nobody has opened the\napp on a real Mac yet, so please\nreport problems. Open the .dmg,\ndrag Skidbladnir into Applications, and open it from there.

\n
    \n
  • On macOS 15 (Sequoia) or later, the first time you open it, macOS says it cannot\nverify the app. Click Done, open System Settings > Privacy & Security, scroll\ndown, click Open Anyway beside the message about Skidbladnir, and confirm.

    \n
  • \n
  • On macOS 14 or earlier, right-click (or Control-click) Skidbladnir in Applications,\nchoose Open, then choose Open again.

    \n
  • \n
  • If macOS says that Skidbladnir is damaged and can't be opened, there is no Open Anyway\nbutton. Remove the quarantine flag your browser set, in Terminal, then open it again:

    \n
    xattr -dr com.apple.quarantine /Applications/Skidbladnir.app
  • \n
\n

Linux. Both packages need glibc 2.35 or later: Ubuntu 22.04, Debian 12 or newer.

\n
    \n
  • The .deb (Ubuntu, Debian, Linux Mint): in the folder you downloaded it to, run\nsudo apt install ./Skidbladnir_<version>_amd64.deb. apt installs what it needs\n(xkbcommon and the Vulkan loader for the window, and WebKitGTK and GTK for the webview\nwindow it falls back to) and adds Skidbladnir to your applications menu.
  • \n
  • The AppImage (any distribution): run chmod +x Skidbladnir_<version>_amd64.AppImage,\nthen ./Skidbladnir_<version>_amd64.AppImage. It adds no menu entry. It needs FUSE: a\nfusermount3 or fusermount program (the fuse3 or fuse package) and /dev/fuse;\nlibfuse2 is not needed. Where FUSE is not available, run it with\n--appimage-extract-and-run.
  • \n
\n

Checking a download

\n

The release page lists a SHA-256 checksum beside each file. Work out the checksum of the\nfile you downloaded and compare the two:

\n
    \n
  • Linux: sha256sum Skidbladnir_<version>_amd64.deb
  • \n
  • macOS: shasum -a 256 Skidbladnir_<version>_aarch64.dmg
  • \n
  • Windows, in PowerShell: Get-FileHash .\\Skidbladnir_<version>_x64-setup.exe (it uses\nSHA-256 unless told otherwise)
  • \n
\n

If they differ, the file is not the one the release published: delete it and download it\nagain.

\n

Updates

\n

Once installed, Skidbladnir checks for a newer release each time it starts and offers it\nin a banner; nothing is downloaded until you click Install and restart, and every\nupdate is verified against the project's signing key before it is installed. A .deb\ninstall updates with a .deb and an .rpm install with an .rpm (each asks for your\npassword to do it); an AppImage, the\nWindows installer and the macOS app update themselves in place. Each edition updates only\nto the same edition; to switch, install the other one over it. Releases before 0.8.0\nhave no updater, so moving off them is a manual download, once.

\n

Privacy

\n

Skidbladnir has no telemetry and no analytics, and your images never leave your\ncomputer. The app makes one network request of its own: when it starts, it fetches a\nsmall update manifest (latest.json, or latest-gpl.json for the GPL edition) from\nraw.githubusercontent.com. Like any web request, that shows GitHub your IP address;\nnothing about you or your files is sent. An update is downloaded from GitHub, and\ninstalled, only when you click Install and restart.

\n

Editions

\n

The two editions are the same app except for how they write HEIC, and so for their\nlicence:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Standard (Skidbladnir_*)GPL (Skidbladnir-GPL_*)
HEIC encoderKvazaar (BSD-3-Clause)x265 (GPL-2.0-or-later)
HEIC controlsevery heif-enc option Kvazaar honoursthose, plus -L lossless, 4:4:4 and 4:2:2 chroma, 10-bit, and x265's preset, tune, TU depth, AQ, psy-rd/psy-rdoq, deblock and SAO as controls, with any other -p x265: parameter by name
HEIC checkedbyte for byte against heif-enc -e kvazaarby decoding
Licence of the buildISC; ships libheif/libde265 as a separate, replaceable LGPL-3.0 libraryGPL-3.0-or-later, as a whole
\n

The GPL edition exists for people who want x265, the HEVC encoder a default heif-enc\nbuild uses, and x265 is GPL: an app that ships it is distributed under the GPL.\nSkidbladnir's own source is ISC either way. Everything but HEIC output — WebP, AVIF,\nJPEG XL, and HEIC input — is identical in both. In both, libheif ships as a separate\nshared library beside the app, with its codec plugins switched off, so the standard\nedition can never pick up x265 from your system.

\n

Each release attaches Skidbladnir-<version>-source.tar.gz, the complete source with\nevery submodule, which is what the GPL edition's licence requires to be offered with it.\nThe Rust crates and npm packages it compiles in are pinned by Cargo.lock and\nfrontend/package-lock.json in that archive, and published unmodified on crates.io and\nnpmjs.com.

\n

What "every option" means

\n

The claim is tested, not assumed: for each format, a test runs the reference tool and\nSkidbladnir on the same input with the same options, across the whole option surface and\nacross the inputs the tool reads differently (PNG colour types and chunks, JPEG chroma\nsamplings, CMYK, metadata), and requires identical bytes. CI builds each reference from the\nsame source as the library the app links (scripts/build-reference-tools.sh). Precisely:

\n
    \n
  • Still images, and animated WebP out of an animated WebP or a GIF, as img2webp and\ngif2webp write it, including their keyframe (-kmin/-kmax), -mixed, -min_size,\n-loop and -loop_compatibility options and img2webp -d for every frame. A different\nquality or duration for each frame is not offered yet. Neither are other options\nabout more than one image: animated AVIF, HEIC or JPEG XL, image sequences,\navifenc --layered and grids assembled from several files, heif-enc with several\ninputs or -T tiled input, and cjxl from an animated GIF or APNG. They are queued in\nROADMAP.md, Phase 8.
  • \n
  • The input formats Skidbladnir reads: PNG, JPEG, TIFF, WebP, AVIF, JPEG XL, HEIC,\nGIF, PNM/PAM, PFM, PGX, Y4M, SVG, EXR and raw YUV. Raw YUV is read as cwebp -s reads it:\ninto WebP as its planes are, byte for byte, and converted to RGB by libwebp for the other\nformats, whose tools do not read it. EXR is read as cjxl reads it — linear light, its\npremultiplied alpha, chromaticities and white luminance, and its other channels kept as\nnamed extra channels — and matched byte for byte. Only cjxl reads EXR, so for the other formats it is given the\nsRGB curve, clipped at white. A JPEG XL converted to JPEG XL is decoded by libjxl as\ncjxl decodes it — its own colour, extra channels, Exif (orientation reset, as the pixels\nare turned upright), XMP, IPTC and its other boxes — and matched byte for byte, except\nthat a JPEG XL of several frames or layers is converted from its pixels.\n(heif-enc --raw cannot write a HEIC in this build, so there is nothing of it to match.) None of them reads SVG, so an SVG has no reference to\nmatch: it is drawn by resvg. cjxl reads only 8- and 16-bit PGX at the right brightness\n(a 12-bit file comes out at a sixteenth of it), so those two depths are matched byte\nfor byte and the others are read as meant. A Y4M is read by avifenc's own reader and encoded to AVIF as\nits planes are, and an 8-bit 4:2:0 one goes to HEIC as heif-enc reads it. heif-enc\nreads every other Y4M as if it were 8-bit 4:2:0 too, garbling it, so those are\nconverted to RGB (BT.601) for HEIC instead, as they are for WebP and JPEG XL, which no\nreference tool reads Y4M for. PNM is read the way cwebp reads it for WebP and the way\ncjxl reads it for JPEG XL (at its own bit depth); PFM is read as cjxl reads it, and\nonly cjxl reads PFM, so PFM to another format has no reference to match. JPEG is read\nthrough libjpeg-turbo 3.2.0, the way each tool reads it — including avifenc's and\nheif-enc's copying of a JPEG's own YCbCr planes — and PNG by libpng 1.6's rules,\nincluding cwebp's gamma correction. For WebP, 16-bit files are reduced to 8 bits the\nway cwebp's libpng or libtiff reduces them, a WebP source takes cwebp's route\nstraight to YUV, and a TIFF with premultiplied (associated) alpha is un-multiplied as\ncwebp un-multiplies it. A tool linked against a different libjpeg or libpng can\ndecode the same file differently.
  • \n
  • The same encoders, built the same way: libwebp 1.6.0, libavif 1.4.2 with libaom\n3.15.1, libjxl 0.12.0, libheif 1.23.5 with Kvazaar 2.3.2. Another version of a tool\nwrites other bytes, as it would against itself. The app's libavif and the reference\navifenc are both built without libyuv. A packaged avifenc (Homebrew, most Linux\ndistributions) usually links libyuv, which converts RGB to YUV differently, so it\nwrites other bytes even at libavif 1.4.2.
  • \n
  • Tested on Linux (x86-64 and ARM64), macOS on Apple silicon and Windows x64. CI\nbuilds the reference tools and runs the parity tests on each, so the encoders' x86 and\nARM code paths, and their MSVC builds, are each held to the tools. One exception in\nwhat is compared: for an EXR whose data window does not cover its display window, cjxl\nencodes whatever memory it was given for the uncovered pixels (libjxl 0.12.0 never writes\nthem): zero on Linux and macOS by chance, other bytes on Windows. Skidbladnir makes them zero, and those\ncases are held to cjxl reading the same image with the zeros written out. The Intel Mac build\nlinks the same sources, but no reference tool runs there, so identical bytes there are\nexpected, not tested.
  • \n
  • AVIF with libaom, avifenc's default codec: -c rav1e and -c svt are not built\nin. JPEG gain-map conversion needs avifenc built with libxml2, which it is not by\ndefault, and is not offered.
  • \n
  • HEIC with Kvazaar in the standard edition, the BSD-licensed HEVC encoder; the\ndefault heif-enc build uses x265, which is GPL, and is the GPL edition's\nencoder. heif-enc -L fails with Kvazaar, so lossless is Kvazaar's own\n(-p lossless=true), and 16-bit PNGs, which heif-enc hands Kvazaar at 10 bits and\nKvazaar refuses, are encoded from their high bytes instead. The GPL edition's x265\noutput is checked by decoding it, not byte for byte.
  • \n
  • WebP's starting settings. Until you change them, the WebP controls start from\nSkidbladnir's own long-standing defaults (-af -alpha_filter best -pass 6 -mt), not\nfrom what cwebp does with no flags. To start where cwebp starts, click Start from\ncwebp's defaults in the WebP panel (tested against cwebp given no options at all),\nthen set the flags you use.
  • \n
  • Options that change how a tool runs but not the file — verbosity, timing, benchmarks,\nprinting statistics — have no control, and neither do cwebp's dump and map outputs.
  • \n
\n

Where a tool refuses an input — cwebp a CMYK JPEG, heif-enc an RGB-coded one —\nSkidbladnir converts it anyway. Two tools misread a TIFF's transparency, and by default\nSkidbladnir reads it correctly instead, keeping the colours the file means:

\n
    \n
  • Straight (unassociated) alpha, to WebP: cwebp reads such a TIFF premultiplied,\nthrough libtiff, and darkens its semi-transparent pixels.
  • \n
  • Premultiplied (associated) alpha, to HEIC: heif-enc takes that alpha as straight,\nso its HEIC is darker where the image is semi-transparent. (Skidbladnir un-multiplies\nit, as cwebp does.)
  • \n
\n

To get the tools' results instead, turn on TIFF input → Read transparency as the\nofficial tool does: both are then reproduced byte for byte (tested against cwebp and\nheif-enc). avifenc and cjxl do not read TIFF, so AVIF and JPEG XL are unaffected.

\n

Besides the byte comparisons, the output is decoded with each format's own tools. AVIF is\ndecoded with libavif's avifdec, and the tests measure how close the pixels come back and\ncheck that transparency survives. JPEG XL is decoded with libjxl's djxl, which must\ndecode it at the right size and alpha, a lossless file exactly, and rebuild a recompressed\nJPEG byte for byte. HEIC is decoded with libheif's heif-dec and heif-info, from a\nseparate build; that is how the GPL edition's x265 output is checked. Metadata is checked\nagainst cwebp itself and with avifdec, djxl and heif-info. AVIF input is checked\nagainst avifdec across 17 colour encodings (bit depths, chroma subsampling, range,\ncolour matrices, alpha), and JPEG XL input against djxl.

\n

How it compares

\n

As each project describes itself on its own page, checked on 1 October 2026. "Not\nstated" means its page does not say, not that it cannot.

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Runs asBatchWritesEncoder optionsByte parity with the reference tools
Skidbladnirdesktop app: Windows, macOS, Linux (x86-64 and ARM64)yes, files or whole foldersWebP, AVIF, JPEG XL, HEICevery option of cwebp, avifenc, cjxl and heif-enc for a still imageyes, tested: 4,270 cases (above)
Squooshweb app in the browser (installable), nothing uploadednot statedMozJPEG, WebP, AVIF, JPEG XL, OxiPNG, QOI, WebP 2 (its codec list); no HEICnot statednot stated
XnConvertdesktop app: Windows, macOS, Linux; free for private or educational use, paid in a companyyesabout 70 formats, including WebP, AVIF, JPEG XL and HEICnot statednot stated
Converseendesktop app on ImageMagick: Windows, Linux, macOS, FreeBSD; GPL-3.0yesover 100 formats, including WebP, AVIF and HEIC; JPEG XL not mentionednot statednot stated
The reference command-line toolscommand lineby scriptingone format eachall of them, by definitionthey are the reference
\n

Build from source

\n

Requires a stable Rust toolchain, Node.js and npm, nasm — libaom, libjpeg-turbo and\nthe AV1 decoder assemble their SIMD code with it, and x265 in the GPL edition (Arch:\nnasm; Debian/Ubuntu: nasm; macOS: brew install nasm; Windows: choco install nasm)\n— and CMake with a C++ compiler, which build libjxl, libavif with libaom, and\nlibjpeg-turbo from source so they are linked statically. Clone with\n--recurse-submodules: those sources are pinned under third_party/. HEIC needs libheif,\nbuilt with scripts/build-libheif.sh from the same pinned sources into build/libheif;\nwithout that build, the system libheif is linked instead. On Linux you also need\nthe development headers of webkit2gtk-4.1, and of xkbcommon (with xkbcommon-x11), xcb,\nWayland, fontconfig and FreeType for the gpui window.

\n
npm --prefix frontend install        # also installs the Tauri CLI\ncd src-tauri && ../frontend/node_modules/.bin/tauri build --no-bundle

Run it with ../frontend/node_modules/.bin/tauri dev from src-tauri/, which starts the\nNuxt dev server and the window together. Release builds must go through tauri build\nrather than cargo build --release: the custom-protocol feature that embeds the\nfrontend is only enabled by the former.

\n

Without CMake, the build links the system libjxl, libavif and libjpeg through\npkg-config instead, and says so. Set SKIDBLADNIR_LIBJXL, SKIDBLADNIR_LIBAVIF or\nSKIDBLADNIR_LIBJPEG to vendored to insist on the static build, or to system to insist\non the system library.

\n

The GPL edition is the gpl feature (or full, every optional feature), with a libheif\nbuilt around x265 into build/libheif-gpl. The build refuses to link either edition\nagainst the other's libheif.

\n
scripts/build-libheif.sh --edition gpl\ncd src-tauri && ../frontend/node_modules/.bin/tauri build --features gpl --config tauri.gpl.conf.json

tauri.gpl.conf.json (and on Windows tauri.gpl.windows.conf.json too) bundles x265's\nlibheif, the GPL edition's notices and the GPL text, and points the updater at the GPL\nedition's releases.

\n

The encoders are the libraries themselves, linked into the binary — there is no command-line\ntool to download and no subprocess.

\n

Checking a build

\n
cargo test --workspace     # includes the encoder-parity tests\nscripts/smoke-test.sh      # drives the webview window: renders, IPC, a real conversion\nscripts/a11y-audit.sh      # axe-core against the live webview window\nscripts/gpui-smoke.sh      # opens the gpui window and converts to every format

The parity tests compare each encoder with its reference tool and need them built from\nthe same sources as the libraries the app links, reading JPEG through the same\nlibjpeg-turbo: scripts/build-reference-tools.sh builds cwebp, img2webp, gif2webp,\navifenc, cjxl and heif-enc and prints the SKIDBLADNIR_REFERENCE_* variables that\npoint the tests at them. The decoding checks use libavif's avifdec and avifenc,\nlibjxl's djxl and libheif's heif-dec and heif-info from PATH. Missing tools make\nthe tests say so and skip rather than pass quietly; set SKIDBLADNIR_REQUIRE_PARITY=1 to turn a skip\ninto a failure.

\n

The two scripts need tauri-driver (cargo install tauri-driver) and WebKitWebDriver\n(webkit2gtk-driver on Debian/Ubuntu, webkitgtk-6.0 on Arch). They skip, loudly, when\nthose are missing.

\n

Status

\n

Skidbladnir is a Rust + Tauri 2 app for Windows, Linux and macOS, and it builds and\npasses its tests on all three in CI. Since 1.3.0 its window is drawn natively with\ngpui (crates/skidbladnir-gpui): the same window, control for\ncontrol, in about a third of the memory and starting about 30% sooner. The Nuxt +\nTailwind webview window it replaces is still built in. It opens when the app is started\nwith SKIDBLADNIR_UI=webview, or by itself when the gpui window cannot start (on a\nmachine with no GPU driver it can use, say). Releases are\nstable from 1.0.0; the ones before it were prereleases. The work queue lives in\nROADMAP.md.

\n

Skidbladnir used to be an Electron app for Windows. That app has been retired: its last\nbinary release is v0.4.3\n(2019), and its last source is the\nelectron-final tag.

\n

Known gaps:

\n
    \n
  • macOS is experimental. The .dmgs (Apple silicon and Intel) are built by CI, and\nthe test suite runs on macOS, where CI also opens the window and converts to every\nformat with it, but nobody has yet opened the app on a real Mac. The\nstandard edition's Windows installer is installed and exercised by CI on every change,\nand the Linux app (both editions) is launched and driven by CI on every change, though\nnot from its .deb or AppImage. Each release's .deb is installed by CI on Ubuntu 22.04\nand Debian 12, and its .rpm on the latest Fedora, on x86-64 and ARM64 alike, and\nchecked to find every library it needs; the ARM64 app itself has not been launched. The\npublished 0.5.0 .deb and AppImage were run through that release's smoke test by hand.
  • \n
  • Nobody has used Skidbladnir with a screen reader yet (Orca, Narrator, NVDA or\nVoiceOver). What one is given is checked automatically on Linux and Windows, not on\nmacOS. Before 1.4.1 the window's text and number fields (sizes, crops, exact values)\nwere missing from it entirely.
  • \n
  • The app is not code-signed on any platform;\nOpening it the first time shows how to get past each\nsystem's warning.
  • \n
  • An AVIF, JPEG XL or HEIC encode cannot be cancelled mid-file: none of those encoders\nreports progress, so Cancel takes effect when the current file finishes (which is then\ndiscarded, not written).
  • \n
  • An animated AVIF, JPEG XL or HEIC is read as one still image: its primary image or\nfirst frame.
  • \n
  • Animated AVIF, JPEG XL and HEIC are not written (see below), so an animation's metadata\nis kept in animated WebP only: a GIF's ICC profile and XMP as gif2webp -metadata keeps\nthem, an animated WebP's ICC profile, Exif and XMP as webpmux -set would set them.
  • \n
  • An animation (animated WebP or GIF) converts to WebP only. AVIF, JPEG XL and HEIC\noutput refuse it by name rather than keeping just the first frame, because those\nencoders write still images here. A target size or PSNR applies to each frame of an\nanimation, not to the whole file.
  • \n
  • JPEG XL files open in Safari, and in Firefox and Chrome as each enables it by default;\nuntil then, most web pages cannot show them. The long-promised JPEG 2000 was dropped\nas a goal; it has no momentum outside medical and archival imaging.
  • \n
  • The standard edition writes HEIC 8-bit 4:2:0 only (Kvazaar), so its lossless coding is\nlossless after the conversion to 4:2:0, as with heif-enc; -L lossless, 4:4:4 and\n10-bit take the GPL edition. Neither writes 12-bit HEIC.
  • \n
  • HEVC patents. HEIC is HEVC (H.265) inside HEIF, and HEVC is covered by patents\nlicensed through patent pools such as Access Advance and Via LA. Skidbladnir includes\nopen-source HEVC software: Kvazaar (x265 in the GPL edition) to write HEIC, and libde265\nto read it. The project holds no HEVC patent licence and passes none on. Whether you\nneed one depends on where you are and what you do with the files; this is not legal\nadvice.
  • \n
\n

Acknowledgements

\n

Skidbladnir is a window around other people's encoders, and is only as good as they are:\nlibwebp (the WebM project, Google),\nlibavif and\nlibaom (the Alliance for Open Media),\nlibjxl (the JPEG XL authors),\nlibheif and\nlibde265 (struktur AG),\nKvazaar (Ultra Video Group, Tampere University),\nx265 (MulticoreWare) and\nlibjpeg-turbo (which includes the work of the Independent\nJPEG Group). The decoders are rav1d through\navif-decode, and\njxl-oxide. The window is\nTauri, Nuxt, Nuxt UI and\nTailwind CSS, set in Fira Code,\nwith icons from the collections THIRD-PARTY-NOTICES.md credits.

\n

Skidbladnir is an independent project. It is not affiliated with, sponsored or endorsed\nby Google, the Alliance for Open Media, the JPEG committee, Apple, Microsoft, or any of\nthe projects above. WebP, AVIF, JPEG XL, HEIC and the other names here are used only to\nsay which formats and tools it works with; they belong to their owners.

\n

License

\n

Skidbladnir's own code is ISC (LICENSE), as declared in the workspace\nCargo.toml, apart from code adapted from the encoders, which keeps its licence:\ncrates/skidbladnir-encode/native/avif_shim.c carries portions of libavif's avifenc\nunder BSD-2-Clause.

\n

The standard edition's own code is ISC; the libraries it ships keep their licences,\nincluding LGPL-3.0 for libheif and libde265, which ship as a separate shared library you\nmay replace with your own build. The GPL edition's builds include x265, so each of them\nis distributed as a whole under the GNU GPL, version 3 or later\n(LICENSES/GPL-3.0.txt); see Editions.

\n

The native libraries each edition ships, the JavaScript packages in its webview window and\nthe Rust crates compiled into it, with their licences, are listed in\nTHIRD-PARTY-NOTICES.md and\nTHIRD-PARTY-NOTICES-GPL.md, and every installer carries its\nown edition's copy with its licence texts.

\n", "releases": [ + { + "tag": "v1.4.3", + "title": "Skidbladnir 1.4.3", + "url": "https://github.com/basic-automation/Skidbladnir/releases/tag/v1.4.3", + "publishedAt": "2026-10-12T18:15:50Z", + "prerelease": false + }, { "tag": "v1.4.2", "title": "v1.4.2 — JPEG XL much faster on Windows; every parity case on every platform", @@ -226,122 +233,115 @@ "url": "https://github.com/basic-automation/Skidbladnir/releases/tag/v1.3.0", "publishedAt": "2026-10-02T19:06:47Z", "prerelease": false - }, - { - "tag": "v1.2.0", - "title": "v1.2.0 — Y4M, PGX and SVG input, animated WebP options, Linux on ARM64 and .rpm", - "url": "https://github.com/basic-automation/Skidbladnir/releases/tag/v1.2.0", - "publishedAt": "2026-10-02T05:42:39Z", - "prerelease": false } ], "latestRelease": { - "tag": "v1.4.2", - "url": "https://github.com/basic-automation/Skidbladnir/releases/tag/v1.4.2", - "publishedAt": "2026-10-05T05:09:10Z" + "tag": "v1.4.3", + "url": "https://github.com/basic-automation/Skidbladnir/releases/tag/v1.4.3", + "publishedAt": "2026-10-12T18:15:50Z" }, "download": { - "tag": "v1.4.2", - "url": "https://github.com/basic-automation/Skidbladnir/releases/tag/v1.4.2", - "publishedAt": "2026-10-05T05:09:10Z", + "tag": "v1.4.3", + "url": "https://github.com/basic-automation/Skidbladnir/releases/tag/v1.4.3", + "publishedAt": "2026-10-12T18:15:50Z", "prerelease": false, "assets": [ { - "name": "Skidbladnir-GPL_1.4.2_aarch64.AppImage", - "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.2/Skidbladnir-GPL_1.4.2_aarch64.AppImage", - "size": 108247560 + "name": "Skidbladnir-GPL_1.4.3_aarch64.AppImage", + "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.3/Skidbladnir-GPL_1.4.3_aarch64.AppImage", + "size": 108255752 }, { - "name": "Skidbladnir-GPL_1.4.2_aarch64.dmg", - "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.2/Skidbladnir-GPL_1.4.2_aarch64.dmg", - "size": 23595084 + "name": "Skidbladnir-GPL_1.4.3_aarch64.dmg", + "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.3/Skidbladnir-GPL_1.4.3_aarch64.dmg", + "size": 23595111 }, { - "name": "Skidbladnir-GPL_1.4.2_aarch64.rpm", - "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.2/Skidbladnir-GPL_1.4.2_aarch64.rpm", - "size": 36196838 + "name": "Skidbladnir-GPL_1.4.3_aarch64.rpm", + "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.3/Skidbladnir-GPL_1.4.3_aarch64.rpm", + "size": 36192244 }, { - "name": "Skidbladnir-GPL_1.4.2_amd64.AppImage", - "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.2/Skidbladnir-GPL_1.4.2_amd64.AppImage", - "size": 110201336 + "name": "Skidbladnir-GPL_1.4.3_amd64.AppImage", + "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.3/Skidbladnir-GPL_1.4.3_amd64.AppImage", + "size": 110217720 }, { - "name": "Skidbladnir-GPL_1.4.2_amd64.deb", - "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.2/Skidbladnir-GPL_1.4.2_amd64.deb", - "size": 35991654 + "name": "Skidbladnir-GPL_1.4.3_amd64.deb", + "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.3/Skidbladnir-GPL_1.4.3_amd64.deb", + "size": 35993580 }, { - "name": "Skidbladnir-GPL_1.4.2_arm64.deb", - "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.2/Skidbladnir-GPL_1.4.2_arm64.deb", - "size": 36195508 + "name": "Skidbladnir-GPL_1.4.3_arm64.deb", + "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.3/Skidbladnir-GPL_1.4.3_arm64.deb", + "size": 36191822 }, { - "name": "Skidbladnir-GPL_1.4.2_x64-setup.exe", - "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.2/Skidbladnir-GPL_1.4.2_x64-setup.exe", - "size": 15709360 + "name": "Skidbladnir-GPL_1.4.3_x64-setup.exe", + "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.3/Skidbladnir-GPL_1.4.3_x64-setup.exe", + "size": 15699101 }, { - "name": "Skidbladnir-GPL_1.4.2_x64.dmg", - "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.2/Skidbladnir-GPL_1.4.2_x64.dmg", - "size": 26320891 + "name": "Skidbladnir-GPL_1.4.3_x64.dmg", + "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.3/Skidbladnir-GPL_1.4.3_x64.dmg", + "size": 26327252 }, { - "name": "Skidbladnir-GPL_1.4.2_x86_64.rpm", - "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.2/Skidbladnir-GPL_1.4.2_x86_64.rpm", - "size": 35989439 + "name": "Skidbladnir-GPL_1.4.3_x86_64.rpm", + "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.3/Skidbladnir-GPL_1.4.3_x86_64.rpm", + "size": 35990900 }, { - "name": "Skidbladnir_1.4.2_aarch64.AppImage", - "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.2/Skidbladnir_1.4.2_aarch64.AppImage", - "size": 106469896 + "name": "Skidbladnir_1.4.3_aarch64.AppImage", + "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.3/Skidbladnir_1.4.3_aarch64.AppImage", + "size": 106453512 }, { - "name": "Skidbladnir_1.4.2_aarch64.dmg", - "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.2/Skidbladnir_1.4.2_aarch64.dmg", - "size": 21369165 + "name": "Skidbladnir_1.4.3_aarch64.dmg", + "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.3/Skidbladnir_1.4.3_aarch64.dmg", + "size": 21366327 }, { - "name": "Skidbladnir_1.4.2_aarch64.rpm", - "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.2/Skidbladnir_1.4.2_aarch64.rpm", - "size": 34051577 + "name": "Skidbladnir_1.4.3_aarch64.rpm", + "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.3/Skidbladnir_1.4.3_aarch64.rpm", + "size": 34056887 }, { - "name": "Skidbladnir_1.4.2_amd64.AppImage", - "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.2/Skidbladnir_1.4.2_amd64.AppImage", - "size": 108349944 + "name": "Skidbladnir_1.4.3_amd64.AppImage", + "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.3/Skidbladnir_1.4.3_amd64.AppImage", + "size": 108345848 }, { - "name": "Skidbladnir_1.4.2_amd64.deb", - "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.2/Skidbladnir_1.4.2_amd64.deb", - "size": 33517666 + "name": "Skidbladnir_1.4.3_amd64.deb", + "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.3/Skidbladnir_1.4.3_amd64.deb", + "size": 33518070 }, { - "name": "Skidbladnir_1.4.2_arm64.deb", - "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.2/Skidbladnir_1.4.2_arm64.deb", - "size": 34050704 + "name": "Skidbladnir_1.4.3_arm64.deb", + "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.3/Skidbladnir_1.4.3_arm64.deb", + "size": 34058542 }, { - "name": "Skidbladnir_1.4.2_x64-setup.exe", - "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.2/Skidbladnir_1.4.2_x64-setup.exe", - "size": 14946270 + "name": "Skidbladnir_1.4.3_x64-setup.exe", + "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.3/Skidbladnir_1.4.3_x64-setup.exe", + "size": 14934443 }, { - "name": "Skidbladnir_1.4.2_x64.dmg", - "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.2/Skidbladnir_1.4.2_x64.dmg", - "size": 23928437 + "name": "Skidbladnir_1.4.3_x64.dmg", + "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.3/Skidbladnir_1.4.3_x64.dmg", + "size": 23930505 }, { - "name": "Skidbladnir_1.4.2_x86_64.rpm", - "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.2/Skidbladnir_1.4.2_x86_64.rpm", - "size": 33517263 + "name": "Skidbladnir_1.4.3_x86_64.rpm", + "url": "https://github.com/basic-automation/Skidbladnir/releases/download/v1.4.3/Skidbladnir_1.4.3_x86_64.rpm", + "size": 33516595 } ] } }, "enlil": { "repo": "enlil", - "fetchedAt": "2026-10-12T04:16:10.919Z", + "fetchedAt": "2026-10-13T05:41:35.359Z", "description": "Bare-Metal Mesh Hypervisor", "htmlUrl": "https://github.com/basic-automation/enlil", "homepage": null, @@ -353,7 +353,7 @@ "license": "MIT", "defaultBranch": "master", "createdAt": "2026-03-26T14:25:45Z", - "pushedAt": "2026-10-12T01:41:00Z", + "pushedAt": "2026-10-12T23:24:34Z", "archived": false, "readmeHtml": "

Enlil

\n
\n

Enlil — the Sumerian god who separated heaven from earth, ruled the space between, and assigned domains to lesser gods.

\n
\n

A portable, bare-metal-capable Type-1 hypervisor written in Rust that turns a single\nx86 desktop into multiple transparent virtual PCs — with granular peripheral routing, GPU\nsharing, and a path toward pooling hardware across many physical machines.

\n
    \n
  • Language: 100% Rust (no_std for the core, std for tooling)
  • \n
  • Foundation: the RustVMM crate ecosystem
  • \n
  • Target hardware: x86-64 desktops with Intel VT-x/VT-d or AMD-V/AMD-Vi (ARM & RISC-V planned)
  • \n
  • Boot model: a UEFI application on a USB stick — non-destructive, coexists with your existing OS
  • \n
  • License: MIT
  • \n
  • Security: pre-1.0 research software — do not run untrusted guests. See SECURITY.md.
  • \n
\n
\n

What is Enlil?

\n

Most virtualization runs N guests on 1 host. Enlil's near-term goal is to make a single\nmachine feel like several independent PCs — each guest pinned to its own cores and memory,\neach seeing what looks like real, dedicated hardware — and to do it transparently enough\nthat a guest OS (including Windows, with anti-VM-detection hardening) cannot tell it is\nvirtualized.

\n

The longer-term vision is logical machines over a physical pool: Enlil mediates both\ndirections of every hardware interaction (guest→hardware traps and hardware→guest\ninterrupts) and can route them across a pool of nodes. That turns physical machines into\nstateless hardware providers and each guest into a logical machine defined only by a\nresource manifest and a routing table — hardware disaggregation underneath unmodified\noperating systems. See the Core Model\nbelow for the full picture.

\n

A defining usability constraint shapes the whole project: Enlil must be testable without\nmodifying your system. It boots from a USB stick as a standard UEFI application\n(/EFI/BOOT/BOOTX64.EFI), loads its config and per-guest virtual firmware from that stick,\nand leaves every existing drive and bootloader untouched. Unplug it and reboot to return to\nbare metal instantly.

\n
┌─────────────┐  ┌─────────────┐  ┌─────────────┐\n│  Guest OS 1 │  │  Guest OS 2 │  │  Management  │\n│ (Windows)   │  │  (Linux)    │  │   Console    │\n├─────────────┤  ├─────────────┤  ├─────────────┤\n│ Virtual HW  │  │ Virtual HW  │  │  Config API  │\n│ vCPU · vGPU │  │ vCPU · vGPU │  │  USB Route   │\n│ vUSB · vNIC │  │ vUSB · vNIC │  │  GPU Policy  │\n├─────────────┴──┴─────────────┴──┴─────────────┤\n│                  ENLIL CORE                    │\n│   CPU/Mem Sched · GPU Arbiter · USB Router ·   │\n│            Storage / Net Manager               │\n├────────────────────────────────────────────────┤\n│           UEFI Firmware (boot payload)         │\n├────────────────────────────────────────────────┤\n│                Physical Hardware               │\n└────────────────────────────────────────────────┘

Each guest gets its own virtual UEFI (OVMF) and its own bootloader inside the VM — Enlil\nsits below everything and never replaces GRUB or the Windows Boot Manager.

\n
\n

Core Model — Logical Machines over a Physical Pool

\n

The near-term product (transparent virtual PCs on one machine, described in What is Enlil?) is one point on a much larger design. Enlil mediates both directions of every hardware interaction — the guest→hardware path (VM-exits, MMIO/PIO traps, hypercalls, VirtIO kicks) and the hardware→guest path (physical IRQs, DMA completions, input events, captured on the node that owns the device and injected as virtual interrupts on the node running the guest). Because both directions pass through Enlil, the mapping from a guest to the hardware it runs on is arbitrary and may cross machine and network boundaries.

\n

That turns classic virtualization inside out. Instead of N guests on 1 host, Enlil aims for N guests on M hosts, fully composable: each physical machine becomes a stateless hardware node (a resource provider), and each guest becomes a logical machine defined only by a resource manifest plus a routing table. It is hardware disaggregation underneath unmodified operating systems — the ambition behind CXL, RDMA device pools, and LegoOS, but working transparently beneath stock Windows and Linux rather than requiring a custom OS.

\n

Three physical laws bound what such a pool can actually do, and they shape the whole design.

\n

Interconnect latency sets the granularity of sharing

\n

How tightly two nodes can share resources is governed by the latency of the link between them. Local DRAM is ~80 ns; a WAN round-trip is ~20–40 ms — roughly 100,000× slower — so a vCPU can never run against RAM that lives thousands of miles away.

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
InterconnectAdded latencyWhat can be pooled transparently
On-board PCIe / CXLsub-µs (~200–400 ns)almost anything, including memory as a NUMA tier and live devices
RDMA, same datacenter~1–5 µsstorage, NIC, GPU-compute, cold-page memory — not a hot vCPU's RAM
WAN, 1000s of miles~20–40 ms RTTcoarse/async only: remote storage, streamed I/O, whole-guest migration, replication
\n

The rule that falls out: a single kernel's tightly-coupled hot CPU+RAM working set stays on one node (or one CXL-NUMA domain). Across distance you move or replicate a guest — you don't share its hot state. Everything else (devices, storage, GPU offload, spare capacity) is poolable with latency-aware placement. To make this practical, every virtual device is built as a front-end/back-end pair behind a pluggable transport (local | CXL | RDMA | network), so remoting a device is a transport swap rather than a rewrite.

\n

ISA sets the boundary of a logical machine

\n

The latency law has a hardware-architecture twin: a single kernel's execution cannot span an ISA boundary. Page-table formats, the interrupt model, atomics, and the memory model are all ISA-fixed, and no machine description a stock OS accepts (ACPI or device tree) can express mixed-ISA SMP. So every logical machine carries a placement key — (ISA, vendor, feature baseline) — frozen at creation; its vCPUs run natively only on matching nodes, and that key defines its live-migration domain. Foreign-ISA nodes can still serve every other lane (device back-ends, memory tiers, fabric work units are ISA-blind).

\n

A guest may optionally run via binary translation, expressed as a per-guest execution mode:

\n
    \n
  • transparent — vCPUs run natively on matching nodes; remote nodes contribute device back-ends only. This is the full-fidelity product.
  • \n
  • hybrid — native vCPUs plus a translated compute device (an opt-in paravirtual accelerator); the native surface stays fully stealthed.
  • \n
  • mesh — all vCPUs translated (JIT); location- and ISA-free, but a compatibility mode only, and explicitly detectable.
  • \n
\n

Each guest manifest declares the floor it will accept (e.g. require = transparent … allow = mesh) and the pool's composition decides what is achievable — Windows-for-ARM on an all-x86 pool admits only in mesh mode, if at all. Modes are switchable at runtime without a guest reboot. Crucially, translation is a compatibility tool, never a transparency one: per-block overhead and timing artifacts defeat the anti-detection guarantees, so the translated layer is always presented to the guest as a device, never as extra CPUs.

\n

Trust domains: your own pool vs. federating with others

\n

Latency and ISA decide what can be pooled and where it runs natively. A third, orthogonal axis — the trust domain — decides how compute must be protected once a pool spans more than one owner.

\n
    \n
  • An "Enlil device" = every node under one owner. Your phone, desktop, and laptop join into a single pool that presents as one logical-machine device. Inside this boundary compute is trusted and shared in the clear — plaintext memory tiers, zero-copy fabric, native vCPU placement — exactly the Core Model above.
  • \n
  • Federating across owners = untrusted compute. Two Enlil devices can share capacity (borrow a friend's idle GPU or cores), but across that boundary a node you don't own must never see plaintext or be trusted for integrity. That is the blind-compute regime: secret-shared / MPC / ZK-verified work units, backed by attestation that the peer runs genuine Enlil.
  • \n
\n

Trust is orthogonal to latency — a node can be near-but-untrusted (a friend's LAN) or far-but-trusted (your own VPS) — so the placement key gains a trust dimension on top of (latency class, ISA, vendor, feature baseline). Each logical machine declares which trust domains may serve it: a sensitive guest pins to trust = owner-only, while fungible, decomposable work may spill to federated-blind capacity. This is what extends N guests on M hosts to M hosts owned by different people.

\n

Transparency and guest-OS feasibility

\n

Transparency is per-guest-family: Enlil presents whatever machine model each OS expects (PC/UEFI, ARM SoC, Apple platform) and defeats that family's specific VM-detection vectors, generalizing "transparent virtual PC" to a transparent virtual machine, platform-shaped per guest. How far that reaches today:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
GuestFeasibility
Linux, *BSD (x86)✅ near-term
Windows (x86)✅ in scope, with VM-detection hardening
Android (x86)✅ near-term
Android (ARM)⏳ needs the ARM backend; Play Integrity is hardware-attested
macOS⚠️ Apple hardware only — license + SMC/board-id, a major transparency lift
iOS❌ research-only — Apple-signed boot chain + Secure Enclave, not transparently virtualizable on non-Apple hardware
\n
\n

Status

\n

Enlil is pre-alpha and under active, incremental development. Work happens on a\nKVM-backed development path on a Linux host first; the bare-metal VMX/SVM backend comes\nlater (this is the same pragmatic strategy Firecracker and Cloud Hypervisor used).

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
AreaState
Platform layer + custom std target (x86_64-unknown-enlil)✅ Implemented & tested (enlil-platform, enlil-std)
HAL portability trait (HypervisorBackend)✅ Defined (enlil-hal)
KVM-backed VMM: single & multi-guest, CPU/memory partitioning✅ Implemented on Linux (enlil-core)
Virtual device layer (VirtIO net/block, interrupts, timers, PS/2, HDA, storage)✅ Implemented (enlil-devices)
Managed vCPU run loop: stealth stack + per-entry platform-timer cadence & LAPIC TSC-deadline firing✅ Implemented on Linux (enlil-core, StealthRunLoop)
Guest-boot orchestrator: config→GuestRuntime→run, bzImage kernel load (setup-header/boot_params/initrd/E820, real·protected·long-mode entry), per-guest CPUID+vTPM+LBR transparency, guest-RAM I/O — driven by the enlil-run binary✅ Implemented on Linux (enlil-core, orchestrator), KVM-verified end to end
Suspend/resume: vCPU state snapshot/restore + automatic ACPI S3 resume — a guest that commits a suspend is resumed in place (FACS waking-vector discovery via the RSDP→XSDT→FADT→FACS walk, re-entry at the waking vector) with no teardown✅ Implemented on Linux (enlil-core, KVM-verified)
USB peripheral routing (virtual xHCI, TRB-level; config-driven [usb.routing] rules assembled into a controller-validated routing registry; Linux sysfs host enumeration + cadence-throttled hot-plug polling; console USB-tab + guest-list view-models/controllers)🚧 Substantially implemented
Windows transparency (ACPI/SMBIOS synthesis incl. S3/S4/S5 sleep states, CPUID identity+topology/timing/LBR stealth, per-guest vTPM with persistent NV state, independent endorsement keys & name-derived seeds; NIC-OUI transparency in config + safe MAC synthesis; anti-detection: IET APERF/MPERF divergence + CPUID hypervisor-tell decoders — with the hypervisor bit, empty 0x40000000 vendor leaf, and preserved Invariant-TSC all KVM-verified from inside a real guest)🚧 Building blocks implemented
Bare-metal UEFI boot (Phase 6)🚧 The bare-metal kernel now boots and brings the machine up under its own control — proven nightly under QEMU+OVMF. The UEFI payload's efi_main collects the ACPI RSDP + GOP framebuffer + final UEFI memory map into a BootHandoff, calls ExitBootServices(), prints its liveness banner to COM1 serial, and transitions into the enlil kernel, which then, with no firmware left: walks the handed-off memory map and reports usable RAM; installs its own heap (a switching global allocator: firmware pool → the kernel's linked-list heap in the largest conventional region) and proves a live allocation; loads its own IDT with exception handlers and self-tests it by taking an int3; enables the local APIC in x2APIC mode; enables AMD SVM (EFER.SVME) and programs the VM_HSAVE_PA host state-save area; runs a guest through a real #VMEXIT dispatch engine — it allocates the guest its own isolated 2 MiB RAM window and maps guest-physical 0 onto it (a non-identity NPT, so the guest sees its RAM at GPA 0 while it lives at a disjoint system-physical base — LOCKED PRINCIPLE 5), writes a real-mode program into it, and VMRUNs in a loop that routes every #VMEXIT through the HAL's arch-neutral VmExit model with a guest-GPR save/restore shell around each entry (VMRUN carries only RAX/RSP/RIP/RFLAGS; the shell swaps the other 14 registers so the guest keeps state across an intercepted-and-resumed instruction). The loop emulates the guest's intercepted instructions: CPUID answered with a stealthed host result — the guest reads CPUID.1:ECX[31] (hypervisor-present) as 0, so it cannot detect enlil in-guest (LOCKED PRINCIPLE 1); port I/O decoded + captured; RDMSR answered with a stealth value and WRMSR shadowed per-guest and read back (the write never reaches host hardware); a nested page fault on an unmapped GPA is demand-mapped (fresh frame + NPT leaf) and the access re-executes; and a native arithmetic loop runs a taken branch to completion with no #VMEXIT — near-native guest execution — before the guest HLTs. The run shell brackets VMRUN with VMSAVE/VMLOAD so the guest's FS/GS/TR/LDTR + SYSENTER state is swapped in and out (a guest using segmentation/syscalls is safe); enlil delivers interrupts into a guest and the guest handles and returns from them: an EVENTINJ-injected interrupt vectors through the guest's real-mode IVT to a handler that does work in guest RAM (which the hypervisor reads back through the NPT window) and IRETs to resume the interrupted code; the same round-trip runs in 64-bit long mode through a real guest GDT + 64-bit IDT interrupt gate + IRETQ (the way a real x86-64 OS handles interrupts); a maskable virtual interrupt posted via the VMCB INT_CONTROL (V_INTR) is correctly held off while the guest masks interrupts (IF=0) and delivered only when it STIs; and enlil forcibly preempts a guest spinning in an infinite loop that never yields by posting that virtual interrupt — pure time-slicing, the mechanism a scheduler quantum uses to reclaim a CPU. That preemption now runs off the real hardware clock: with the VMCB's physical-interrupt (INTR) intercept armed, a host LAPIC timer that fires while a guest is spinning takes an INTR #VMEXIT straight back into enlil — the host reclaims the CPU from a non-cooperative guest on a genuine time base rather than a pre-posted interrupt. enlil then delivers that tick into the guest: it services and EOIs the physical interrupt, re-posts it to the guest as a virtual interrupt, and resumes without advancing RIP, so the guest vectors through its own long-mode IDT to its own handler and returns — the full path a scheduler tick takes from a host hardware clock into a running guest OS. enlil also traps a guest's own exceptions: it arms the VMCB exception-intercept bitmap so a guest #UD (or any vector) takes a #VMEXIT, then re-delivers the fault to the guest's own IDT/IVT (carrying the error code for the vectors that push one) — the guest handles its own fault while enlil observes it. It runs a full interrupt round-trip — an injected interrupt's handler runs, IRETs, and the guest resumes past the injection point (the inject → handle → IRET → resume cycle a virtual timer tick needs). And it write-protects guest memory in the NPT: a guest store to a read-only page takes a present+write nested page fault enlil records and grants (then the store re-executes) — the dirty-page / copy-on-write signal live migration builds on, while reads and instruction fetches from the same page still succeed. enlil services a VMMCALL paravirt hypercall (result returned in the guest RAX); and a full 64-bit long-mode guest runs with paging on, its own CR3 page-table walk resolved through the NPT (the mode a real x86-64 OS boots in). All boot-verified nightly on real nested SVM. The kernel also loads its own GDT + TSS with three Interrupt Stack Table stacks (the #DF, #PF, and #GP handlers each run on their own known-good stack, every IST-switch self-tested), arms the LAPIC timer in both one-shot and TSC-deadline mode (the precise-preemption clock a scheduler quantizes on — it even arms a 2 ms deadline from a nanosecond request and confirms via its own clock that it fired on time), calibrates the TSC against the fixed-rate PIT (~4.2 GHz measured) and turns it into a monotonic nanosecond clock with a busy-sleep (verified by a 5 ms sleep), installs a per-CPU GS-base TLS block, switches to its own identity page tables (off the firmware's, span derived from the memory map + framebuffer) with the low 2 MiB rendered at 4 KiB granularity and virtual address 0 left unmapped as a null-page guard (a stray kernel null-pointer dereference faults into the #PF handler instead of silently touching page 0), and can refine any other 2 MiB region of that live map down to 4 KiB pages, keeping the bulk huge-page-granular while a chosen region is managed page by page. It uses that to run on a stack it owns with a guard page: the whole rest of bring-up executes on kernel-allocated memory instead of the firmware's boot-services stack, with the page below it left unmapped so a stack overflow takes a diagnosable #PF (on its own IST stack) rather than silently corrupting its neighbour — the guard is proven absent by walking the live page tables, not by touching it. It brings up the other CPUs: the BSP finds a firmware-conventional page below 1 MiB for the AP startup trampoline (discovered from the real memory map, not a hardcoded address), installs a 16-bit real-mode trampoline, and wakes each application processor from the MADT inventory with INIT-SIPI-SIPI at the SDM's timings, each AP reporting in on a shared counter before a bounded timeout. It self-tests a spinlock primitive, and draws both a boot indicator and an 8×8-font text banner to the GOP framebuffer (pixel-readback verified). It discovers real hardware from the firmware tables the UEFI stage handed off: ACPI RSDP→XSDT→MADT (enabled-CPU count plus each processor's APIC ID — the SMP AP inventory, booted under -smp 2), MCFG (the PCIe ECAM window), and DMAR/IVRS — for VT-d it parses the DMAR body into each DMA-remapping hardware unit (register block, PCI segment, catch-all flag) and decodes that unit's device scopes (which endpoints, bridges, I/O APICs and HPETs it governs — what can be placed in a per-guest DMA domain), verified nightly against an emulated VT-d IOMMU's real firmware table. It enumerates PCI both via the legacy 0xCF8/0xCFC mechanism (host-bridge identity, device classes, MSI-X capability, and the first memory BAR — base and size via the standard all-ones write-probe, e.g. the real VGA BAR sized at 16 MiB, plus a summary of every memory BAR on the bus — their count and total MMIO footprint, the window the hypervisor must account for in config-space routing and passthrough) and via ECAM MMIO (all buses, extended config space). Each step is asserted on serial by the headless QEMU+OVMF harness (scripts/qemu-boot-test.sh, KVM-accelerated, nested +svm). The HAL carries the decode + region seams both backends need: enlil-hal::vmx (Intel VMX capability MSRs, VMCS field/exit encodings, VMXON/VMCS region init) and enlil-hal::svm (AMD SVM CPUID/VM_CR gates, the VMCB control/save-area layout, #VMEXIT decode), plus enlil-hal::region owning the 4 KiB-aligned VMCS/VMXON/VMCB/host-save pages, enlil-hal::svm's VMCB programming layer (program_minimal_hlt_guest + segment/exit-field accessors) and enlil-hal::npt's nested-page-table identity-map builder — all built for the x86_64-unknown-enlil kernel target. The kernel now links enlil-platform directly (Phase 1.2 — the crate is no_std under platform-baremetal, so its host-agnostic modules cross-compile for the bare-metal target): it drives enlil-platform's work-stealing scheduler (four tasks submitted out of order run Critical→Low on real hardware, their closures heap-boxed through the kernel's own allocator across the crate boundary), and drives that scheduler's quantum from the LAPIC TSC-deadline timer — one dispatch per real hardware tick, each quantum armed as a nanosecond deadline, so the bare-metal scheduler advances on the same clock a preemptive OS quantizes on, builds a MemoryMap from the real firmware map and carves a disjoint DMA / heap / per-guest RAM plan (cross-checked against the kernel's own memory walk — LOCKED PRINCIPLE 5) after marking the already-installed kernel heap as in-use, so the planner can never hand a guest memory the hypervisor is allocating from — and guests now run out of that planned region rather than the kernel heap, so a guest's nested mapping cannot reach the hypervisor's own allocator. It times a busy-sleep with enlil-platform's Instant monotonic clock — all boot-verified, so the boot kernel consumes shared platform primitives instead of re-deriving them. Still landed: discover::build_device_tree assembles a HostDeviceTree from the ACPI tables (CPUs with NUMA nodes via MADT + SRAT, per-node RAM ranges and distances via SRAT/SLIT, a full MCFG-ECAM PCI walk incl. BARs/xHCI/capabilities/SR-IOV, DMAR/IVRS IOMMU kind), plus MemoryMap carve/plan from E820 & UEFI maps and x86-64 identity page-table builders. Next: bringing each started AP from real mode into long mode with its own guarded stack and per-CPU state (multi-vCPU guests follow), programming the discovered IOMMU units' root/context tables for per-guest DMA isolation, and booting a real distro / privileged service VM
GPU sharing, compute fabric, ARM/RISC-V, multi-machine mesh⏳ Planned (Phases 7–11)
\n
\n

Honest caveat: the RustVMM/KVM dependencies in enlil-core are gated to\ntarget_os = "linux", and live guest-boot/integration tests require KVM (nested\nvirtualization). On other hosts the logic-level unit tests still build and run, but a real\nguest boot does not. Wiring enlil-core and enlil-devices fully through enlil-hal is\nin progress. See ROADMAP.md for exactly what is built vs. planned.

\n
\n
\n

Architecture

\n

Enlil is a Cargo workspace of focused crates layered over a custom platform abstraction. The\nkey idea — platform-first — is that every crate above the platform layer writes ordinary\nRust (Vec, HashMap, Arc<Mutex<T>>, async/await), whether the final target is Linux\nor bare metal. The platform crate absorbs all the no_std complexity.

\n
┌─────────────────────────────────────────────────────────────────┐\n│                          Enlil Crates                           │\n│  ┌──────────┐ ┌───────────┐ ┌────────────┐ ┌───────────────┐   │\n│  │enlil-core│ │enlil-mgmt │ │enlil-config│ │ enlil-devices │   │\n│  └────┬─────┘ └─────┬─────┘ └─────┬──────┘ └──────┬────────┘   │\n│       │             │             │               │            │\n│  ┌────┴─────────────┴─────────────┴───────────────┴────────┐   │\n│  │                       enlil-hal                          │   │\n│  │        HypervisorBackend trait (VMX / SVM / EL2 / H)     │   │\n│  └──────────────────────────┬───────────────────────────────┘  │\n│                             │                                   │\n│  ┌──────────┐         ┌─────┴─────┐         ┌───────────────┐   │\n│  │enlil-boot│         │enlil-setup│         │   enlil-std   │   │\n│  │UEFI stub │         │ setup TUI │         │  std facade   │   │\n│  └──────────┘         └───────────┘         └───────────────┘   │\n├─────────────────────────────────────────────────────────────────┤\n│                     Rust std / core / alloc                     │\n│            (x86_64-unknown-enlil on bare metal, or              │\n│              x86_64-unknown-linux-gnu when hosted)              │\n├─────────────────────────────────────────────────────────────────┤\n│                         enlil-platform                          │\n│   Memory/Alloc · Threading · Sync · Async · Time · I/O          │\n│   ┌─────────────────────────┐  ┌────────────────────────────┐   │\n│   │   platform-linux        │  │   platform-baremetal       │   │\n│   │   (delegates to std)    │  │   (raw HW implementations) │   │\n│   └─────────────────────────┘  └────────────────────────────┘   │\n├─────────────────────────────────────────────────────────────────┤\n│           Hardware  ·  or  ·  Linux + KVM host                  │\n│      x86_64 (VMX/EPT)  ·  ARM (EL2)  ·  RISC-V (H-ext)          │\n└─────────────────────────────────────────────────────────────────┘

The second pillar is the HAL: enlil-hal defines a single architecture-neutral\nHypervisorBackend trait, so the rest of the system never touches VMCS fields, EPT entries,\nor KVM ioctls directly. Adding ARM EL2 or native VMX is "just" a new impl HypervisorBackend\nbehind a #[cfg(target_arch = "…")] gate — the core, device, config, and management crates\ndon't change.

\n
pub trait HypervisorBackend: Send + Sync {\n    type VCpu: Send;\n    type PageTable: Send;\n    type InterruptController: Send;\n\n    fn create_vcpu(&self, config: &VCpuConfig) -> HalResult<Self::VCpu>;\n    fn run_vcpu(&self, vcpu: &mut Self::VCpu) -> HalResult<VmExit>;\n    fn handle_exit(&self, vcpu: &mut Self::VCpu, exit: &VmExit) -> HalResult<bool>;\n    fn map_guest_memory(&self, page_table: &mut Self::PageTable, guest_addr: u64, host_addr: u64, size: u64, writable: bool) -> HalResult<()>;\n    fn inject_interrupt(&self, vcpu: &mut Self::VCpu, controller: &Self::InterruptController, irq: u32) -> HalResult<()>;\n}

Crate map

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
CrateRole
enlil-platformThe foundation. std-equivalent APIs (memory/alloc, threading + scheduler, sync, async runtime, time, I/O) for both hosted (platform-linux, delegates to std) and bare-metal (platform-baremetal, raw hardware) targets. Under platform-baremetal the crate is #![no_std] + alloc, so memory, sync, threading, and time cross-compile for x86_64-unknown-enlil / x86_64-unknown-uefi and are linked + boot-driven by enlil-boot (io/async_rt still hosted-only pending a no_std IoError / reactor).
enlil-stdA std-shaped facade (collections, future, io, sync, thread, time) over enlil-platform, with integration tests — proves the platform layer can carry ordinary Rust on the custom target.
enlil-halThe architecture-neutral HypervisorBackend trait — the only place ISA-specific virtualization details are meant to appear.
enlil-coreThe hypervisor core: VM lifecycle, vCPU management, memory partitioning, EPT, CPUID/SMBIOS/ACPI handling, serial console, timing stealth, vTPM. Carries the RustVMM stack (kvm-ioctls, vm-memory, vm-superio, linux-loader, …) gated to Linux for the KVM-backed dev path.
enlil-devicesThe virtual device library: VirtIO net/block, full ACPI table synthesis, SMBIOS, interrupt controllers (LAPIC/IOAPIC/MSI), timers (PIT/HPET/TSC/paravirt), PS/2, HDA audio, qcow2/raw storage, a virtual xHCI USB stack with routing, an inter-guest bridge (clipboard, drag-and-drop, shared FS, notifications, URL/protocol-handler routing), a display compositor (Enlil Zones, incl. per-monitor multi-monitor tiling), and anti-detection stealth modules (CPUID, timing, LBR, PMC).
enlil-configTOML guest definitions and validation (no overlapping CPU sets, memory, or device assignments).
enlil-mgmtThe management console — clap CLI + ratatui/crossterm TUI for live control (list/start/stop/attach/status).
enlil-setupFirst-run setup wizard: detect hardware, define guests, assign resources, write config.toml.
enlil-bootUEFI boot payload + early kernel for the bare-metal target (Phase 6). A real efi_main collects the boot handoff and calls ExitBootServices(), then kernel_entry brings the machine up under enlil's own control: memory-map walk, switching heap, IDT + int3 self-test, own GDT+TSS with three IST stacks (#DF/#PF/#GP self-tested), spinlock, x2APIC + LAPIC timer (one-shot and TSC-deadline, incl. ns-precise deadline arming), PIT-calibrated TSC with a monotonic ns clock + busy-sleep, per-CPU GS-base TLS, its own identity page tables (with 2 MiB→4 KiB refinement) and a kernel-owned guarded stack it runs the rest of bring-up on, SMP AP bring-up (firmware-discovered low-memory trampoline + INIT-SIPI-SIPI), ACPI/PCI hardware discovery (MADT + APIC-ID inventory / MCFG / DMAR remapping units + device scopes + legacy & ECAM PCI, with memory-BAR read + write-probe sizing), an 8×8-font GOP text console, and AMD SVM enable + VM_HSAVE_PA then runs guests through a real #VMEXIT dispatch loop — each guest in its own isolated RAM at GPA 0 (non-identity NPT), a guest-GPR save/restore shell + VMSAVE/VMLOAD around VMRUN, per-exit emulation (CPUID stealth, port I/O, RDMSR spoof + WRMSR shadow, NPF demand-paging, native compute loop, VMMCALL hypercall), EVENTINJ interrupt injection, a full 64-bit long-mode guest, guest-exception interception (#UD trapped + re-injected to the guest's IDT), a full interrupt round-trip (inject → handle → IRET → resume), timer-driven preemption (a host LAPIC timer preempting a spinning guest through the INTR intercept, then that tick delivered into the guest and handled by its own IDT), and NPT write-protection (a guest store to a read-only page trapped + granted — the dirty-tracking / copy-on-write primitive). QEMU+OVMF boot-proven nightly.
\n

The intended layering is enlil-mgmt/enlil-setup → enlil-config/enlil-core →\nenlil-devices → enlil-hal → enlil-platform, with enlil-boot as the bare-metal entry\npoint over enlil-platform + enlil-core. Some of those edges are still being wired through\nthe HAL (today enlil-core is the KVM-backed VMM and enlil-devices is a standalone library).

\n
\n

Building

\n

Enlil uses a pinned Rust nightly toolchain (see rust-toolchain.toml)\nbecause the bare-metal path needs -Z build-std and some asm/no_std features.

\n
# Hosted development build (Linux/KVM is the default backend)\ncargo build\ncargo test            # workspace unit + integration tests\ncargo clippy\ncargo fmt --check\n\n# Bare-metal target — recompiles std against the Enlil platform layer\ncargo build --target x86_64-unknown-enlil.json -Z build-std=core,alloc,std
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
TargetPlatform featureHypervisor backendUse case
x86_64-unknown-linux-gnuplatform-linuxKVM (via cfg(target_os = "linux") deps)Development (Phases 0–5)
x86_64-unknown-enlilplatform-baremetalNative VMX (bare-metal)Production (Phase 6+)
\n

The platform is selected by feature flag at compile time. The hypervisor backend\n(KVM vs. native VMX) is determined automatically by the target OS — there is no separate\nbackend-* feature flag:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Feature flagEffect
platform-linuxUse the std-delegating platform (default for hosted builds)
platform-baremetalUse the bare-metal platform implementations
\n
\n

The KVM-backed path requires a Linux host with /dev/kvm; live guest-boot tests\nadditionally need nested virtualization. Where that isn't available, those tests are\nskipped rather than faked.

\n
\n
\n

Configuration

\n

Guests are declared in TOML and validated by enlil-config. See\nexamples/two-guests.toml for a complete example.

\n
[[guest]]\nname = \"debian\"\ncpus = [0, 1]\nmemory_mb = 2048\nkernel = \"/boot/vmlinuz\"\ninitrd = \"/boot/initrd.img\"\ncmdline = \"console=ttyS0 root=/dev/vda1\"\n\n[guest.storage]\ntype = \"virtio-blk\"\npath = \"/dev/sda2\"\n\n[guest.network]\ntype = \"virtio-net\"\ntap = \"tap0\"\n\n[[guest]]\nname = \"windows\"\ncpus = [2, 3]\nmemory_mb = 4096\n# …

Validation enforces the invariants that make isolation real: CPU sets must not overlap\nbetween guests, memory regions must not overlap, total allocation must fit physical memory\nminus Enlil's own reservation, and device assignments must be exclusive.

\n
\n

Repository layout

\n
enlil/\n├── Cargo.toml                  # Workspace root (resolver 2, edition 2024)\n├── rust-toolchain.toml         # Pinned nightly\n├── x86_64-unknown-enlil.json   # Custom bare-metal target spec\n├── README.md                   # This file\n├── ROADMAP.md                  # Development roadmap — a [ ]/[x] task checklist of all 11 phases\n├── examples/                   # Sample guest configurations\n├── enlil-platform/             # Platform abstraction (linux + baremetal backends)\n├── enlil-std/                  # std-shaped facade over the platform layer\n├── enlil-hal/                  # HypervisorBackend trait\n├── enlil-core/                 # Hypervisor core (KVM-backed dev path)\n├── enlil-devices/              # Virtual device library\n├── enlil-config/               # TOML guest config + validation\n├── enlil-mgmt/                 # Management CLI/TUI\n├── enlil-setup/                # First-run setup wizard\n└── enlil-boot/                 # UEFI boot payload + early kernel (Phase 6)

\n

Design principles

\n
    \n
  1. Platform-first. enlil-platform exists so every other crate can write normal Rust\nregardless of target. On Linux it's a thin shim over std; on bare metal it provides a\nglobal allocator, cooperative threading, spinlock-based sync, a hardware-counter timer,\nand UART I/O.
  2. \n
  3. Architecture portability via the HAL. The HypervisorBackend trait is the only place\nISA-specific virtualization appears. The core never touches VMCS/EPT/ioctls directly.
  4. \n
  5. Dual-backend strategy. The same crate graph compiles both for a Linux/KVM development\ntarget and for the bare-metal x86_64-unknown-enlil production target.
  6. \n
  7. Custom target triple. x86_64-unknown-enlil.json disables the default std sysroot\nand recompiles std against enlil-platform via -Z build-std.
  8. \n
  9. USB live boot. Enlil is the bootloader — a UEFI application that boots from USB with\nzero modifications to existing drives, coexisting with Windows Boot Manager / GRUB.
  10. \n
  11. cfg-gated architecture code. Every ISA-specific module sits behind\n#[cfg(target_arch = "…")]; enlil-core contains zero architecture-specific imports.
  12. \n
\n
\n

Security model

\n
    \n
  • Guest isolation is non-negotiable. EPT/NPT enforces memory separation in hardware — no\nguest can read another guest's memory or Enlil's own.
  • \n
  • CPU partitioning. Each vCPU is pinned to a physical core; CPU sets must not overlap\n(validated by enlil-config).
  • \n
  • Device isolation. Passthrough devices use the IOMMU (VT-d / SMMU) to restrict DMA to\nthe owning guest's memory.
  • \n
  • Minimal attack surface. In bare-metal mode Enlil runs no OS — it is the OS. No\nsyscalls, no kernel modules, no userspace; the only ring-0/EL2 code is Enlil itself.
  • \n
  • Rust safety. unsafe is minimized and kept to small, audited blocks; it is concentrated in enlil-platform and enlil-hal, with some unsafe in enlil-core/enlil-devices for FFI and CPU instructions.
  • \n
\n

The roadmap goes further with confidential-VM support (AMD SEV-SNP / Intel TDX) and optional\nZK-proof attestation and cross-guest isolation proofs — see ROADMAP.md (Phases 8.7 / 8.9 / 9.12).

\n
\n

Documentation

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
DocumentWhat's in it
README.md (this file)Project overview — what Enlil is, its architecture, the crate layout, how to build, and where things stand today.
ROADMAP.mdThe development roadmap — a [ ]/[x] task checklist of every phase, from scaffold through to the multi-machine mesh.
Developer book (docs/)Browsable developer docs built with mdbook (pinned 0.4.52): layer stack, crate structure, KVM-to-bare-metal migration plan, dev environment, testing, and the docs/CI build. Build with mdbook build docs.
\n
\n

License

\n

Not yet finalized. The roadmap recommends MIT or Apache-2.0 for maximum ecosystem\ncompatibility with the RustVMM crates Enlil builds on.

\n", "releases": [ @@ -384,7 +384,7 @@ }, "weftdb": { "repo": "weftdb", - "fetchedAt": "2026-10-12T04:16:09.737Z", + "fetchedAt": "2026-10-13T05:41:34.359Z", "description": null, "htmlUrl": "https://github.com/basic-automation/weftdb", "homepage": null, @@ -479,7 +479,7 @@ }, "Nanna": { "repo": "Nanna", - "fetchedAt": "2026-10-12T04:16:13.701Z", + "fetchedAt": "2026-10-13T05:41:37.706Z", "description": "A high-performance, always-on personal AI presence written in Rust — local-first, runs on your own machine with a small open model on a single consumer GPU", "htmlUrl": "https://github.com/basic-automation/Nanna", "homepage": null, @@ -576,7 +576,7 @@ }, "splimes": { "repo": "splimes", - "fetchedAt": "2026-10-12T04:16:08.577Z", + "fetchedAt": "2026-10-13T05:41:33.126Z", "description": "Spline interpolation over irregularly sampled time series, on SIMD/parallel CPU or GPU (wgpu).", "htmlUrl": "https://github.com/basic-automation/splimes", "homepage": "https://docs.rs/splimes", @@ -592,14 +592,21 @@ ], "stars": 0, "forks": 0, - "openIssues": 3, + "openIssues": 0, "license": "MIT", "defaultBranch": "main", "createdAt": "2026-10-05T19:18:10Z", - "pushedAt": "2026-10-11T09:20:21Z", + "pushedAt": "2026-10-12T18:59:26Z", "archived": false, - "readmeHtml": "

\n \"splimes\"\n

\n \"crates.io\"\n \"docs.rs\"\n \"CI\"\n \"MSRV\n \"License:\n

Resample irregularly sampled time series onto a regular grid.

\n

You have points that arrived whenever they arrived; you want a value every second,\nminute or day. splimes fits a local spline through the points, evaluates it on the\ngrid, and labels every output point as an observation, an interpolation or an\nextrapolation.

\n
    \n
  • Methods: linear, quadratic, cubic and polynomial (degree 1 to 8). Each is\nprecisely defined, inside the data and outside it, and every backend computes the\nsame thing.
  • \n
  • Backends: one CPU thread, rayon's thread pool, or the GPU via\nwgpu (Vulkan, Metal, DX12), picked per call by size or chosen\nexplicitly. A GPU failure never panics: automatic selection reruns on the CPU and\ntells you why.
  • \n
  • Values: BigDecimal or f64. Computation is in f64 (or f32 on the GPU, if\nyou ask), against a published, tested error bound. Points that land on an input\nreturn that input exactly, every digit intact.
  • \n
  • Provenance: each output point is Raw, Interpolated or Extrapolated.
  • \n
  • Honest reporting: the result says which method and backend actually ran. If\nthere are too few points for the method you asked for, it steps down (cubic →\nquadratic → linear) and says so, or refuses if you'd rather it didn't.
  • \n
\n

Quick start

\n
[dependencies]\nsplimes = \"1\"\nbigdecimal = \"0.4\"\nchrono = \"0.4\"
use bigdecimal::BigDecimal;\nuse chrono::{TimeZone, Utc};\nuse splimes::{Point, PointKind, Resolution, Spline};\n\nfn main() -> Result<(), splimes::Error> {\n    let at = |secs: i64| Utc.timestamp_opt(1_700_000_000 + secs, 0).unwrap();\n\n    // Four readings, unevenly spaced.\n    let readings = [\n        Point::new(at(0), BigDecimal::from(10)),\n        Point::new(at(7), BigDecimal::from(14)),\n        Point::new(at(19), BigDecimal::from(11)),\n        Point::new(at(30), BigDecimal::from(20)),\n    ];\n\n    // A value every second, with a cubic spline, a little past the last reading.\n    let series = splimes::interpolate(&readings, at(0), at(33), Resolution::Seconds, Spline::Cubic)?;\n\n    for (timestamp, value, kind) in series.iter().take(3) {\n        println!(\"{timestamp} {value} {kind}\");\n    }\n    assert_eq!(series.kinds()[7], PointKind::Raw);\n    assert_eq!(series.kinds()[32], PointKind::Extrapolated);\n    Ok(())\n}

For more control, configure an Interpolator:

\n
use chrono::{TimeDelta, TimeZone, Utc};\nuse splimes::{Backend, Interpolator, Resolution, Spline};\n\nlet t0 = Utc.timestamp_opt(1_700_000_000, 0).unwrap();\nlet timestamps = [t0, t0 + TimeDelta::seconds(4), t0 + TimeDelta::seconds(9)];\nlet values = [1.0, 3.5, 2.0];\n\nlet series = Interpolator::new(Spline::Quadratic, Resolution::Milliseconds)\n    .backend(Backend::Parallel) // or Auto (the default), Cpu, Gpu\n    .exact(true)                // error instead of stepping down to a simpler method\n    .run_f64(&timestamps, &values, t0, t0 + TimeDelta::seconds(9))?; // f64 in, f64 out\n\nassert_eq!(series.len(), 9_001);\nassert_eq!(series.spline(), Spline::Quadratic);\n# Ok::<(), splimes::Error>(())

From async code, spawn (or spawn_f64) runs the work on rayon's pool and returns a\nfuture that any executor can await, with no feature flag:\ninterpolator.spawn(points, start, end).await?. Dropping the future cancels work that\nhasn't started, and with the GPU it waits for work that has, so the process can exit\nsafely; spawn's docs say when the GPU is used and the cases that don't wait. With the\ntokio feature, run_async uses tokio's blocking pool instead.

\n

Accuracy

\n

Every backend computes from the same normalised inputs, with every time difference\ntaken exactly, and tests/contract.rs checks each one against an exact 60-digit\nreference. Each result is as accurate as perturbing every input value by 10⁻¹³ of\nthe value range allows in f64 (CPU or GPU), or 10⁻⁵ in GPU f32 — for every\nmethod, inside the data and out, however long the series. Where the problem is\nill-conditioned (very irregular spacing, high degree, far extrapolation) the error grows\nexactly as much as the method amplifies such a perturbation, and no more. The\ncrate documentation\nhas the precise statement. It is part of the semver promise.

\n

Performance

\n

On a 16-core Ryzen 9 7950X3D with an RTX 4070 Ti SUPER, a cubic resample of 4,096\nirregular points onto a 16.7-million-point grid takes about 100 ms on rayon's pool,\nagainst 670 ms on one thread, and a million points about 7–12 ms. Building the output\n(timestamps, values, provenance) costs the same whichever backend computed the values,\nand it dominates, so the GPU gains little: here it at best ties rayon in f64 and wins\nmodestly in f32 at a million points and more. Backend::Auto therefore uses the GPU\nonly once splimes::calibrate() has started it and measured that it pays off on your\nmachine. BENCHMARKS.md has the numbers and how to reproduce them.

\n

Features

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
FeatureDefaultEffect
gpuyesThe wgpu backend. Without it, Backend::Gpu returns Error::GpuUnavailable, and the build drops wgpu entirely.
serdeyesSerialize/Deserialize for Point, PointKind, Resolution and Spline.
tokionoInterpolator::run_async and run_f64_async.
\n

Stability

\n

splimes 1.x follows semantic versioning. Covered: the public\nAPI, what each method computes (beyond rounding within the published bound), and the\nserde formats. The minimum supported Rust version is 1.95; raising it is a\nminor-version change, announced in the changelog, and only to a\nversion at least six months old. Upgrading from 0.1? See MIGRATING.md.

\n

splimes is the interpolation engine behind WeftDB,\nbut it has no dependency on the database and works on its own.

\n

Contributing

\n

cargo test runs anywhere. Without a GPU, the GPU tests skip themselves; set\nSPLIMES_REQUIRE_GPU=1 to make a missing GPU a failure instead, or\nSPLIMES_REQUIRE_GPU_F64=1 to require one with f64 support too (CI sets that, on a\nsoftware Vulkan driver). Formatting uses nightly rustfmt: cargo +nightly fmt.

\n

License

\n

MIT; see LICENSE.

\n", + "readmeHtml": "

\n \"splimes\"\n

\n \"crates.io\"\n \"docs.rs\"\n \"CI\"\n \"MSRV\n \"License:\n

Resample irregularly sampled time series onto a regular grid.

\n

You have points that arrived whenever they arrived; you want a value every second,\nminute or day. splimes fits a local spline through the points, evaluates it on the\ngrid, and labels every output point as an observation, an interpolation or an\nextrapolation.

\n
    \n
  • Methods: linear, quadratic, cubic and polynomial (degree 1 to 8). Each is\nprecisely defined, inside the data and outside it, and every backend computes the\nsame thing.
  • \n
  • Backends: one CPU thread, rayon's thread pool, or the GPU via\nwgpu (Vulkan, Metal, DX12), picked per call by size or chosen\nexplicitly. A GPU failure never panics: automatic selection reruns on the CPU and\ntells you why. With several GPUs, wgpu's WGPU_ADAPTER_NAME (or WGPU_BACKEND)\npicks which one.
  • \n
  • Values: BigDecimal or f64. Computation is in f64 (or f32 on the GPU, if\nyou ask), against a published, tested error bound. Points that land on an input\nreturn that input exactly, every digit intact.
  • \n
  • Provenance: each output point is Raw, Interpolated or Extrapolated.
  • \n
  • Honest reporting: the result says which method and backend actually ran. If\nthere are too few points for the method you asked for, it steps down (cubic →\nquadratic → linear) and says so, or refuses if you'd rather it didn't.
  • \n
\n

Quick start

\n
[dependencies]\nsplimes = \"1\"\nbigdecimal = \"0.4\"\nchrono = \"0.4\"
use bigdecimal::BigDecimal;\nuse chrono::{TimeZone, Utc};\nuse splimes::{Point, PointKind, Resolution, Spline};\n\nfn main() -> Result<(), splimes::Error> {\n    let at = |secs: i64| Utc.timestamp_opt(1_700_000_000 + secs, 0).unwrap();\n\n    // Four readings, unevenly spaced.\n    let readings = [\n        Point::new(at(0), BigDecimal::from(10)),\n        Point::new(at(7), BigDecimal::from(14)),\n        Point::new(at(19), BigDecimal::from(11)),\n        Point::new(at(30), BigDecimal::from(20)),\n    ];\n\n    // A value every second, with a cubic spline, a little past the last reading.\n    let series = splimes::interpolate(&readings, at(0), at(33), Resolution::Seconds, Spline::Cubic)?;\n\n    for (timestamp, value, kind) in series.iter().take(3) {\n        println!(\"{timestamp} {value} {kind}\");\n    }\n    assert_eq!(series.kinds()[7], PointKind::Raw);\n    assert_eq!(series.kinds()[32], PointKind::Extrapolated);\n    Ok(())\n}

For more control, configure an Interpolator:

\n
use chrono::{TimeDelta, TimeZone, Utc};\nuse splimes::{Backend, Interpolator, Resolution, Spline};\n\nlet t0 = Utc.timestamp_opt(1_700_000_000, 0).unwrap();\nlet timestamps = [t0, t0 + TimeDelta::seconds(4), t0 + TimeDelta::seconds(9)];\nlet values = [1.0, 3.5, 2.0];\n\nlet series = Interpolator::new(Spline::Quadratic, Resolution::Milliseconds)\n    .backend(Backend::Parallel) // or Auto (the default), Cpu, Gpu\n    .exact(true)                // error instead of stepping down to a simpler method\n    .run_f64(&timestamps, &values, t0, t0 + TimeDelta::seconds(9))?; // f64 in, f64 out\n\nassert_eq!(series.len(), 9_001);\nassert_eq!(series.spline(), Spline::Quadratic);\n# Ok::<(), splimes::Error>(())

examples/resample.rs is a small command-line program built on these calls: it reads\ntimestamp,value lines, in any order, and prints the resampled series with each\npoint's provenance (cargo run --example resample < readings.csv).

\n

From async code, spawn (or spawn_f64) runs the work on rayon's pool and returns a\nfuture that any executor can await, with no feature flag:\ninterpolator.spawn(points, start, end).await?. Dropping the future cancels work that\nhasn't started, and with the GPU it waits for work that has, so the process can exit\nsafely; spawn's docs say when the GPU is used and the cases that don't wait. With the\ntokio feature, run_async uses tokio's blocking pool instead.

\n

Accuracy

\n

Every backend computes from the same normalised inputs, with every time difference\ntaken exactly, and tests/contract.rs checks each one against an exact 60-digit\nreference. Each result is as accurate as perturbing every input value by 10⁻¹³ of\nthe value range allows in f64 (CPU or GPU), or 10⁻⁵ in GPU f32 — for every\nmethod, inside the data and out, however long the series. Where the problem is\nill-conditioned (very irregular spacing, high degree, far extrapolation) the error grows\nexactly as much as the method amplifies such a perturbation, and no more. The\ncrate documentation\nhas the precise statement. It is part of the semver promise.

\n

Performance

\n

On a 16-core Ryzen 9 7950X3D with an RTX 4070 Ti SUPER, a cubic resample of 4,096\nirregular points onto a 16.7-million-point grid takes about 55 ms on rayon's pool,\nagainst about 340 ms on one thread, and a million points about 5 ms, measured on an\notherwise quiet machine. Building the output\n(timestamps, values, provenance) costs the same whichever backend computed the values,\nand it dominates, so the GPU gains little: here it at best ties rayon in f64 and wins\nmodestly in f32 at a million points and more. Backend::Auto therefore uses the GPU\nonly once splimes::calibrate() has started it and measured that it pays off on your\nmachine. BENCHMARKS.md has the numbers and how to reproduce them.

\n

Features

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
FeatureDefaultEffect
gpuyesThe wgpu backend. Without it, Backend::Gpu returns Error::GpuUnavailable, and the build drops wgpu entirely.
serdeyesSerialize/Deserialize for Point, PointKind, Resolution and Spline.
tokionoInterpolator::run_async and run_f64_async.
\n

Stability

\n

splimes 1.x follows semantic versioning. Covered: the public\nAPI, what each method computes (beyond rounding within the published bound), and the\nserde formats. The minimum supported Rust version is 1.95; raising it is a\nminor-version change, announced in the changelog, and only to a\nversion at least six months old. Upgrading from 0.1? See MIGRATING.md.

\n

splimes is the interpolation engine behind WeftDB,\nbut it has no dependency on the database and works on its own.

\n

Contributing

\n

cargo test runs anywhere. Without a GPU, the GPU tests skip themselves; set\nSPLIMES_REQUIRE_GPU=1 to make a missing GPU a failure instead, or\nSPLIMES_REQUIRE_GPU_F64=1 to require one with f64 support too (CI sets that, on a\nsoftware Vulkan driver). wgpu's WGPU_BACKEND and WGPU_ADAPTER_NAME choose which\nGPU the tests (and any program using splimes) run on, for example\nWGPU_ADAPTER_NAME=llvmpipe for CI's software driver where it is installed.\nFormatting uses nightly rustfmt: cargo +nightly fmt.

\n

License

\n

MIT; see LICENSE.

\n", "releases": [ + { + "tag": "v1.2.0", + "title": null, + "url": "https://github.com/basic-automation/splimes/releases/tag/v1.2.0", + "publishedAt": "2026-10-12T20:02:11Z", + "prerelease": false + }, { "tag": "v1.1.0", "title": null, @@ -623,13 +630,13 @@ } ], "latestRelease": { - "tag": "v1.1.0", - "url": "https://github.com/basic-automation/splimes/releases/tag/v1.1.0", - "publishedAt": "2026-10-08T21:15:02Z" + "tag": "v1.2.0", + "url": "https://github.com/basic-automation/splimes/releases/tag/v1.2.0", + "publishedAt": "2026-10-12T20:02:11Z" }, "download": null, - "crateVersion": "1.1.0", - "crateDownloads": 267, + "crateVersion": "1.2.0", + "crateDownloads": 277, "crateUrl": "https://crates.io/crates/splimes", "docsUrl": "https://docs.rs/splimes" }