A native graphical installer for FreeBSD that runs directly on the vt(4) console framebuffer — no Xorg, no Wayland, no ports dependencies. Everything below is renderable with software primitives on a memory-mapped linear framebuffer, using only what the FreeBSD base system provides.
FreeBSD's vt(4) console has no userland graphics story. The old vgl(3) library is hard-wired to the deprecated sc(4) driver — it sets VGA/VESA modes through syscons-specific ioctls that vt never implemented, and sc(4) itself now carries a formal deprecation notice (no UEFI, no UTF-8, Giant-locked). Meanwhile bsdinstall still greets users with a 1980s ncurses dialog.
What vt(4) does offer is enough: the console tty answers fbio ioctls (FBIO_GETINFO, FBIO_GETLINEWIDTH) and lets a process mmap() the framebuffer — exactly the interface Xorg's scfb driver uses. Add KDSETMODE KD_GRAPHICS to stop the console scribbling over your pixels, kbio(4) raw keyboard, and /dev/sysmouse, and you have a complete platform for a fixed-mode graphical application in the base system.
These constraints shaped every visual decision in this document.
The video mode is fixed. vt(4) inherits whatever mode the loader set (EFI GOP) and cannot change it — after ExitBootServices() nothing in the base kernel can reprogram the display. The layout therefore targets ~1024×768 minimum and scales its grid to whatever fbio reports. Below that, fall back to dialog-based bsdinstall.
One optional mode change. If the user opts into loading a KMS driver (screen 02), the framebuffer address, stride, and resolution all change once. The renderer is built around "the mode can change exactly once": tear down, re-query fbio, re-mmap, redraw.
Everything is a software primitive. Filled rectangles and horizontal spans are the atoms. Rounded corners are precomputed per-radius span tables. Hard shadows are offset gray rects; soft shadows are a small precomputed 1D blur kernel blended once per redraw. Alpha blending (read-modify-write) is affordable because the screen redraws once per keypress, never per frame.
Assets are embedded at build time. Logos and icons are converted from SVG/PNG to raw alpha bitmaps during build and linked into the binary — zero runtime decoders, zero file I/O. If dynamic images are ever needed, a ~500-line PNG-subset decoder on top of base libz is the fallback plan.
Keyboard first, mouse welcome. Every screen is fully operable with Tab/Space/Enter (kbio raw mode); sysmouse is a bonus. Focus is shown by inversion and accent borders — states that are trivial to redraw.
Text needs a real font. Spacious layouts die with the 8×16 console font. The plan is a bundled bitmap font (Terminus/spleen class, or a pre-rasterized atlas of a permissively-licensed face) at two or three fixed sizes: 22–26px headings, 14px body, 11–12px captions.
The palette is deliberately small — eight solid colors cover the whole installer. Warm off-whites instead of clinical gray, FreeBSD red as the single accent doing triple duty: brand, selection state, and primary action.
#f7f6f2 canvas
#ffffff cards
#e2dfd7 borders
#a02c2c accent
#26221e text
#7a746a secondary
#e6ede2 success tint
#f8ecd9 warning tint
Corner radii are 8px for cards and buttons, 3px for checkboxes, 4px for badges and progress bars — enough to feel modern, square enough to feel like an OS tool (an earlier draft with pill-shaped badges read as a web app and was rejected). Shadows appear in exactly two places: the selected/focused card and the primary button. Icons are Tabler-style outlines (MIT), used only where they speed recognition: device categories, driver sources, navigation arrows. Decoration is limited to the FreeBSD sphere — large on first/last screens, 16px in every header (an earlier draft's faint corner circles were cut: less is more). Every step screen carries a progress strip in the header's top-right corner: the step name over a row of nine small rectangles, filled up to the current step.
The layout contract, held on every screen: one question per screen with a 22px title and one-line explanation; 48px gutters; Back bottom-left, primary action bottom-right, skip/escape paths in the middle as plain text; the primary button always says what it will actually do ("Load from media and download 14 MB", "Erase ada0 and install" — never "OK" or "Finish").
Honesty pass: by the time the installer process runs, kernel device probing, devd/devmatch, and usually the DHCP lease are already finished — a splash narrating them would be theater. The slow stretch a user actually experiences is earlier (loader + kernel + memory-disk loading from USB), and that is covered by a static image the existing boot chain already provides: the graphics-capable loader with a logo and boot_mute to hide the dmesg scroll. The installer itself therefore boots straight to the welcome screen and runs its own startup work asynchronously — tasting disks, walking pciconf, starting the Wi-Fi scan, waiting out a straggling lease, querying NTP for the clock heuristic. Welcome needs none of those results, and by the time a human picks a language the probes are long done.
This screen survives only as a fallback for the rare case where the installer genuinely cannot proceed yet (slow USB settle, missing lease it must have) — its task line then names the installer's own real work, never the kernel's. The console-map hint (log on console 2 via Alt+F2, emergency shell on console 3; vt(4) keeps console switching alive in KD_GRAPHICS mode via VT_PROCESS) repeats in the welcome footer so it isn't lost when this screen never shows.
First contact sets the tone: one obvious action and quiet escape hatches to the live system and rescue shell that experienced users look for on install media. Language and keymap moved to the next screen, where location prefills them — welcome stays action-only. The progress strip starts here: nine rectangles, one filled. The small print carries the console map (log on Alt+F2, shell on Alt+F3), since the splash that used to teach it rarely renders at all.
One location choice drives three prefills: time zone, language, and keymap, each marked "auto" until the user overrides it (an override clears the badge and pins the value). The live clock makes the time zone verifiable at a glance. Clock synchronization is no longer asked here — it's simply the ntpd entry on the services screen. This replaces the split welcome-dropdowns + late-timezone arrangement; the split variant is kept at screen 11 for comparison. The language list only offers what the installer can honestly deliver (font coverage ∩ existing catalogs); system locales are a larger set, so when they diverge — Slovak system, no Slovak installer catalog yet — an inline warning appears with a second selector for the installation UI only, shown in the figure.
The hardware-clock row answers bsdinstall's "is the CMOS clock set to UTC?" question without asking it as a riddle. Once NTP time is available (servers courtesy of DHCP option 42 or the pool), the guess is mechanical: RTC within a couple of minutes of UTC means UTC; RTC matching UTC plus the local offset means localtime — the classic dual-boot-with-Windows signature. The offset-zero case is ambiguous and defaults to UTC, as does the no-network case. Like every guessed value on this screen it wears an "auto" badge and is freely editable, because no heuristic survives all the machines people actually own.
The installer's headline feature — and it builds on machinery the boot already ran. Since FreeBSD 12, devd(8) fires devmatch(8) at boot: it compares each unattached device's PNP info against the linker hints of modules on the media and kldloads whatever matches. That is how em and snd_hda in the last row attached "automatically." The installer's own pass covers the three cases devmatch cannot: kmods that live outside base (drm-kmod from FreeBSD-ports-kmods, built against the RELEASE GENERIC kernel so the ABI matches), devices whose driver needs firmware fetched separately (what fwget(8) does for wifi and GPUs), and devices with no FreeBSD driver at all — listed last, so the user learns now that the fingerprint reader will need extra action or won't work, rather than discovering it post-install. Each actionable card states where the driver comes from and what a download costs; the primary button repeats the plan verbatim. Status lines are written as plain sentences, pluralized at runtime (“this device was” / “these devices were”) — the devmatch machinery stays behind the curtain.
Sequencing is deliberate: this happens before any disk writes, so if a KMS driver hangs the machine the user reboots and declines, having lost nothing. Loading i915kms switches vt to the KMS framebuffer — the one sanctioned mode change. Accepted drivers are also written to the target system's rc.conf (kld_list), so the installed system boots with working graphics and Wi-Fi from the first start. A monitor icon stands for the GPU — people associate graphics with the screen, not with a chip hidden in the case. A text-only variant of the cards is shown below for comparison; the icons are embedded bitmaps either way, so the cost difference is zero.
This screen exists only on the path where the user opted into a KMS driver — skip the driver and it never appears. It does two jobs: confirms the risky step worked (the green banner is the payoff for the opt-in), and exposes the capability the driver just unlocked — resolution switching, which plain vt/efifb could not do. The mode list comes straight from the DRM connector's mode enumeration. "Native" is the monitor's preferred mode from its EDID block, which also yields the vendor (PNP ID, e.g. GSM → LG), the model-name descriptor, and — via DRM — the connector it hangs on. Hence the monitor line: make "native" mean "native for this LG on HDMI-1", not an abstract label. None of this is visible without KMS — one more quiet argument for the opt-in.
Changing modes uses the monitor-OSD safety pattern: the new mode applies immediately and reverts automatically after 10 seconds unless confirmed, because a failed modeset leaves an unreadable screen and Back buttons don't help when you can't see them. For the renderer this reuses the same teardown/re-query/re-mmap path the driver load already exercised — "the mode can change once" simply generalizes to once per confirmed user action. The chosen mode is also written to the installed system's loader.conf (kern.vt.fb.default_mode), so the console comes up in it from the first boot.
Interfaces appear as cards with live state (link, lease) rather than as a bare device list; the common case — wired DHCP — is already done by the time the user arrives, and the screen just confirms it. The interface part is skippable, but the screen itself never disappears: on a machine with no usable NIC it degrades to the hostname question alone, because a hostname is needed with or without a network.
The hostname is prefilled from whatever the environment offers, best first: DHCP option 12 (host-name) if the server assigns one, a reverse-DNS PTR lookup on the leased address, and finally a generated freebsd-XXXX from the machine's hostid — always editable, the "auto" badge clearing on first keystroke. DHCP is generous beyond addresses, and the installer should harvest all of it: DNS servers (option 6), domain and search list (15, 119) to complete the FQDN, and NTP servers (42), which feed the clock display on the location screen and the UTC-vs-localtime detection described there.
Entered from a wireless interface. Networks sort by signal; each row shows security (lock icon + WPA badge, explicit "Open" pill for unsecured) and band. Selecting a row expands it in place for the password — no separate dialog, keeping the one-surface model that both keyboard focus handling and the renderer prefer. Signal bars are four rects; the whole row costs nothing to draw. The regulatory domain — which channels and power levels the radio may legally use — is prefilled from the location screen's country and applied via the wlan regdomain machinery; it stays editable for the traveler installing on hardware bought elsewhere.
Per-interface IPv4 and IPv6, each independently DHCP/SLAAC, static, or off — both protocols optional, with one guard sentence explaining when at least one is needed. The automatic options show their live result inline, so "DHCP" is a verifiable fact, not a hope. Static fields use the monospace face; addresses are the one place alignment aids reading.
Guided ZFS is the recommended default and the selected card carries all the detail: a proportional partition preview (efi / swap / zroot as colored spans — squared corners; an early pill-shaped draft looked like a toy) and the resulting layout in words. UFS and manual partitioning stay one keypress away but visually quiet. Encryption is a single checkbox here; the passphrase is collected on commit.
The target disk is identified by everything CAM/GEOM knows about it — device name, vendor, model, capacity, and serial number (diskinfo -v / CAM inquiry data). On a machine with three visually identical SSDs the serial is the only string the user can physically match against the label on the drive, so it appears here at selection time and is repeated verbatim in the final warning on the summary screen.
Appears only when guided ZFS is chosen and more than one disk exists. The layout choice is the important decision, so each option states its consequence in usable capacity and failure tolerance — computed live from the checked disks — instead of assuming the user knows what raidz1 trades away. Layouts the disk count can't support (RAID-Z2 with three disks) stay visible but disabled, teaching what would unlock them. Every disk row carries the full identity again — model and serial — because this is exactly the screen where picking the wrong physical disk hurts. Pool name, per-disk swap, and 4K-sector forcing map straight to bsdinstall's zfsboot options.
The full gpart editor for people who know exactly what they want: scheme selection (GPT default), a proportional map, and a table where the selected row exposes Edit/Delete inline and free space exposes Add — the same interaction grammar as everywhere else, no modal dialogs. The critical property is staging: every operation edits an in-memory plan, and nothing touches the disk until the "Erase and install" commit at the very end of setup. The screen says so in its second sentence — worded carefully ("changes apply at the very end, after the final review") so nobody goes hunting for a confirm button on this screen. That single sentence turns a terrifying screen into a safe sandbox — with "Undo all" and an "Auto layout instead" escape for the user who wanders in by mistake.
Distribution sets, reorganized by audience. lib32 stands alone; everything developer-facing (src, ports, debug symbols, tests) folds into a Development group with a selection count — progressive disclosure so a first-time user sees one decision, not six, while a developer opens the group once. Base system is shown but locked; hiding it entirely would invite "where's the OS?" doubts.
Two decisions live here. First, since FreeBSD 15.0 the base system itself comes in two formats: packages (pkgbase), managed entirely by pkg(8) afterwards, or the legacy distribution sets, updated via freebsd-update(8) — kept for the 15.x lifetime and planned for removal in time for FreeBSD 16 (open-source schedules being what they are); either way pkgbase is the honest default for new installs. Second, where the bits come from: memstick and disc1 images don't carry every set, and a desktop choice always means packages from the network. When everything selected is on the media, this screen never appears; when something is missing, it shows exactly what comes from where and what the download costs, with the mirror chosen automatically by probing a handful of candidates and picking the fastest — overridable for the user behind a corporate mirror. Downloads are checksummed against the release manifests and resume on failure.
Desktop selection during install — the single biggest quality-of-life gap versus Linux installers, and cheap once the network step exists: it's pkg install into the target plus rc.conf wiring (SDDM/GDM, dbus). Real project logos (KDE, GNOME, Xfce — MIT-licensed single-path versions from the simple-icons set) rendered white on each project's brand color: recognizable at a glance but visually uniform, so no card shouts. MATE uses a placeholder leaf in the same treatment until a proper mark is sourced. Console-only is a first-class card, not a buried opt-out, and each choice shows its download cost. MATE and LXQt use placeholder glyphs in the same treatment until proper marks are sourced. Trademark note: shipping project logos in an official installer needs the usual permission round with each project.
Only the root password is required. Creating a user is an explicit, optional action behind a real button — a deliberate rejection of the Debian-style forced user that gets created, used once, and deleted. The SSH-for-root toggle lives here because it's the first question a headless-server admin asks; it defaults off. The hint names adduser(8) so nobody fears this is the only chance.
Kept for comparison: the traditional arrangement where welcome asks language/keymap and time zone is its own late step. If the combined location screen (02) is adopted, this screen disappears entirely and its ntpd checkbox is absorbed by the ntpd entry on the services screen — one fewer step, no lost information.
The four everyday services as cards with plain-language subtitles (sshd, ntpd, powerd, dumpdev — sensible defaults pre-checked; ntpd here is where clock synchronization from the location screen actually lives). The twelve hardening options collapse into a single expandable row with a "2 of 12" count: present and discoverable for operators, invisible noise for everyone else. Same accordion pattern as Development on screen 08 — one interaction model to learn, one widget to implement.
The last stop before anything irreversible. Every decision from the whole flow on one screen, each row selectable to jump back and change it. The warning box is the only red-tinted surface in the entire installer, reserving that alarm for the one moment it's true — and it repeats the disk's full identity (device, vendor and model, capacity, serial number), so on a multi-disk machine the user can physically verify the victim against the drive label. The commit button names it too: "Erase ada0 and install" — a user who clicks it cannot claim they weren't told. The footer carries a third, quieter button — "Save as install script…" — exporting every answer as a bsdinstall(8)-compatible installerconfig script: the graphical flow doubles as a script generator, so the second, third, and fortieth machine install unattended — and conversely the installer honors an existing script on boot, skipping straight to the summary.
One honest progress bar with a file count and time estimate, plus a task checklist so a stall is diagnosable at a glance ("stuck on base.txz" beats a frozen percentage). No slideshow marketing — this screen redraws on a timer tick and dtrace-style extraction callbacks, nothing more.
Closes the loop the welcome screen opened — same centered composition. Two actions: reboot, or drop to a shell first for the people who want to tweak the new system before first boot.
The only translation system in FreeBSD base is catgets(3), the POSIX message-catalog API with gencat(1)-compiled .msg files. It works, costs nothing — and is why nothing gets translated: messages are addressed as set/number pairs rather than by their English text, there is no plural machinery, and no translator community works in the format. GNU gettext solves all of that but lives in ports as an (L)GPL dependency, which would undercut the installer's base-only premise. The plan therefore splits format from runtime: keep .po as the source format — where translators and the FreeBSD project's own Weblate instance already live — and replace the GNU runtime with a small BSD-licensed one.
A compiled .mo catalog is just a string-lookup table: a 28-byte header (magic 0x950412de, revision, string count N, and file offsets of two tables), followed by two parallel arrays of N (length, offset) pairs — the first for original strings, sorted bytewise, the second for their translations — and an optional precomputed hash table a reader is free to ignore. A complete reader is: map the file (or point at an embedded blob), sanity-check the header, binary-search the original-string table, return the translation at the same index. With bounds checking and the plural hook that lands around 200 lines of C, no allocations required. Plural entries add no format complexity: the original is stored as msgid NUL msgid_plural, the translation as all plural forms NUL-separated — the lookup stays identical, the caller just indexes into the result.
The even simpler variant fits this installer better: skip runtime files entirely. A build step (a ~100-line Python script replacing msgfmt, avoiding GPL tools in the build too) parses each .po and emits a sorted C array compiled into the binary — the exact pipeline the fonts and logos already use. Selecting a language on screen 02 then means swapping one pointer and redrawing; fallback is automatic because keys are the English strings themselves. Per-language cost is a few tens of kilobytes.
None of this is speculative. NetBSD has shipped a BSD-licensed libintl for two decades — written from scratch as part of the Citrus project — that reads GNU .mo files with a gettext()-compatible API. It is the existence proof that the format is an interface, not a GPL contract: the file format isn't copyrightable, only GNU's implementation is. Importing a citrus-style libintl into FreeBSD base (or contributing a fresh minimal one) would be a valuable side effect of this project — it would let other base utilities grow translations for free, and it has been idly wished for on the lists for years.
Every .po header carries a C-expression describing the language's plural selection, e.g. Czech: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;. Two honest options. (a) Evaluate it: the expression grammar is tiny — integer arithmetic, comparisons, boolean ops, ternary — a recursive-descent evaluator is ~150 lines and handles every language Weblate will ever throw at it. (b) Hard-code a table of plural functions for the launch languages and grow it on demand. Option (a) is barely more work and never needs revisiting, so it wins. Czech is the useful forcing function here: with three forms ("1 soubor, 2 soubory, 5 souborů") it breaks every lazy n == 1 assumption on day one — an installer developed against Czech is plural-correct for most of Europe by construction. English-side discipline matters too: every countable UI string ("N files extracted", "N devices without a driver") goes through the plural API from the start, because retrofitting is miserable.
Because the installer rasterizes text itself, translation coverage is bounded by the font atlas, not the catalog. The base-first answer: FreeBSD's vt(4) console font is a Terminus BSD Console derivative (SIL OFL), shipped in base with Latin, extended Latin, Cyrillic, and Greek coverage — which covers all of Europe, and with it the Americas, Australia, and most of Africa essentially for free. That makes the honest launch scope "every language writable in extended Latin, Cyrillic, or Greek". Scripts absent from base fonts — CJK, Armenian, Georgian, Ethiopic, Arabic (which also needs shaping, a different problem entirely) — are deliberately out of scope for the prototype and are a community-level discussion about what belongs in base, not something one installer should solve alone.
One layer of honesty about these mockups: the HTML previews render in the viewer's system UI font (Segoe UI, Roboto, or similar) — a proportional face that does not exist in FreeBSD base, where console fonts are all monospace bitmaps. The real installer has two options: embrace Terminus everywhere (monospace aesthetic, pure base pedigree), or bundle one permissively-licensed proportional face rasterized into the atlas at build time — DejaVu Sans (Bitstream Vera license, no copyleft, strong Latin/Cyrillic/Greek coverage) is the natural candidate. The mockups' look assumes the second; the first would still work, just with a more terminal flavor. Decision for the prototype: bundle the proportional face first — it's what the mockups promise — and keep the pure-Terminus route open as a documented fallback; the proof render below shows it holds up, with an honest terminal accent. The language list on screen 02 must be generated from the intersection of two sets: what the font atlas can draw, and which installer catalogs actually exist — offering a language the font can't draw or that would silently fall back to English is worse than not offering it.
There is a third set in play: system locales, which are far more numerous than installer translations will ever be. The two choices must be allowed to diverge. If the user picks, say, Slovak — a perfectly good system locale (sk_SK.UTF-8) with no installer catalog yet — the screen says so plainly and offers a second selector for the installation UI only: "Slovak isn't translated in the installer yet. Your system will use Slovak; choose a language for the installation itself." The installed system gets exactly what the user asked for, the installer runs in their second-best language instead of silently defaulting to English, and the warning doubles as a recruitment poster for the translation effort.
The script story scales past "save my answers". Because the installer both generates and honors installerconfig, the network-boot case composes from existing FreeBSD pieces: the machine PXE-boots (or UEFI-HTTP-boots) the installer image from the deployment server, and the installer then hunts for its configuration in a fixed order — a kernel environment variable set by the loader, a DHCP-supplied URL, then a URL template on the config server tried with progressively less specific names: by MAC (installerconfig-3c-ec-ef-12-34-56), by product serial, then a catch-all default — the same convention PXELINUX taught every lab admin decades ago.
What happens next is one policy knob in the config itself. Review mode boots straight to the summary screen, every answer prefilled and marked as network-loaded (see the summary figure), waiting for a human to press the one button — right for a technician walking a row of machines. Auto mode confirms by itself after a visible countdown and reports progress to a syslog server or HTTP webhook — right for the forty-seat school lab where the configs were generated from a spreadsheet of MACs and hostnames by a fifty-line script. Per-machine differences (hostname, static address, disk layout by machine class) are just fields in the generated files; everything else is identical. The graphical installer's role in this story is mostly to get out of the way — but its summary screen is what makes the semi-attended variant humane, because the technician sees exactly what this machine is about to become before it happens.
Design notes on the countdown screen: it shows only what a passing technician needs — which machine this is (MAC and assigned hostname), the one destructive fact (which disk dies, with model), and how to interrupt. Any key pauses; "Review settings…" opens the ordinary summary in review mode; cancel falls back to local boot. The countdown card reuses the selected-card accent, making it the obvious focal point on a screen someone may glance at from two meters away. The completion screen leads with the hostname — in a room of forty identical boxes, "which one is this" is the only question — and reports success or warnings to the deployment server before rebooting itself.
A checklist against everything the current installer can do, so nothing is silently dropped in the name of polish. Covered by design: keymap/language (02), hostname (04 — asked even with no NIC), network including wireless with WPA and regulatory domain (04–06), guided UFS and ZFS with multi-disk topologies, pool options and GELI (07, 07b), manual gpart partitioning (07c), distribution sets and pkgbase (08, 08b), fetching missing sets from a mirror (08b), root password and optional users (10), time zone and UTC/localtime CMOS handling (02), services and hardening options (12), crash-dump device (12), final-stage shell access (15), live system and rescue shell (01), and scripted installs (13 — both generating and honoring installerconfig).
Consciously beyond bsdinstall: driver detection with opt-in kmod loading (03), resolution switching (03b), desktop environment installation (09), and the unmatched-devices report (03). Consciously not replicated: the ncurses aesthetic.
vgl(3) was ruled out early. It only works on deprecated sc(4); vt(4) never implemented its mode-setting interface. Instead: fbio + mmap + KDSETMODE, the scfb approach. A vt/fbio backend for libvgl remains a worthwhile upstream contribution — it would remove the last practical reason to keep sc(4) alive.
Fixed mode, embraced. Runtime mode switching would require KMS drivers that live in ports, not base. Rather than fight this, the design targets the loader-set mode and offers drm-kmod as an opt-in (screen 02), placed before disk writes so a driver hang costs nothing.
Style evolved through three drafts. Draft one was a period-correct beige dialog (rejected: cramped, joyless). Draft two went airy with rounded cards and soft shadows once we established both are cheap in software (corner span tables, precomputed blur kernels). Draft three squared off badges and bars that read as too web-like, swapped abstract DE icons for real logos, made the GPU icon a monitor, and turned implicit actions (add user) into explicit buttons.
Structural choices: one question per screen instead of bsdinstall's modal maze; a single location choice prefilling time zone, language, and keymap (with "auto" badges and a split-flow variant kept for comparison); a nine-segment progress strip in every header; root-only accounts with optional user creation; developer distribution sets and hardening options behind counted accordions; every download labeled with source and size; disks identified by vendor, model, capacity, and serial at selection time and again in the final warning; every destructive or network action named on its button.
Assets: all images (FreeBSD sphere, DE logos, icon glyphs) are compiled into the binary as raw alpha bitmaps at build time — no runtime decoders. Icon set: Tabler (MIT). DE logos: simple-icons single-path versions (the set is CC0; the marks themselves remain each project's trademark and need permission for official use). If runtime image loading is ever needed, the plan is a small PNG-subset decoder (~500 lines) over base libz, or QOI-converted assets.