77 Commits

Author SHA1 Message Date
mia26MAjFm
38c61af4a0 kernel-builder: add zlib1g-dev (#25)
gendwarfksyms links -lz.
2026-07-08 21:05:31 +02:00
ps5-linux-bot
94284d3c14 kernel: auto-bump kernel-7.1.2-bf16f7e -> kernel-7.1.3-e81e281 2026-07-06 08:37:43 +00:00
ps5-linux-bot
3c3be20587 kernel: auto-bump kernel-7.1.2-a57d783 -> kernel-7.1.2-bf16f7e 2026-07-05 07:33:52 +00:00
mia26MAjFm
b67f9a5a47 cachyos: pkill plasmashell + kwin before switching to gamescope 2026-07-04 23:47:09 -04:00
mia26MAjFm
34fa9da3f1 cachyos: soft-handoff on return-to-desktop instead of reboot 2026-07-04 23:07:26 -04:00
Your Name
74f978b1bb bazzite: replace dnf shell wrapper with dnf5 symlink
Bazzite's /usr/bin/dnf is a 1KB bash wrapper that intercepts install/remove
args and prints a docs URL pointing at rpm-ostree unless it detects a
container or a dev-mode ostree deployment. Our kexec-booted install has
neither: the image build wipes /sysroot/ostree/deploy/ + masks
rpm-ostreed, so dnf install <anything> prints the URL and exits 1.
Since ostree is gone the guard has no purpose here; point dnf at dnf5
directly so 'dnf install linux-ps5' works Fedora-style.
2026-07-03 13:48:40 -04:00
Your Name
d763b8496f kernel-builder-rpm: ship ps5-stage-firmware + ps5-bt-quiet services
The deb and arch packagers already copy /out/staging/{etc,usr/local}
into their packages, but the rpm packager was missing that step, so
Fedora/Bazzite installs never got the firmware-bridge service or
the moal modprobe.d config. Symptom: moal loads on boot, requests
nxp/pcieuartiw620_combo_v1.bin, kernel returns -ENOENT because the
firmware sits at /efi/lib/nxp/ instead of /lib/firmware/nxp/, and
wifi is dead until fix-wifi.sh runs by hand.
2026-07-03 13:43:26 -04:00
mia26MAjFm
babdcf0762 build-image: fail-fast=false 2026-07-01 09:40:51 -04:00
mia26MAjFm
3db2fb65b3 watcher: add actions:write for createWorkflowDispatch 2026-07-01 09:00:30 -04:00
ps5-linux-bot
415d6e9ee8 kernel: auto-bump kernel-7.1.1-07f9d7f -> kernel-7.1.2-a57d783 2026-07-01 08:18:34 +00:00
mia
d7d3b22c1f kernel-builder: ship ps5-stage-firmware + ps5-bt-quiet services 2026-06-30 21:05:29 -04:00
mia
7b8a097651 grow-rootfs: drop stale partitions from kernel + BLKPG ioctl (mounted-safe) 2026-06-30 20:00:14 -04:00
mia
7f8d0302b7 kernel pkg: bundle ps5-stage-firmware service + modprobe.d (fixes wifi) 2026-06-30 19:58:23 -04:00
mia
44bd2b4cf5 bazzite: NetworkManager backend=wpa_supplicant (iwd breaks mwifiex) 2026-06-30 18:19:50 -04:00
mia
d891b0c01e watcher: dispatch build-image after bump 2026-06-30 11:06:54 -04:00
ps5-linux-bot
f05b110a46 kernel: auto-bump kernel-7.1.1-6cfe53c -> kernel-7.1.1-07f9d7f 2026-06-30 07:58:31 +00:00
ps5-linux-bot
6a10749945 kernel: auto-bump kernel-7.1.1-76db3a4 -> kernel-7.1.1-6cfe53c 2026-06-28 15:11:10 +00:00
mia
4c27d97ce0 watcher: ubuntu-latest — no need for self-hosted (api only) 2026-06-28 11:10:56 -04:00
mia
b17ce46319 watcher: use releases api (chronological), not tags 2026-06-28 11:04:45 -04:00
mia
af8c7d472f watcher: use github-script (no gh, no checkout) 2026-06-28 11:03:29 -04:00
mia
bfdb43f1a1 watcher: no checkout — read+write build_image.sh via api 2026-06-28 11:00:39 -04:00
mia
33c6bc2660 watcher: skip workspace clean (root-owned build leftovers) 2026-06-28 10:56:19 -04:00
mia
7f4b0009d0 watcher: use gh api tag order, not sort -V on hex sha 2026-06-28 10:51:55 -04:00
mia
9f9fd3fd7f release: add steamos to ALL_DISTROS table 2026-06-28 01:36:49 -04:00
mia
eafb7fbd56 steamos: tar --zstd instead of bsdtar 2026-06-27 20:51:47 -04:00
mia
21f86f562e ship bluez + enable bluetooth.service across distros 2026-06-27 19:05:04 -04:00
mia
de5d60da56 steamos: drop curl dep — image-builder doesn't ship it
Build #28301886085 failed with 'curl: command not found' on the URL
resolution step. Upstream Dockerfile only installs wget + python3,
not curl. Use urllib.request.urlopen().geturl() to follow Valve's
redirect and pull the versioned IMG name (e.g. steamdeck-recovery-
4-20250603.5-3.7.10.img.bz2) for the cache key.
2026-06-27 18:44:59 -04:00
mia
9fa102e098 Add SteamOS distro
Adds `--distro steamos` as a first-class build target. Pattern is the
same as bazzite-deck/batocera: distros/steamos/build-rootfs.sh fetches
the upstream rootfs (Valve's Steam Deck recovery .img.bz2 from
steamdeck-images.steamos.cloud), extracts the rootfs-A + var-A btrfs
slots into $CHROOT, swaps in our linux-ps5 kernel pkg.tar.zst, and
rebuilds the initramfs with the modules amdgpu needs for the PS5
Oberon GPU.

PS5-specific fixes applied to the SteamOS rootfs:

- Replace /usr/bin/steamos-session-select with a shim that writes
  /etc/sddm.conf.d/zzz-session-override.conf directly + restarts sddm.
  The vendored helper goes through steamosctl / steamos-manager whose
  Deck-hardware code paths silently no-op on PS5, leaving the UI stuck
  at "Switching to Desktop..." with no actual session swap.

- Install a small Python dbus monitor (steam-session-switch-listener)
  that catches org.freedesktop.DisplayManager.Seat.SwitchToUser from
  Big Picture's "Switch to Desktop" button and writes the same
  override file. No -steamos3 / -steampal needed (those trigger Steam's
  A/B update reboot loop on a non-Deck box).

- Drop a Return-to-Gaming-Mode.desktop into /home/deck/Desktop pointing
  at steamos-session-select gamescope.

- chown /home/deck to deck:deck (the recovery image leaves it root:root,
  which breaks ~/.config writability on first Plasma launch).

- Enable sshd + set deck:deck so the box is reachable for further
  iteration without dragging a USB keyboard around.

- Patch /etc/fstab: comment out the by-partsets atomic mount entries
  (those paths don't exist on a flat-rootfs flash), append a LABEL-based
  /efi mount so the FAT boot partition comes up cleanly. Required for
  ps5-iw620-firmware.service to find the WLAN blob.

- Append amdgpu.dpm=0 + amdgpu.gpu_recovery=0 to the kernel cmdline.
  modprobe.d alone isn't enough — amdgpu loads before the rootfs conf
  is read, so the cmdline is the only reliable path. Without these
  Switch-to-Desktop produces a black screen on the PS5 HDMI output.

- Polkit-deny the deck user reboot/poweroff to break Steam's update
  reboot loop on first boot.

- Patch steam-jupiter to skip its OOBE rm -rf path. The OOBE assumes
  Deck factory layout and wipes /home/deck/.local if it runs.

Verified end-to-end on real PS5 (HW-rev EA-1102): autologin lands in
gamescope, Switch-to-Desktop and Return-to-Gaming both work, sshd is
reachable, ps5-iw620 brings up internal WiFi after firmware staging.

Files:
- distros/steamos/build-rootfs.sh  (646 lines)
- distros/steamos/README.md
- distros/steamos/return-to-gaming-mode.desktop
- distros/steamos/steam-session-switch-listener.{py,service}
- build_image.sh: steamos → FORMAT=arch (uses linux-ps5 pkg.tar.zst)
- .github/workflows/build-image.yml: steamos in matrix options + distros list
2026-06-27 17:10:07 -04:00
mia26MAjFm
096675b60a release: include bazzite + bazzite-deck in the table 2026-06-24 09:45:33 -04:00
mia26MAjFm
00d89868c8 build-image: clean step now umounts + detaches loops before rm
Cachyos build on minipc-gh-2 failed with "Directory not empty"
because a previous run left a loop device backed by a (deleted) file
in image/work/, and rm -rf can't cross the live mountpoint. Lazy-
umount everything under the workspace + detach matching loops before
the rm so stale mounts don't fail subsequent runs.
2026-06-24 06:59:55 -04:00
mia26MAjFm
b1aa5e5b50 build-image: drop batocera from matrix
5h cold-cache download from o2switch (only mirror, rate-limited to
~250 KB/s), and no persistent cache plumbing yet means every run pays
the full cost. Ship bazzite/bazzite-deck for emulation use cases.
2026-06-24 06:49:50 -04:00
mia26MAjFm
b2c77d5cb2 image-builder: add python3 (needed by batocera build-rootfs.sh)
distros/batocera/build-rootfs.sh:211 calls python3 inline to patch
libretroControllers.py with the PS5 HOTKEY-on-gamepad fix. Without it
the batocera build exits 127 at the very end of an otherwise-clean
run.
2026-06-24 02:01:13 -04:00
mia26MAjFm
f8d0b65b36 batocera: auto-discover the current build instead of hardcoding
The mirror rotates older builds out — the pinned 43-20260430 went 404
on its own within ~4 weeks. Scrape the `last/` index for whatever
batocera-x86_64-*.img.gz is there now (currently 43.1-20260529). VER
and BUILD are then derived from the discovered filename so the cache
key + log lines remain informative.

BATOCERA_URL still wins if set (override path unchanged).
2026-06-23 19:27:48 -04:00
mia26MAjFm
875bb35fef image-builder: --no-cache instead of CACHEBUST arg
CACHEBUST only invalidates the umoci layer; the broken cached layer on
the Oracle runner is upstream (apt-install), so step 10 still tried to
exec /bin/sh from a corrupt parent FS. --no-cache rebuilds every layer
fresh — slower (~5 min apt re-run per build) but reliable on every
runner regardless of buildkit storage state.
2026-06-23 18:13:15 -04:00
mia26MAjFm
7e42201d88 image-builder: CACHEBUST arg to force-rebuild umoci layer
One org runner has a broken cached layer for the umoci install (runc:
"/bin/sh: no such file"). Pass a unique build-arg each invocation so
that layer + downstream get rebuilt fresh, while the heavy apt install
above stays cached.
2026-06-23 18:03:17 -04:00
mia26MAjFm
0367dcb89c image-builder: pass --pull on docker build
One of the runners has a corrupted buildkit cache for the umoci layer
("/bin/sh: no such file or directory" mid-RUN). --pull refreshes the
ubuntu:24.04 base, which invalidates all downstream cached layers and
forces the umoci step to actually re-execute.
2026-06-23 17:33:55 -04:00
mia26MAjFm
98892eec6b fedora: drop duplicate in-image mwifiex build, use rpm-shipped modules
The kernel rpm built by kernel-builder/build.sh now ships mlan.ko / moal.ko
plus modprobe options for the PS5 IW620, so the image side no longer needs
its own out-of-tree build (which was pinned to an old ps5-linux-mwifiex
ref and only applied 1 of 3 patches, breaking against kernel 7.1.x).

Suppress the rpm's modules-load.d autoload so firmware-copy still runs
first via ps5-iw620.service.
2026-06-23 16:07:45 -04:00
rmux
9350227d7b apply cmd-timeout-recover and kernel71-compat mwifiex patches (#22) 2026-06-23 19:47:13 +02:00
mia26MAjFm
b10c0b2855 kernel-builder: install ca-certificates
git clone https://github.com/... fails with 'server certificate verification
failed. CAfile: none' because ubuntu:24.04 with just 'git' doesn't pull in
ca-certificates. Hit in 'Building NXP IW620 mwifiex driver' stage.
2026-06-21 09:37:42 -04:00
mia26MAjFm
bf96770651 build-image: max-parallel 1 -> 5 (we have 5 self-hosted runners) 2026-06-21 09:29:35 -04:00
mia26MAjFm
7f5dc8df0c build-image: wire bazzite, bazzite-deck, batocera into matrix + dispatch
#20 added the distro directories but missed updating the dispatch choice
enum and the 'all' matrix expansion, so the three new distros couldn't
be triggered through CI.
2026-06-21 09:23:04 -04:00
ps5-linux-bot
7fbb7df59d kernel: auto-bump kernel-7.1.1-2ee6846 -> kernel-7.1.1-76db3a4 (upstream watch) 2026-06-21 11:13:29 +00:00
mia26MAjFm
247e4ea01b kernel-builder: build + bundle NXP IW620 mwifiex driver
linux-ps5 currently ships no wifi driver — the PS5 onboard wifi is NXP
IW620 (not the upstream mwifiex), which lives at ps5-linux/ps5-linux-
mwifiex as an out-of-tree patch against nxp-imx/mwifiex. Users have to
clone that repo + run install.sh on the target, which needs gcc + kernel
headers and is a manual step every kernel bump.

Build it in the kernel-builder container right after the kernel: clone
both repos, apply ps5-iw620.patch, make against the kernel src tree,
install mlan.ko+moal.ko into /out/staging/lib/modules/$KVER/extra/
ps5-iw620/ with the NXP modprobe options + autoload conf. depmod -b
refreshes the index. All package formats (.deb/.rpm/.pkg.tar.zst) then
include the driver automatically.

MWIFIEX_REPO / MWIFIEX_REF overridable via env in case downstream wants
to test a fork or specific commit.
2026-06-20 17:07:11 -04:00
mia26MAjFm
71bce4c5cb cachyos: drop -steamos3 from gamescope-session-ps5
Steam in -steamos3 mode polls for SteamOS atomic OS updates via
steamos-update, steamos-update-rauc, steamos-atomupd-client. CachyOS
ships none of these (pacman handles rolling updates), so every poll
fires "YieldingApplyUpdateOS: OS update result: 2" and the GamepadUI
shows "Updater apply error: 2: null".

That alone is just a banner, but the first-run OOBE blocks on the
"Software updates" step waiting for a successful update result, so
users can't get past initial setup. Field-reported.

Dropping -steamos3 keeps -gamepadui (controller-friendly UI) without
the SteamOS-mode update polling. OS updates: sudo pacman -Syu.
2026-06-20 16:02:56 -04:00
mia26MAjFm
a32505e94d ci: daily upstream patches watch — auto-bump PATCHES_REF
Polls ps5-linux/ps5-linux-patches for new kernel-X.Y.Z-<sha> tags at
04:00 UTC. If newer than build_image.sh's PATCHES_REF, bumps + pushes
to main; the push fires trigger-builds.yml which rebuilds all images
against the new kernel. Safe-skips if PATCHES_REPO has been forked.
2026-06-20 15:29:30 -04:00
mia26MAjFm
fe4e686d62 bump PATCHES_REF v1.3 -> kernel-7.1.1-2ee6846
7.1.1 series. Includes SVM implementation + running
transmitter_control in guest mode.
2026-06-20 15:26:50 -04:00
mia26MAjFm
e1f17f7e38 fix: export CHROOT/DISTRO/KVER to build-rootfs.sh + sanity guard (#21)
The dispatch in #20 ran build-rootfs.sh without exporting $CHROOT, so
inside the script it was empty — meaning '/' expansions like
`mv "$UNPACK/rootfs"/* "$CHROOT/"` resolved to '/' and the bazzite
rootfs went into the build container's root. Container-contained, but
the build failed with a confusing 'Device or resource busy' on
/etc/resolv.conf.

Two fixes:
- entrypoint exports DISTRO/CHROOT/KVER/ROOT_LABEL/EFI_LABEL when
  calling the per-distro script, and asserts $CHROOT non-empty.
- bazzite + batocera scripts have a top-of-file guard that bails if
  $CHROOT is unset, missing, or '/'.

Co-authored-by: mia26MAjFm <mia26MAjFm@users.noreply.github.com>
2026-06-20 12:43:53 -04:00
Andy Nguyen
f090f170a0 Remove kali from github workflows. 2026-06-20 17:53:28 +02:00
mia26MAjFm
9682cada8d Add bazzite, bazzite-deck, batocera distro support (#20)
Each distro ships with a self-contained build-rootfs.sh that the entrypoint
calls in place of distrobuilder.  This keeps upstream's distrobuilder flow
untouched for the existing distros (ubuntu/arch/cachyos/fedora/etc.) and
isolates the new distros' build logic in their own dirs.

distros/bazzite/
  build-rootfs.sh — skopeo+umoci on ghcr.io/ublue-os/bazzite:stable,
                    promote ostree /usr/etc -> /etc, install linux-ps5 RPM,
                    mask rpm-ostree services, set up grow-rootfs + DTM-TA
                    amdgpu reprobe.
  image.yaml, grow-rootfs, grow-rootfs.service

distros/bazzite-deck/
  Symlinks into distros/bazzite/ — bazzite-deck shares all build logic;
  the case branch in build-rootfs.sh dispatches via $DISTRO.

distros/batocera/
  build-rootfs.sh — download upstream batocera-x86_64-*.img.gz, mount,
                    extract /boot/batocera squashfs, swap in linux-ps5
                    kernel, patch libretroControllers.py, install first-boot
                    SHARE-partition creator (ps5-share-init).

docker/image-builder/entrypoint.sh
  - detect_kver() helper from /kernel-debs/ package filenames
  - dispatch to distros/$DISTRO/build-rootfs.sh when present (skips
    distrobuilder for that distro)
  - EFI assembly detects /boot/efi/ vs /boot/bzImage layouts so batocera
    (which mounts FAT at /boot, not /boot/efi, for batocera-part SHARE
    detection) works alongside the standard layout

build_image.sh
  - IMG_SIZE defaults: bazzite* -> 24 GB, batocera* -> 16 GB
  - FORMAT: bazzite* -> rpm (linux-ps5 RPM); batocera* stays deb

CI workflows are NOT modified — the upstream build-image.yml /
trigger-builds.yml continue to apply and will skip these distros unless
explicitly dispatched (they're not in MULTI_DISTROS).  Local builds:
  ./build_image.sh --distro bazzite
  ./build_image.sh --distro bazzite-deck
  ./build_image.sh --distro batocera

Co-authored-by: mia26MAjFm <mia26MAjFm@users.noreply.github.com>
2026-06-20 11:51:53 -04:00
Andy Nguyen
ccc30f19b4 Remove kali since it's too big. 2026-06-20 17:40:50 +02:00
Andy Nguyen
34df7b0b64 Undo changes to zz-update-boot. 2026-06-19 20:14:58 +02:00
Hasanuddin Abu Bakar
76824ba725 fix(kernel): merge UAPI headers into existing usr/ directory (#17)
Both packagers copied the staged UAPI headers with:

    cp -a /out/staging/headers/usr "$DEST/usr"

Because $DEST/usr already exists (modules live under $STAGING/usr/lib/modules,
and other files may be staged under $PKG/usr), cp placed the source directory
inside the destination, producing a nested usr/usr/include/ path. The headers
were therefore installed at the wrong location and not found by builds.

Merge the contents with cp -a .../usr/. "$DEST/usr/" so the headers land at
the expected /usr/include/linux/... path in both .deb and .pkg.tar.zst
packages.
2026-06-16 20:13:44 +02:00
Bug Bounty Zip
7d5efa0583 Add Debian 12 (XFCE) image support
Plain Debian 12 Bookworm with XFCE desktop, LightDM autologin, Firefox
ESR, and standard desktop utilities. Fills the gap between the heavier
Kali (Debian + security tools) and the headless Proxmox (Debian +
hypervisor) — a clean, lightweight Debian desktop for PS5.

Tested on PS5 hardware: XFCE displays over HDMI, boots to desktop.

[skip ci]
2026-06-14 00:39:13 +08:00
Bug Bounty Zip
76d3ba281e ci: merge release table instead of overwriting on single-distro builds
The release step now fetches the existing release body and merges the
new distro rows into it, instead of rebuilding the table from scratch.
This means a single-distro build (e.g. --distro fedora) only updates
that distro's row and checksum — all other distros stay in the table.

Previously, every single-distro CI run wiped the other distros from
the release page, requiring a manual API patch each time.

[skip ci]
2026-06-14 00:30:19 +08:00
Bug Bounty Zip
177cc5cfbf Add Proxmox VE 8 image support
Debian 12 Bookworm base with Proxmox VE 8.4 (pve-manager, pve-qemu-kvm,
qemu-server). Headless hypervisor managed via web UI at https://<ip>:8006.
Login: root/proxmox.

The PS5 kernel can't satisfy proxmox-ve's hard dependency on a Proxmox
kernel, so the recipe installs an equivs stub (ps5-proxmox-kernel-stub)
that Provides: proxmox-default-kernel before installing proxmox-ve. First
boot auto-bridges the PS5 ethernet into vmbr0 with DHCP. WiFi is not
included (Proxmox VM networking requires wired bridging).

Also wires proxmox into the CI matrix, entrypoint staging, and release
table.

[skip ci]
2026-06-13 23:02:59 +08:00
Bug Bounty Zip
ddfda4de88 image-builder: build umoci from source to fix arm64 CI runner crash
Ubuntu's apt umoci (0.4.7) crashes on the self-hosted arm64 runner with
"fatal error: lfstack.push invalid packing" while unpacking Fedora's OCI
base image. The image-builder is an amd64 image, so it runs under qemu on
the arm64 runner, and the emulated process inherits the host's 52-bit-VA
high addresses that the old Go in apt's umoci mis-packs in lfstack. Build
umoci v0.5.0 from source with the Go 1.25 toolchain already installed
(Go >=1.21 handles high virtual addresses correctly). Only Fedora uses
umoci (OCI base); the other distros use debootstrap/pacstrap.

[skip ci]
2026-06-13 07:57:12 +08:00
Bug Bounty Zip
ed54e99482 Add Fedora 44 (GNOME) image support
New fedora distro: distrobuilder fedora-http recipe with full GNOME
desktop (GDM autologin), the PS5 IW620 internal WiFi modules built from
nxp-imx/mwifiex + ps5-linux-mwifiex, and an RPM packaging path for the
shared PS5 kernel (docker/kernel-builder-rpm).

Notable PS5-specific fix: the kernel patches write into the
request_firmware() buffer to skip the firmware signature header
(gfx_v10_0_early_init, amdgpu_sdma_init_microcode). Fedora ships
firmware xz-compressed, and the kernel maps xz-decompressed firmware
read-only (fw_decompress_xz_pages -> fw_map_paged_buf -> vmap
PAGE_KERNEL_RO), so the write oopses amdgpu before /dev/dri exists and
the display never comes up. Distros with zstd or uncompressed firmware
decompress into writable buffers, which is why only Fedora was
affected. The recipe therefore ships the cyan_skillfish GPU firmware
uncompressed, materializing linux-firmware's dedup symlinks first
(unxz refuses symlinks).

Also installs umoci/skopeo in the image builder (Fedora 41+ bases ship
as OCI archives) and wires fedora into build_image.sh (FORMAT=rpm) and
the image-builder entrypoint.

Tested on PS5 hardware: GNOME displays over HDMI, internal WiFi scans
and connects.

[skip ci]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 07:00:06 +08:00
Dan Kluser
50d8ec0b03 ci: reduce compression level 2026-06-03 07:43:58 +02:00
Dan Kluser
5f7cfddd3f ci: update actions version; move release date 2026-06-02 22:00:25 +02:00
Dan Kluser
47b35f48dc ci: move release commit tag 2026-06-02 21:53:31 +02:00
Dan Kluser
2ea14c3e0c ci: remove obsolete upload; using r2 going forward 2026-06-02 21:43:03 +02:00
Andy Nguyen
f624aef764 Update to v1.3 patches. 2026-05-30 22:03:16 +02:00
Bug Bounty Zip
1c629a881e Merge pull request #13 from BugBountyzip/feature/kali-iw620-default-wifi
Add Kali internal IW620 WiFi support
2026-05-29 16:13:08 +03:00
Bug Bounty Zip
90f06ee77b Add Kali internal IW620 WiFi support 2026-05-29 15:53:36 +03:00
Bug Bounty Zip
b68aa667d1 Extend image build timeout 2026-05-27 15:22:44 +03:00
Bug Bounty Zip
a1e2bc92e5 Reduce Kali release image scope 2026-05-27 15:19:17 +03:00
Bug Bounty Zip
0adaf1c6d6 Use Kali snapshot for release builds 2026-05-27 10:38:58 +03:00
Bug Bounty Zip
d0efec98fe Harden self-hosted image builds 2026-05-27 09:17:16 +03:00
Bug Bounty Zip
7f905df12c Merge pull request #11 from ps5-linux/kali-official-support
Include Kali in full image builds
2026-05-27 01:37:57 +03:00
Bug Bounty Zip
daf634a6ed Include Kali in full image builds 2026-05-27 01:36:48 +03:00
Bug Bounty Zip
703dc736e9 Merge pull request #10 from ps5-linux/fix/arch-cachyos-mirrors
Fix Arch mirror selection in image builds
2026-05-27 01:15:15 +03:00
Bug Bounty Zip
dc365dda15 Fix Arch mirror selection in image builds 2026-05-26 21:18:14 +03:00
Bug Bounty Zip
8160bc649b Add Kali Linux image support (#9)
* Add Kali Linux image support

* Remove forced 1080p from Kali cmdline
2026-05-25 21:16:56 +02:00
Andy Nguyen
1ea662d598 Update to v1.2 patches. 2026-05-25 07:44:00 +02:00
Andy Nguyen
04378b78f4 Make backup in zz-update-boot. 2026-05-24 23:01:23 +02:00
Dan
0e1219f422 deprecate alpine (#7) 2026-05-19 09:11:54 +02:00
Dan Kluser
33189d7e7b ci: pull from branch for ci 2026-05-19 07:43:52 +02:00
60 changed files with 3487 additions and 491 deletions

View File

@@ -1,7 +1,6 @@
linux_deb/
linux-bin/
work/
work-alpine/
output/
.git/
ps5-linux-patches/

13
.gitattributes vendored Normal file
View File

@@ -0,0 +1,13 @@
* text=auto
*.sh text eol=lf
*.bash text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.service text eol=lf
build_image.sh text eol=lf
boot/kexec*.sh text eol=lf
distros/**/grow-rootfs text eol=lf
distros/shared/zz-update-boot* text eol=lf
docker/** text eol=lf

View File

@@ -8,19 +8,13 @@ on:
description: 'Distribution'
default: 'ubuntu2604'
type: choice
options: [all, ubuntu2604, arch, cachyos, alpine]
options: [all, ubuntu2604, arch, cachyos, fedora, proxmox, debian, bazzite, bazzite-deck, steamos]
workflow_call:
inputs:
distro:
type: string
default: 'ubuntu2604'
secrets:
REMOTE_SSH_KEY:
required: true
REMOTE_SSH_CONFIG:
required: true
REMOTE_SSH_KNOWN_HOSTS:
required: true
R2_ACCESS_KEY_ID:
required: true
R2_SECRET_ACCESS_KEY:
@@ -40,7 +34,7 @@ jobs:
run: |
INPUT="${{ inputs.distro || 'ubuntu2604' }}"
if [ "$INPUT" = "all" ]; then
echo 'distros=["ubuntu2604","arch","cachyos","alpine"]' >> "$GITHUB_OUTPUT"
echo 'distros=["ubuntu2604","arch","cachyos","fedora","proxmox","debian","bazzite","bazzite-deck","steamos"]' >> "$GITHUB_OUTPUT"
else
echo "distros=[\"$INPUT\"]" >> "$GITHUB_OUTPUT"
fi
@@ -48,13 +42,13 @@ jobs:
build:
needs: matrix
runs-on: self-hosted
timeout-minutes: 240
timeout-minutes: 720
permissions:
contents: write
strategy:
fail-fast: false
max-parallel: 1
max-parallel: 5
matrix:
distro: ${{ fromJson(needs.matrix.outputs.distros) }}
@@ -62,12 +56,40 @@ jobs:
CCACHE_DIR: /home/opc/ccache
steps:
- name: Clean image workspace
run: |
set -euo pipefail
if [ -z "${GITHUB_WORKSPACE:-}" ] || [ "$GITHUB_WORKSPACE" = "/" ]; then
echo "Invalid GITHUB_WORKSPACE" >&2
exit 1
fi
# A previous run can leave loop mounts (kpartx / losetup -P) and
# bind mounts (distrobuilder chroots) live inside image/work/.
# rm -rf then trips on "Directory not empty" because it can't
# cross the mountpoint. Unmount everything under the workspace
# first, then detach any loop devices still backed by files in
# the workspace, then rm.
WS="$GITHUB_WORKSPACE"
# Lazy-unmount any mount whose target is under the workspace.
# Reverse order so deepest mounts go first.
for m in $(findmnt -rn -o TARGET | grep "^$WS/" | sort -r || true); do
echo "unmounting stale $m"
sudo umount -l "$m" || true
done
# Detach any loop device whose backing file lives under the
# workspace (or shows '(deleted)' against one — that means the
# backing file was removed but the loop wasn't released).
for l in $(sudo losetup -l --noheadings -O NAME,BACK-FILE 2>/dev/null \
| awk -v ws="$WS" '$2 ~ "^"ws || $2 ~ ws"/" || $2 ~ "\\(deleted\\)$" {print $1}'); do
echo "detaching stale loop $l"
sudo losetup -d "$l" || true
done
sudo rm -rf "$WS/image"
- name: Checkout image builder
uses: actions/checkout@v6
with:
repository: ps5-linux/ps5-linux-image
path: image
ref: main
- name: Build ${{ matrix.distro }} image
run: |
@@ -90,7 +112,7 @@ jobs:
- name: Compress image
run: |
xz -T0 -9 -c image/output/ps5-${{ matrix.distro }}.img \
xz -T0 -4 -c image/output/ps5-${{ matrix.distro }}.img \
> image/output/ps5-${{ matrix.distro }}.img.xz
- name: Upload to R2
@@ -122,27 +144,6 @@ jobs:
path: meta/
retention-days: 1
- name: Upload to remote
if: github.event_name != 'pull_request' && github.event_name != 'pull_request_target'
env:
SSH_KEY: ${{ secrets.REMOTE_SSH_KEY }}
SSH_CONFIG: ${{ secrets.REMOTE_SSH_CONFIG }}
SSH_KNOWN_HOSTS: ${{ secrets.REMOTE_SSH_KNOWN_HOSTS }}
run: |
DIR=$(mktemp -d)
echo "$SSH_KEY" > "$DIR/key"
chmod 600 "$DIR/key"
echo "$SSH_CONFIG" > "$DIR/config"
sed -i "s|~/.ssh/remote_key|$DIR/key|" "$DIR/config"
echo "$SSH_KNOWN_HOSTS" > "$DIR/known_hosts"
SSH="ssh -F $DIR/config -o UserKnownHostsFile=$DIR/known_hosts"
SCP="scp -F $DIR/config -o UserKnownHostsFile=$DIR/known_hosts"
DEST="image-${{ matrix.distro }}-${{ steps.version.outputs.ts }}"
$SSH remote mkdir -p "./$DEST"
$SCP image/output/*.img.xz "remote:./$DEST/"
$SSH remote 'ls -dt image-${{ matrix.distro }}-*/ | tail -n +5 | xargs rm -rf' || true
rm -rf "$DIR"
release:
needs: build
runs-on: self-hosted
@@ -159,33 +160,112 @@ jobs:
- name: Prepare release body
id: body
run: |
KVER=$(cat meta/kver)
PSHA=$(cat meta/psha)
TS=$(cat meta/ts)
SUMS=$(cat meta/*.sha256)
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');
const path = require('path');
{
echo 'text<<EOF'
echo "PS5 Linux images — built from latest \`main\`."
echo ""
echo "Kernel: \`$KVER\`"
echo "Patches: [\`$PSHA\`](https://github.com/ps5-linux/ps5-linux-patches/commit/$PSHA)"
echo "Built: \`$TS\`"
echo ""
echo "| Image | Download |"
echo "|-------|----------|"
echo "| Ubuntu 26.04 | [\`ps5-ubuntu2604.img.xz\`](https://pub-561df4012f1a46fbbdf618d5cc5941f6.r2.dev/ps5-ubuntu2604.img.xz) |"
echo "| Arch | [\`ps5-arch.img.xz\`](https://pub-561df4012f1a46fbbdf618d5cc5941f6.r2.dev/ps5-arch.img.xz) |"
echo "| CachyOS | [\`ps5-cachyos.img.xz\`](https://pub-561df4012f1a46fbbdf618d5cc5941f6.r2.dev/ps5-cachyos.img.xz) |"
echo "| Alpine | [\`ps5-alpine.img.xz\`](https://pub-561df4012f1a46fbbdf618d5cc5941f6.r2.dev/ps5-alpine.img.xz) |"
echo ""
echo "**SHA256 checksums:**"
echo "\`\`\`"
echo "$SUMS"
echo "\`\`\`"
echo 'EOF'
} >> "$GITHUB_OUTPUT"
const kver = fs.readFileSync('meta/kver', 'utf8').trim();
const psha = fs.readFileSync('meta/psha', 'utf8').trim();
const ts = fs.readFileSync('meta/ts', 'utf8').trim();
// Collect new checksums from this run
const newSums = {};
for (const f of fs.readdirSync('meta').filter(f => f.endsWith('.sha256'))) {
const distro = f.replace('.sha256', '');
newSums[distro] = fs.readFileSync(path.join('meta', f), 'utf8').trim();
}
// All known distros and their display names (order = table order)
const R2 = 'https://pub-561df4012f1a46fbbdf618d5cc5941f6.r2.dev';
const ALL_DISTROS = [
{ key: 'ubuntu2604', label: 'Ubuntu 26.04', file: 'ps5-ubuntu2604.img.xz' },
{ key: 'arch', label: 'Arch', file: 'ps5-arch.img.xz' },
{ key: 'cachyos', label: 'CachyOS', file: 'ps5-cachyos.img.xz' },
{ key: 'fedora', label: 'Fedora 44', file: 'ps5-fedora.img.xz' },
{ key: 'proxmox', label: 'Proxmox VE 8', file: 'ps5-proxmox.img.xz' },
{ key: 'debian', label: 'Debian 12', file: 'ps5-debian.img.xz' },
{ key: 'bazzite', label: 'Bazzite (KDE gaming)', file: 'ps5-bazzite.img.xz' },
{ key: 'bazzite-deck', label: 'Bazzite Deck UI', file: 'ps5-bazzite-deck.img.xz' },
{ key: 'steamos', label: 'SteamOS 3 (Holo)', file: 'ps5-steamos.img.xz' },
];
// Fetch existing release body to preserve distros not built this run
const existingSums = {};
try {
const { data: rel } = await github.rest.repos.getReleaseByTag({
...context.repo, tag: 'latest',
});
// Extract existing checksum lines (format: "hash filename")
const sumBlock = (rel.body || '').match(/```\n([\s\S]*?)```/);
if (sumBlock) {
for (const line of sumBlock[1].trim().split('\n')) {
const m = line.match(/^(\w+)\s+ps5-(\S+)\.img\.xz$/);
if (m) existingSums[m[2]] = line.trim();
}
}
// Also preserve distros that appear in the table even without checksums
for (const d of ALL_DISTROS) {
if (rel.body && rel.body.includes(d.file) && !existingSums[d.key]) {
existingSums[d.key] = '';
}
}
} catch (e) {
core.info('No existing release found, starting fresh');
}
// Merge: new checksums override existing ones
const mergedSums = { ...existingSums };
for (const [k, v] of Object.entries(newSums)) {
mergedSums[k] = v;
}
// Build the table — only include distros that have been built at least once
const rows = ALL_DISTROS
.filter(d => d.key in mergedSums || d.key in newSums)
.map(d => `| ${d.label} | [\`${d.file}\`](${R2}/${d.file}) |`);
// Combine all checksum lines
const allSumLines = ALL_DISTROS
.filter(d => mergedSums[d.key])
.map(d => mergedSums[d.key])
.join('\n');
const body = [
'PS5 Linux images — built from latest `main`.',
'',
`Kernel: \`${kver}\``,
`Patches: [\`${psha}\`](https://github.com/ps5-linux/ps5-linux-patches/commit/${psha})`,
`Built: \`${ts}\``,
'',
'| Image | Download |',
'|-------|----------|',
...rows,
'',
'**SHA256 checksums:**',
'```',
allSumLines,
'```',
].join('\n');
core.setOutput('text', body);
- name: Move latest tag to current commit
uses: actions/github-script@v8
with:
script: |
const ref = 'tags/latest';
try {
await github.rest.git.updateRef({
...context.repo, ref, sha: context.sha, force: true,
});
} catch (e) {
if (e.status !== 422) throw e;
await github.rest.git.createRef({
...context.repo, ref: 'refs/tags/latest', sha: context.sha,
});
}
- name: Create or update release
uses: softprops/action-gh-release@v3
@@ -195,3 +275,20 @@ jobs:
name: PS5 Linux Image (latest)
body: ${{ steps.body.outputs.text }}
make_latest: true
- name: Refresh release publish date
uses: actions/github-script@v8
with:
script: |
const { data: rel } = await github.rest.repos.getReleaseByTag({
...context.repo, tag: 'latest',
});
// Toggling draft off->on->off re-publishes, resetting published_at
// to now while keeping the same release (reactions/comments survive).
await github.rest.repos.updateRelease({
...context.repo, release_id: rel.id, draft: true,
});
await github.rest.repos.updateRelease({
...context.repo, release_id: rel.id, draft: false, make_latest: 'true',
});
core.info(`re-published release ${rel.id}`);

56
.github/workflows/move-release-tag.yml vendored Normal file
View File

@@ -0,0 +1,56 @@
name: Move release tag
on:
workflow_dispatch:
inputs:
commit:
description: 'Commit SHA to point `latest` at (defaults to the selected ref)'
type: string
default: ''
jobs:
move-tag:
runs-on: self-hosted
permissions:
contents: write
steps:
- name: Move latest tag to commit
uses: actions/github-script@v8
with:
script: |
const ref = 'tags/latest';
const sha = '${{ inputs.commit }}'.trim() || context.sha;
try {
await github.rest.git.updateRef({
...context.repo, ref, sha, force: true,
});
} catch (e) {
if (e.status !== 422) throw e;
await github.rest.git.createRef({
...context.repo, ref: 'refs/tags/latest', sha,
});
}
core.info(`latest -> ${sha}`);
- name: Refresh release publish date
uses: actions/github-script@v8
with:
script: |
let rel;
try {
({ data: rel } = await github.rest.repos.getReleaseByTag({
...context.repo, tag: 'latest',
}));
} catch (e) {
if (e.status === 404) { core.info('no latest release yet; skipping'); return; }
throw e;
}
// Toggling draft off->on->off re-publishes, resetting published_at
// to now while keeping the same release (reactions/comments survive).
await github.rest.repos.updateRelease({
...context.repo, release_id: rel.id, draft: true,
});
await github.rest.repos.updateRelease({
...context.repo, release_id: rel.id, draft: false, make_latest: 'true',
});
core.info(`re-published release ${rel.id}`);

View File

@@ -10,7 +10,7 @@ jobs:
distros: ${{ steps.set.outputs.distros }}
steps:
- id: set
run: echo 'distros=["ubuntu2604","arch","cachyos","alpine"]' >> "$GITHUB_OUTPUT"
run: echo 'distros=["ubuntu2604","arch","cachyos"]' >> "$GITHUB_OUTPUT"
build:
needs: matrix
@@ -65,24 +65,6 @@ jobs:
path: meta/
retention-days: 1
- name: Upload to remote
env:
SSH_KEY: ${{ secrets.REMOTE_SSH_KEY }}
SSH_CONFIG: ${{ secrets.REMOTE_SSH_CONFIG }}
SSH_KNOWN_HOSTS: ${{ secrets.REMOTE_SSH_KNOWN_HOSTS }}
run: |
DIR=$(mktemp -d)
echo "$SSH_KEY" > "$DIR/key"
chmod 600 "$DIR/key"
echo "$SSH_CONFIG" > "$DIR/config"
sed -i "s|~/.ssh/remote_key|$DIR/key|" "$DIR/config"
echo "$SSH_KNOWN_HOSTS" > "$DIR/known_hosts"
SSH="ssh -F $DIR/config -o UserKnownHostsFile=$DIR/known_hosts"
SCP="scp -F $DIR/config -o UserKnownHostsFile=$DIR/known_hosts"
$SSH remote mkdir -p ./test-upload
$SCP image/output/ps5-${{ matrix.distro }}.img.xz "remote:./test-upload/"
rm -rf "$DIR"
- name: Cleanup
if: always()
run: rm -rf image/output meta
@@ -119,7 +101,6 @@ jobs:
echo "| Ubuntu 26.04 | [\`ps5-ubuntu2604.img.xz\`](https://pub-561df4012f1a46fbbdf618d5cc5941f6.r2.dev/ps5-ubuntu2604.img.xz) |"
echo "| Arch | [\`ps5-arch.img.xz\`](https://pub-561df4012f1a46fbbdf618d5cc5941f6.r2.dev/ps5-arch.img.xz) |"
echo "| CachyOS | [\`ps5-cachyos.img.xz\`](https://pub-561df4012f1a46fbbdf618d5cc5941f6.r2.dev/ps5-cachyos.img.xz) |"
echo "| Alpine | [\`ps5-alpine.img.xz\`](https://pub-561df4012f1a46fbbdf618d5cc5941f6.r2.dev/ps5-alpine.img.xz) |"
echo ""
echo "**SHA256 checksums:**"
echo "\`\`\`"
@@ -152,22 +133,6 @@ jobs:
RCLONE_CONFIG_R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
RCLONE_CONFIG_R2_NO_CHECK_BUCKET: true
run: |
for d in ubuntu2604 arch cachyos alpine; do
for d in ubuntu2604 arch cachyos; do
rclone delete "r2:${{ secrets.R2_BUCKET }}/test-${d}.img.xz" -v || true
done
- name: Clean up remote test files
env:
SSH_KEY: ${{ secrets.REMOTE_SSH_KEY }}
SSH_CONFIG: ${{ secrets.REMOTE_SSH_CONFIG }}
SSH_KNOWN_HOSTS: ${{ secrets.REMOTE_SSH_KNOWN_HOSTS }}
run: |
DIR=$(mktemp -d)
echo "$SSH_KEY" > "$DIR/key"
chmod 600 "$DIR/key"
echo "$SSH_CONFIG" > "$DIR/config"
sed -i "s|~/.ssh/remote_key|$DIR/key|" "$DIR/config"
echo "$SSH_KNOWN_HOSTS" > "$DIR/known_hosts"
SSH="ssh -F $DIR/config -o UserKnownHostsFile=$DIR/known_hosts"
$SSH remote rm -rf ./test-upload
rm -rf "$DIR"

View File

@@ -17,9 +17,6 @@ jobs:
with:
distro: all
secrets:
REMOTE_SSH_KEY: ${{ secrets.REMOTE_SSH_KEY }}
REMOTE_SSH_CONFIG: ${{ secrets.REMOTE_SSH_CONFIG }}
REMOTE_SSH_KNOWN_HOSTS: ${{ secrets.REMOTE_SSH_KNOWN_HOSTS }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}

View File

@@ -0,0 +1,72 @@
name: Upstream patches watch
run-name: Watch ps5-linux-patches for new kernel-* tags
on:
schedule:
- cron: '0 4 * * *'
workflow_dispatch:
permissions:
contents: write
actions: write
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
// Read build_image.sh from main via API (no checkout — runner
// workspace has root-owned build leftovers EACCES checkout).
const file = await github.rest.repos.getContent({
owner, repo, path: 'build_image.sh', ref: 'main',
});
const buf = Buffer.from(file.data.content, 'base64');
let body = buf.toString();
const patchesRepo = (body.match(/^PATCHES_REPO="([^"]+)"/m) || [])[1];
if (patchesRepo !== 'https://github.com/ps5-linux/ps5-linux-patches.git') {
core.info(`PATCHES_REPO is a fork (${patchesRepo}) — skipping auto-bump.`);
return;
}
// Releases API returns by published_at (most recent first).
// Tags API order is not chronological — hex SHA suffixes sort
// alphabetically.
const releases = await github.paginate(github.rest.repos.listReleases, {
owner: 'ps5-linux', repo: 'ps5-linux-patches', per_page: 100,
});
const re = /^kernel-\d+\.\d+\.\d+-[a-f0-9]+$/;
const latest = releases.find(r => re.test(r.tag_name))?.tag_name;
if (!latest) { core.setFailed('no kernel-*-* tags found'); return; }
const cur = (body.match(/^PATCHES_REF="([^"]+)"/m) || [])[1];
core.info(`upstream latest: ${latest}`);
core.info(`current ref: ${cur}`);
if (latest === cur) { core.info('already current — no bump.'); return; }
const updated = body.replace(
new RegExp(`^PATCHES_REF="${cur}"`, 'm'),
`PATCHES_REF="${latest}"`,
);
await github.rest.repos.createOrUpdateFileContents({
owner, repo, path: 'build_image.sh', branch: 'main',
message: `kernel: auto-bump ${cur} -> ${latest}`,
content: Buffer.from(updated).toString('base64'),
sha: file.data.sha,
committer: { name: 'ps5-linux-bot', email: 'ps5-linux-bot@users.noreply.github.com' },
author: { name: 'ps5-linux-bot', email: 'ps5-linux-bot@users.noreply.github.com' },
});
core.info(`pushed bump to ${latest}`);
// Pushes authored by GITHUB_TOKEN don't fire workflow_run /
// push events, so trigger-builds.yml won't auto-run. Kick off
// the matrix build directly.
await github.rest.actions.createWorkflowDispatch({
owner, repo, workflow_id: 'build-image.yml', ref: 'main',
inputs: { distro: 'all' },
});
core.info('dispatched build-image.yml distro=all');

View File

@@ -1,11 +1,11 @@
# PS5 Linux Image Builder
Builds bootable Linux USB images for PlayStation 5 using Docker containers. Supports Ubuntu 26.04, Arch, CachyOS (Gamescope + Steam), and Alpine, individually or as a multi-distro image with kexec switching.
Builds bootable Linux USB images for PlayStation 5 using Docker containers. Supports Ubuntu 26.04, Arch, CachyOS (Gamescope + Steam), Fedora (GNOME), individually or as a multi-distro image with kexec switching.
## Prerequisites
- Docker (with permission to run `--privileged` containers) — install as per your distro's instructions
- ~30GB free disk space
- ~30GB free disk space for Ubuntu, Arch, or CachyOS
Once Docker is installed, add your user to the docker group and apply it without logging out:
@@ -27,7 +27,14 @@ OR
OR
# Build a multi-distro image (ubuntu2604 + arch + alpine + cachyos)
OR
# Build Fedora (GNOME desktop)
./build_image.sh --distro fedora
OR
# Build a multi-distro image (ubuntu2604 + arch + cachyos)
./build_image.sh --distro all
```
@@ -43,12 +50,12 @@ sudo dd if=output/ps5-ubuntu2604.img of=/dev/sdX bs=4M status=progress
| Flag | Description | Default |
|------|-------------|---------|
| `--distro` | `ubuntu2604`, `arch`, `cachyos`, `alpine`, or `all` | `ubuntu2604` |
| `--kernel` | Path to kernel source directory | auto-clone `v6.19.10` |
| `--distro` | `ubuntu2604`, `arch`, `cachyos`, `fedora`, or `all` | `ubuntu2604` |
| `--kernel` | Path to kernel source directory | auto-clone version selected by PS5 patch set |
| `--img-size` | Disk image size in MB | `12000` (`32000` for `all`) |
| `--clean` | Remove all cached build artifacts and start fresh | off |
| `--kernel-only` | Build and package the kernel only, then exit | off |
| `--patches-ref` | Branch, tag, or commit SHA for patches | `v1.0` |
| `--patches-ref` | Branch, tag, or commit SHA for patches | `v1.2` |
## Caching
@@ -66,7 +73,7 @@ Use `--clean` to wipe everything and rebuild from scratch. The build will also s
PS5 Linux Image Builder
=======================
Distro: all
(ubuntu2604 arch alpine cachyos)
(ubuntu2604 arch cachyos)
Image size: 32000MB
Kernel src: /path/to/work/linux
@@ -91,19 +98,17 @@ All verbose output goes to `build.log`. The terminal shows a spinner with live p
| Ubuntu 26.04 (Resolute) | GNOME | `.deb` | systemd |
| Arch | Sway | `.pkg.tar.zst` | systemd |
| CachyOS | Gamescope + Steam Big Picture (Arch + `[cachyos]` repo, no v3 migration in image build) | `.pkg.tar.zst` | systemd |
| Alpine (3.21) | GNOME | extracted from `.deb` | OpenRC |
## Multi-distro Image
`--distro all` builds a 32GB image with 5 partitions (one EFI boot partition plus four root filesystems):
`--distro all` builds a 32GB image with 4 partitions (one EFI boot partition plus three root filesystems):
| Partition | Type | Label | Content |
|-----------|------|-------|---------|
| p1 | FAT32 | boot | Shared kernel, per-distro initrds, kexec scripts |
| p2 | ext4 | ubuntu2604 | Ubuntu 26.04 rootfs |
| p3 | ext4 | arch | Arch rootfs |
| p4 | ext4 | alpine | Alpine rootfs |
| p5 | ext4 | cachyos | CachyOS rootfs |
| p4 | ext4 | cachyos | CachyOS rootfs |
The boot partition contains kexec scripts to switch between distros at runtime. Ubuntu 26.04 is the default boot target.
@@ -130,7 +135,7 @@ sudo dpkg -i linux-bin/linux-ps5_*.deb
build_image.sh # Image builder (also supports --kernel-only)
docker/
kernel-builder/ # Kernel compilation container
kernel-builder-arch/ # Repackages .deb kernel as .pkg.tar.zst
kernel-builder-arch/ # Repackages .deb kernel as .pkg.tar.zst
image-builder/
Dockerfile # Image building container (distrobuilder)
entrypoint.sh # Single-distro build logic
@@ -139,12 +144,11 @@ distros/
ubuntu2604/ # Ubuntu 26.04 (Resolute)
arch/ # Arch Linux
cachyos/ # CachyOS repos + Gamescope/Steam
alpine/ # Alpine 3.21
shared/ # Kernel postinst hooks (single + multi)
boot/
cmdline.txt # Kernel cmdline template (__DISTRO__ placeholder)
vram.txt # VRAM allocation
kexec-{ubuntu2604,arch,alpine,cachyos}.sh
kexec-{ubuntu2604,arch,cachyos}.sh
work/ # Build artifacts (auto-created)
linux-bin/ # Compiled kernel packages
output/ # Final .img files

View File

@@ -1,6 +0,0 @@
#!/bin/sh
# Switch to Alpine Linux via kexec
set -e
BOOT=/boot/efi
kexec -l "$BOOT/bzImage" --initrd="$BOOT/initrd-alpine.img" --command-line="$(cat $BOOT/cmdline-alpine.txt)"
kexec -e

View File

@@ -9,21 +9,21 @@ KERNEL_SRC=""
CLEAN=false
IMG_SIZE=12000
KERNEL_ONLY=false
PATCHES_REF="v1.1"
PATCHES_REF="kernel-7.1.3-e81e281"
MULTI_DISTROS="ubuntu2604 arch alpine cachyos"
MULTI_DISTROS="ubuntu2604 arch cachyos"
usage() {
echo "Usage: $0 [--distro <distro>] [--kernel <path>] [--img-size <MB>] [--clean]"
echo ""
echo "Options:"
echo " --distro Distribution to build: ubuntu2604, arch, cachyos, alpine, all (default: ubuntu2604)"
echo " --distro Distribution to build: ubuntu2604, arch, cachyos, fedora, proxmox, debian, bazzite, bazzite-deck, batocera, all (default: ubuntu2604)"
echo " --kernel Path to kernel source directory (default: auto-clone to work/linux/)"
echo " --img-size Disk image size in MB (default: 12000, 32000 for --distro all)"
echo " --clean Remove all cached build artifacts and start from scratch"
echo " --clean-only Remove all cached build artifacts and exit"
echo " --kernel-only Build and package the kernel only, then exit"
echo " --patches-ref Branch, tag, or commit SHA for patches (default: v1.0)"
echo " --patches-ref Branch, tag, or commit SHA for patches (default: v1.2)"
exit 1
}
@@ -65,8 +65,31 @@ if [ "$DISTRO" = "all" ] && [ "$IMG_SIZE" = "12000" ]; then
IMG_SIZE=32000
fi
# Bazzite assembles the OCI rootfs + an embedded /sysroot/ostree/repo/objects
# (a deduplicated second copy of the same content) + the linux-ps5 kernel —
# 12 GB is not enough headroom. Bump the default for any bazzite* target.
# Batocera unsquashes to ~6 GB; 12 GB is tight once kernel + initrd +
# /userdata defaults are added. Bump to 16 GB.
case "$DISTRO" in
bazzite*)
if [ "$IMG_SIZE" = "12000" ]; then
IMG_SIZE=24000
fi
;;
batocera*)
if [ "$IMG_SIZE" = "12000" ]; then
IMG_SIZE=16000
fi
;;
esac
if [ -z "$FORMAT" ]; then
case "$DISTRO" in arch|cachyos) FORMAT="arch" ;; all) FORMAT="all" ;; *) FORMAT="deb" ;; esac
case "$DISTRO" in
arch|cachyos|steamos) FORMAT="arch" ;;
fedora|bazzite*) FORMAT="rpm" ;;
all) FORMAT="all" ;;
*) FORMAT="deb" ;;
esac
fi
KERNEL_BUILDER_PLATFORM="linux/amd64"
@@ -106,6 +129,7 @@ SKIP_CHROOT=false
case "$FORMAT" in
arch) ls "$KERNEL_OUT"/*.pkg.tar.zst 1>/dev/null 2>&1 && SKIP_KERNEL=true ;;
rpm) ls "$KERNEL_OUT"/*.rpm 1>/dev/null 2>&1 && SKIP_KERNEL=true ;;
all) ls "$KERNEL_OUT"/*.deb 1>/dev/null 2>&1 && \
ls "$KERNEL_OUT"/*.pkg.tar.zst 1>/dev/null 2>&1 && SKIP_KERNEL=true ;;
*) ls "$KERNEL_OUT"/*.deb 1>/dev/null 2>&1 && SKIP_KERNEL=true ;;
@@ -276,7 +300,7 @@ else
KERNEL_SRC="$(cd "$KERNEL_SRC" && pwd)"
rm -f "$KERNEL_OUT"/*.deb "$KERNEL_OUT"/*.pkg.tar.zst
rm -f "$KERNEL_OUT"/*.deb "$KERNEL_OUT"/*.pkg.tar.zst "$KERNEL_OUT"/*.rpm
run_stage "Build kernel builder image" \
docker build --platform "$KERNEL_BUILDER_PLATFORM" -t ps5-kernel-builder \
@@ -310,6 +334,16 @@ else
-v "$KERNEL_OUT":/out \
ps5-kernel-packager-arch
esac
case "$FORMAT" in rpm)
run_stage "Build rpm packager image" \
docker build -t ps5-kernel-packager-rpm \
-f "$SCRIPT_DIR/docker/kernel-builder-rpm/Dockerfile" "$SCRIPT_DIR"
run_stage "Package kernel (.rpm)" \
docker run --rm --name "$DOCKER_NAME" \
-v "$KERNEL_OUT":/out \
ps5-kernel-packager-rpm
esac
fi
if [ "$KERNEL_ONLY" = true ]; then
@@ -321,7 +355,8 @@ fi
# --- Step 2: Build distribution image ---
run_stage "Build image builder image" \
docker build -t ps5-image-builder -f "$SCRIPT_DIR/docker/image-builder/Dockerfile" "$SCRIPT_DIR"
docker build --pull --no-cache \
-t ps5-image-builder -f "$SCRIPT_DIR/docker/image-builder/Dockerfile" "$SCRIPT_DIR"
if [ "$DISTRO" = "all" ]; then
DOCKER_ARGS=(

View File

@@ -1,14 +0,0 @@
#!/bin/sh
# Grows the root partition and filesystem to fill the disk.
# Runs once on first boot, then disables itself.
set -e
ROOT_DEV=$(findmnt -no SOURCE /)
DISK=$(lsblk -ndo PKNAME "$ROOT_DEV")
PART_NUM=$(cat /sys/class/block/$(basename "$ROOT_DEV")/partition)
growpart "/dev/$DISK" "$PART_NUM" || true
partprobe "/dev/$DISK"
resize2fs "$ROOT_DEV" || true
rc-update del grow-rootfs default

View File

@@ -1,13 +0,0 @@
#!/sbin/openrc-run
description="Grow root filesystem to fill disk"
depend() {
need localmount
}
start() {
ebegin "Growing root filesystem"
/usr/local/sbin/grow-rootfs
eend $?
}

View File

@@ -1,175 +0,0 @@
image:
name: ps5-alpine
distribution: alpine
release: "3.21"
description: Alpine Linux with Weston
architecture: x86_64
source:
downloader: alpinelinux-http
url: https://dl-cdn.alpinelinux.org/alpine
skip_verification: true
packages:
manager: apk
update: true
cleanup: true
repositories:
- name: repositories
url: |-
https://dl-cdn.alpinelinux.org/alpine/v3.21/main
https://dl-cdn.alpinelinux.org/alpine/v3.21/community
sets:
- packages:
# Wayland compositor + basics
- weston
- weston-shell-desktop
- weston-backend-drm
- weston-xwayland
- weston-terminal
- foot
- xwayland
# Wayland support
- dbus
- eudev
- elogind
- polkit-elogind
- seatd
# Audio / media
- pipewire
- wireplumber
- pipewire-pulse
# Display / GPU
- mesa-gbm
- mesa-egl
- mesa-gl
- mesa-dri-gallium
- mesa-va-gallium
- mesa-vulkan-ati
- libinput
- xkeyboard-config
# Networking
- networkmanager
- networkmanager-wifi
- wpa_supplicant
- linux-firmware
- linux-firmware-amdgpu
- xf86-video-amdgpu
# System
- openrc
- openssh
- sudo
- shadow
- nano
- bash
- coreutils
- util-linux
- e2fsprogs
- e2fsprogs-extra
- cloud-utils-growpart
- mkinitfs
- kmod
- kexec-tools
- syslog-ng
# Fonts
- font-ubuntu
- font-liberation
- font-dejavu
- fontconfig
action: install
files:
- path: /etc/hostname
generator: hostname
- path: /etc/hosts
generator: hosts
- path: /etc/kernel/postinst.d/zz-update-boot
generator: copy
source: /tmp/build-staging/zz-update-boot
mode: "0755"
- path: /etc/fstab
generator: copy
source: /tmp/build-staging/fstab
- path: /usr/local/sbin/grow-rootfs
generator: copy
source: /tmp/build-staging/grow-rootfs
mode: "0755"
- path: /etc/init.d/grow-rootfs
generator: copy
source: /tmp/build-staging/grow-rootfs.openrc
mode: "0755"
actions:
- trigger: post-unpack
action: |-
#!/bin/sh
set -eux
# Retry up to 10 times on transient network errors
echo "retries = 10" >> /etc/apk/apk.conf 2>/dev/null || true
# dbus post-install chokes on an empty machine-id
rm -f /etc/machine-id
- trigger: post-packages
action: |-
#!/bin/sh
set -eux
fc-cache -f -v
# SSH
sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
rc-update add syslog-ng default
rc-update add udev sysinit
rc-update add udev-trigger sysinit
rc-update add udev-settle sysinit
rc-update add sshd default
rc-update add dbus default
rc-update add elogind default
rc-update add seatd default
rc-update add networkmanager default
- trigger: post-files
action: |-
#!/bin/sh
set -eux
rc-update add grow-rootfs default
# Create default user (ps5/ps5) in groups needed for Wayland/input
addgroup ps5
adduser -D -s /bin/bash -G ps5 ps5
echo "ps5:ps5" | chpasswd
addgroup ps5 wheel
addgroup ps5 video
addgroup ps5 input
addgroup ps5 seat
echo "%wheel ALL=(ALL:ALL) NOPASSWD: ALL" > /etc/sudoers.d/wheel
# Auto-start weston on tty1 login
{
echo 'if [ "$(tty)" = "/dev/tty1" ] && [ -z "$WAYLAND_DISPLAY" ]; then'
echo ' export XDG_RUNTIME_DIR="/tmp/xdg-runtime-$(id -u)"'
echo ' mkdir -p "$XDG_RUNTIME_DIR"'
echo ' chmod 700 "$XDG_RUNTIME_DIR"'
echo ' exec weston --backend=drm-backend.so'
echo 'fi'
} >> /home/ps5/.bash_profile
chown -R ps5:ps5 /home/ps5
mkdir -p /boot/efi
mappings:
architecture_map: alpinelinux

View File

@@ -1,14 +1,81 @@
#!/bin/bash
# Grows the root partition and filesystem to fill the disk.
# Runs once on first boot, then disables itself.
# Grows the root partition + filesystem to fill the disk on first boot.
# Self-disables after success.
#
# Uses sfdisk + partx + resize2fs (all in util-linux + e2fsprogs, both
# present in the minimal Arch/SteamOS base). Earlier versions called
# growpart from cloud-utils — but that package isn't in the SteamOS
# recovery image, so the unit failed with "growpart: command not found"
# and rootfs stayed at the dd'd image size (~14 GB) on any disk larger
# than that.
set -e
ROOT_DEV=$(findmnt -no SOURCE /)
DISK="/dev/$(lsblk -ndo PKNAME "$ROOT_DEV")"
PART_NUM=$(cat /sys/class/block/$(basename "$ROOT_DEV")/partition)
parted -s "$DISK" resizepart "$PART_NUM" 100%
partprobe "$DISK"
# Our images dd at their built size (~14 GB) onto a much larger USB. The GPT
# backup header is therefore at byte ~built-size, not at end of disk. Move
# the backup header to end-of-disk first so partition table growth works.
sgdisk -e "$DISK"
# If a previous flash left behind partitions BEYOND our rootfs + boot pair
# (e.g. a stale `linux-home` partset at part 3 from a prior SteamOS install
# on the same drive), the partition extend refuses to grow into the space
# already claimed by the leftover partition. Delete every partition with a
# number higher than 2 — we only ever create partitions 1 (rootfs) + 2 (boot).
# Important: sgdisk only rewrites the on-disk GPT; we must ALSO drop those
# stale entries from the kernel's in-memory partition table via partx -d,
# or BLKPG_RESIZE_PARTITION below returns EBUSY because the kernel still
# thinks sda1 would have to grow into space owned by phantom sda3.
for n in $(parted -ms "$DISK" print 2>/dev/null \
| awk -F: 'NR>2 && $1+0>2 {print $1}'); do
sgdisk -d "$n" "$DISK" 2>/dev/null || true
partx -d --nr "$n" "$DISK" 2>/dev/null || true
done
# sfdisk `,+` extends partition $PART_NUM to consume all available free
# space. Works on a mounted/live partition because sfdisk only rewrites
# the GPT entry (kernel re-read of the partition table comes next).
echo ",+" | sfdisk --no-reread -N "$PART_NUM" "$DISK"
# Make the kernel pick up the new partition size without unmounting via
# BLKPG_RESIZE_PARTITION. partx -u also uses BLKPG, but on some images
# (notably SteamOS recovery loop-mounted btrfs) partx returns an error
# updating the mounted partition; the direct ioctl is more reliable.
python3 - "$DISK" "$PART_NUM" <<'PY'
import sys, os, fcntl, ctypes, subprocess
disk, pno = sys.argv[1], int(sys.argv[2])
out = subprocess.run(["sfdisk","--bytes","-q","-l",disk],
capture_output=True, text=True, check=True).stdout
start = sectors = None
for line in out.splitlines():
if line.startswith(f"{disk}{pno} ") or line.startswith(f"{disk}p{pno} "):
f = line.split()
start, sectors = int(f[1]), int(f[3])
break
if start is None:
sys.exit(f"could not parse partition {pno} from sfdisk -l {disk}")
class P(ctypes.Structure):
_fields_=[("start",ctypes.c_longlong),("length",ctypes.c_longlong),
("pno",ctypes.c_int),("devname",ctypes.c_char*64),
("volname",ctypes.c_char*64)]
class A(ctypes.Structure):
_fields_=[("op",ctypes.c_int),("flags",ctypes.c_int),
("datalen",ctypes.c_int),("data",ctypes.c_void_p)]
p = P(start=start*512, length=sectors*512, pno=pno)
a = A(op=3, flags=0, datalen=ctypes.sizeof(p), data=ctypes.addressof(p))
fd = os.open(disk, os.O_RDONLY)
try:
fcntl.ioctl(fd, 0x1269, a) # BLKPG, BLKPG_RESIZE_PARTITION
finally:
os.close(fd)
PY
# Belt-and-suspenders: also try partx in case BLKPG via python failed.
partx -u --nr "$PART_NUM" "$DISK" 2>/dev/null || true
# Online ext4 grow.
resize2fs "$ROOT_DEV"
systemctl disable grow-rootfs.service

View File

@@ -35,6 +35,10 @@ packages:
- wl-clipboard
- xorg-xwayland
# Bluetooth
- bluez
- bluez-utils
# Audio / media
- pipewire
- wireplumber
@@ -125,9 +129,12 @@ actions:
pacman-key --init
pacman-key --populate archlinux
# Fetch up-to-date HTTPS mirrors ranked by score, force-refresh package DBs
curl -fsSL "https://archlinux.org/mirrorlist/?country=all&protocol=https&use_mirror_status=on" \
| sed 's/^#Server/Server/' > /etc/pacman.d/mirrorlist
# Keep CI on a small stable mirror set. The full active global list can
# select slow mirrors and fail pacman's low-speed download timeout.
cat > /etc/pacman.d/mirrorlist <<'EOF'
Server = https://geo.mirror.pkgbuild.com/$repo/os/$arch
Server = https://mirrors.kernel.org/archlinux/$repo/os/$arch
EOF
pacman -Syy --noconfirm
@@ -163,6 +170,8 @@ actions:
ln -sf /etc/systemd/system/grow-rootfs.service \
/etc/systemd/system/local-fs.target.wants/grow-rootfs.service
systemctl enable bluetooth.service
# Create default user (ps5/ps5)
useradd -m -G wheel,seat -s /bin/bash ps5
echo "ps5:ps5" | chpasswd

View File

@@ -0,0 +1,28 @@
# batocera
Adds support for [Batocera](https://batocera.org/) (Buildroot-based
retro-emulation distro) on PS5 hardware.
Batocera ships as an `.img.gz` with FAT32 boot + ext4 SHARE partitions; the
OS itself lives in a squashfs at `/boot/batocera`.
`distros/batocera/build-rootfs.sh` downloads + unsquashes that image and
swaps in the linux-ps5 kernel:
1. Download `https://mirrors.o2switch.fr/batocera/x86_64/stable/last/`
2. Loop-mount the FAT32, find the embedded squashfs
3. `unsquashfs` to `$CHROOT`
4. Extract the linux-ps5 `.deb`'s `vmlinuz``/boot/bzImage`
5. Patch `libretroControllers.py` (PS5 controller-mapping fix)
6. Set up first-boot SHARE partition creator (`ps5-share-init`)
7. Write fstab with `/boot vfat` (NOT `/boot/efi` — batocera-part's
SHARE auto-detection greps `/proc/mounts` for `/boot`)
## Build locally
```bash
./build_image.sh --distro batocera
```
Image size: 16 GB default (Batocera unsquashes to ~6 GB; headroom for
/userdata). Override the batocera release with `BATOCERA_VER` /
`BATOCERA_BUILD` envs (defaults track the upstream "last" channel).

369
distros/batocera/build-rootfs.sh Executable file
View File

@@ -0,0 +1,369 @@
#!/bin/bash
# distros/batocera/build-rootfs.sh — download upstream batocera image, extract
# its squashfs to $CHROOT, swap in our PS5 kernel + modules. Called from
# docker/image-builder/entrypoint.sh for DISTRO=batocera*.
#
# Batocera is Buildroot-based, shipping as a single .img.gz with FAT32 boot +
# ext4 SHARE partitions; the OS itself lives in a squashfs file at
# /boot/batocera on the FAT32. We unsquash, swap kernel + modules in, and
# the rest of the standard image-builder flow packs it onto ext4.
#
# Expects in env: DISTRO, CHROOT, KVER, ROOT_LABEL, EFI_LABEL
# Expects on disk: /kernel-debs/*.deb (linux-ps5 .deb to extract bzImage from)
set -ex
: "${CHROOT:?ERROR: \$CHROOT unset/empty}"
[ -d "$CHROOT" ] || { echo "ERROR: \$CHROOT=$CHROOT not a directory"; exit 2; }
case "$CHROOT" in /) echo "ERROR: refuse to operate on /"; exit 2 ;; esac
# Batocera is a Buildroot-based emulation distro. It ships as a
# single .img.gz with FAT32 boot + ext4 SHARE partitions; the OS
# itself lives in a squashfs file (`/boot/batocera`) on the FAT32.
# We unsquash, swap our PS5 kernel + modules in, and let the rest
# of the standard image-builder flow pack everything onto ext4.
# `last/` always points at the current build, but the .img.gz filename
# inside it bakes in the version + date (e.g. batocera-x86_64-43.1-20260529.img.gz),
# and the mirror rotates older builds out. Scrape the index to discover
# whatever's there today rather than hardcoding (the previous hardcoded
# 43-20260430 went 404 within ~4 weeks).
BATOCERA_INDEX_URL="${BATOCERA_INDEX_URL:-https://mirrors.o2switch.fr/batocera/x86_64/stable/last/}"
if [ -z "${BATOCERA_URL:-}" ]; then
IMG_NAME=$(wget -qO- "$BATOCERA_INDEX_URL" \
| grep -oE 'batocera-x86_64-[0-9.]+-[0-9]+\.img\.gz' \
| head -1)
if [ -z "$IMG_NAME" ]; then
echo "ERROR: couldn't find a batocera-x86_64-*.img.gz link at $BATOCERA_INDEX_URL"
exit 1
fi
BATOCERA_URL="${BATOCERA_INDEX_URL}${IMG_NAME}"
else
IMG_NAME="$(basename "$BATOCERA_URL")"
fi
# Pull VER + BUILD out of the discovered (or overridden) filename so the
# cache key + log lines stay informative.
BATOCERA_VER=$(echo "$IMG_NAME" | sed -E 's/^batocera-x86_64-([0-9.]+)-[0-9]+\.img\.gz$/\1/')
BATOCERA_BUILD=$(echo "$IMG_NAME" | sed -E 's/^batocera-x86_64-[0-9.]+-([0-9]+)\.img\.gz$/\1/')
echo "=== Batocera: locate / download $BATOCERA_VER ($BATOCERA_BUILD) ==="
# /build/cache is per-run temp. The workflow symlinks /build/cache/
# persistent -> /data/cache/ps5/downloads, so the .img.gz can be
# pre-staged or survive between runs. The mirror rate-limits per-IP
# to ~250KB/s sustained (4MB/s burst), so re-downloading every run
# is unacceptably slow.
# The workflow hard-links /data/cache/ps5/downloads/* into
# image/work/cache before the build container starts, so the
# image appears as /build/cache/batocera-*.img.gz inside.
CACHED="/build/cache/batocera-${BATOCERA_VER}-${BATOCERA_BUILD}.img.gz"
if [ ! -s "$CACHED" ]; then
echo ">> No cached image, downloading (this will be slow due to mirror rate-limiting)"
wget --tries=3 -O "$CACHED.part" "$BATOCERA_URL"
mv "$CACHED.part" "$CACHED"
fi
echo ">> Using $CACHED ($(du -h "$CACHED" | cut -f1))"
echo "=== Batocera: decompress + loop ==="
BAT_IMG=/build/batocera-src.img
gunzip -c "$CACHED" > "$BAT_IMG"
BATLOOP=$(losetup -Pf --show "$BAT_IMG")
sleep 1
# kpartx fallback in case partition kernel events didn't fire
[ -e "${BATLOOP}p1" ] || kpartx -av "$BATLOOP"
BAT_MNT=$(mktemp -d)
BAT_PART1=""
for p in "${BATLOOP}p1" "/dev/mapper/$(basename "$BATLOOP")p1"; do
[ -e "$p" ] && BAT_PART1="$p" && break
done
mount -o ro "$BAT_PART1" "$BAT_MNT"
BAT_SQUASH=""
for c in /boot/batocera /batocera /boot/batocera.update; do
[ -f "$BAT_MNT$c" ] && BAT_SQUASH="$BAT_MNT$c" && break
done
if [ -z "$BAT_SQUASH" ]; then
echo "ERROR: squashfs not found in batocera image:"
find "$BAT_MNT" -maxdepth 3 -type f | head -30
exit 1
fi
echo "=== Batocera: unsquashfs $BAT_SQUASH -> $CHROOT ==="
unsquashfs -f -d "$CHROOT" "$BAT_SQUASH"
# Batocera ships a SECOND squashfs (boot/rufomaculata) with the
# libretro cores, mame binary, and other emulator assets. At
# runtime it's mounted as a second overlayfs layer on top of the
# main batocera squashfs. We don't do overlay — just unsquash
# rufomaculata on top of $CHROOT so the unified view is realised
# on the ext4 root. Without this, /usr/lib/libretro/ doesn't
# exist and EmulationStation reports "no games start" because
# retroarch fails to load any core.
if [ -f "$BAT_MNT/boot/rufomaculata" ]; then
echo "=== Batocera: unsquashfs boot/rufomaculata (libretro + mame) -> $CHROOT ==="
unsquashfs -f -d "$CHROOT" "$BAT_MNT/boot/rufomaculata"
else
echo "WARN: boot/rufomaculata not found — emulator cores will be missing"
fi
umount "$BAT_MNT"
rmdir "$BAT_MNT"
kpartx -dv "$BATLOOP" 2>/dev/null || true
losetup -d "$BATLOOP"
rm -f "$BAT_IMG"
echo "=== Batocera: install linux-ps5 kernel + modules ==="
KSTAGE=/tmp/bat-kernel-staging
rm -rf "$KSTAGE"; mkdir -p "$KSTAGE"
# The kernel-builder ships a single combined linux-ps5_*.deb
# (Provides: linux-image-X) — there is no linux-image-*.deb on
# disk, so target the actual filename pattern.
shopt -s nullglob
for deb in /kernel-debs/linux-ps5*.deb /kernel-debs/linux-image-*.deb; do
[ -f "$deb" ] && dpkg-deb -x "$deb" "$KSTAGE"
done
shopt -u nullglob
KVER=$(ls -1 "$KSTAGE/lib/modules" 2>/dev/null | head -1)
if [ -z "$KVER" ]; then
echo "ERROR: no kernel modules found after dpkg-deb -x of /kernel-debs/*.deb"
ls -la /kernel-debs/
exit 1
fi
rm -rf "$CHROOT"/lib/modules/*
cp -a "$KSTAGE/lib/modules/$KVER" "$CHROOT/lib/modules/"
mkdir -p "$CHROOT/boot/efi"
cp "$KSTAGE/boot/vmlinuz-$KVER" "$CHROOT/boot/efi/bzImage"
# depmod -b runs from outside the chroot — Batocera's busybox
# depmod may not be present, and host depmod handles -b cleanly.
depmod -a -b "$CHROOT" "$KVER" || true
# Stage WLAN firmware loader + module autoload (same files the
# debian/fedora paths get from /kernel-debs/staging via .deb).
for src in usr/local/sbin etc/modules-load.d etc/systemd/system; do
[ -d "$KSTAGE/$src" ] || continue
mkdir -p "$CHROOT/$src"
cp -an "$KSTAGE/$src/." "$CHROOT/$src/" || true
done
echo "=== Batocera: PS5 modprobe quirks ==="
mkdir -p "$CHROOT/etc/modprobe.d" "$CHROOT/etc/modules-load.d"
cat > "$CHROOT/etc/modprobe.d/ps5-amdgpu.conf" <<MODPROBE
options amdgpu dpm=0 gpu_recovery=0
MODPROBE
# uinput is needed by Batocera's hotkeygen (for virtual keyboard
# events when launching games). It's not autoloaded by default on
# PS5, so hotkeygen crashes with 'UInputError: /dev/uinput does
# not exist'. Force-load on boot.
cat > "$CHROOT/etc/modules-load.d/uinput.conf" <<MODPROBE
uinput
MODPROBE
echo "=== Batocera: build initrd via host mkinitramfs ==="
# Host (image-builder, ubuntu:24.04) has initramfs-tools. Trick
# it into building for our PS5 kernel by symlinking the chroot's
# modules into /lib/modules/$KVER, then unlinking after.
#
# initramfs-tools default behaviour: autodetect kernel modules
# from /sys on the BUILD HOST — which is a docker container with
# no USB, no amdgpu, no real disks. The resulting initrd would
# ship without xhci_pci / usb_storage / ext4 / amdgpu drivers,
# and the PS5 hangs silently when the kernel tries to find the
# USB root partition. Override with an explicit modules list +
# MODULES=most so initramfs-tools includes everything the PS5
# actually needs at boot.
mkdir -p /lib/modules
ln -sfn "$CHROOT/lib/modules/$KVER" "/lib/modules/$KVER"
cat > /etc/initramfs-tools/modules <<'INITMODS'
# USB host controllers (PS5 boot drive is on USB 3 — xhci is the must-have).
xhci_pci
xhci_hcd
ehci_pci
ehci_hcd
ohci_pci
ohci_hcd
# USB storage class + UAS (faster path).
usb_storage
uas
sd_mod
# Filesystems for root + EFI.
ext4
vfat
nls_iso8859-1
nls_cp437
# Common HID so a USB keyboard works at the initramfs shell if we drop there.
usbhid
hid_generic
INITMODS
# Force MODULES=most (curated full driver set, no autodetect).
sed -i 's/^MODULES=.*/MODULES=most/' /etc/initramfs-tools/initramfs.conf
mkinitramfs -k "$KVER" -o "$CHROOT/boot/efi/initrd.img"
rm -f "/lib/modules/$KVER"
rm -rf "$KSTAGE"
echo "=== Batocera: patch configgen to bind HOTKEY combos on gamepad ==="
# Upstream Batocera's libretroControllers.py only sets
# input_enable_hotkey_btn — the hotkey "enable" button — and never
# binds input_exit_emulator_btn / input_menu_toggle_btn /
# input_save_state_btn / input_load_state_btn. The keyboard-side
# bindings (escape = exit, f1 = menu) work fine but on a DualSense
# there's no way out of a game without sshing in and pkill'ing
# retroarch. Patch the function to also bind start/select/L1/R1.
PYFILE="$CHROOT/usr/lib/python3.12/site-packages/configgen/generators/libretro/libretroControllers.py"
if [ -f "$PYFILE" ]; then
python3 - "$PYFILE" <<'PYPATCH'
import sys
p = sys.argv[1]
src = open(p).read()
old = " retroconfig.save('input_enable_hotkey_btn', controllers[0].inputs['hotkey'].id)"
extra = '''
# PS5: map HOTKEY combos to gamepad — upstream sets only the
# enable button, leaving exit-emulator unbound on gamepad. Without
# this, gamepad users can't exit a retroarch game without sshing
# in and pkill'ing retroarch.
for batocera_key, retroarch_key in [
('start', 'input_exit_emulator_btn'),
('select', 'input_menu_toggle_btn'),
('pageup', 'input_load_state_btn'),
('pagedown', 'input_save_state_btn'),
]:
if batocera_key in controllers[0].inputs:
retroconfig.save(retroarch_key, controllers[0].inputs[batocera_key].id)'''
if old in src and extra not in src:
open(p, 'w').write(src.replace(old, old + extra))
print(' patched libretroControllers.py')
else:
print(' skipped (line not found or already patched)')
PYPATCH
else
echo " WARN: $PYFILE missing — configgen patch skipped"
fi
echo "=== Batocera: fstab + users ==="
# NOTE the FAT32 boot partition is mounted at /boot (not
# /boot/efi like the other distros) because batocera-part —
# which S11share uses to autodetect the SHARE partition by
# 'partition next to /boot' — greps /proc/mounts for /boot.
# If we mount at /boot/efi the SHARE auto-detection silently
# fails and S11share falls back to a 256 MB tmpfs at
# /userdata, which won't fit Steam / save data / anything.
# PS5 loader reads bzImage / cmdline.txt from the FAT32
# partition's root regardless of where Linux mounts it.
mkdir -p "$CHROOT/boot"
cat > "$CHROOT/etc/fstab" <<FSTAB
LABEL=$ROOT_LABEL / ext4 defaults 0 1
LABEL=$EFI_LABEL /boot vfat defaults 0 1
LABEL=SHARE /userdata ext4 defaults 0 2
FSTAB
echo "=== Batocera: first-boot SHARE partition creator ==="
# Batocera's design splits the disk into:
# sda1 = rootfs (this image, ~15 GB)
# sda2 = /boot FAT32
# sda3 = /userdata SHARE (everything user-facing — games,
# BIOS, Steam flatpak, screenshots, saves)
# The image only ships sda1+sda2. On first boot, expand the
# GPT backup header to the actual disk end (so parted/sgdisk
# see the full free space) then carve sda3 = SHARE out of
# the remainder. Self-disables after running.
cat > "$CHROOT/usr/local/sbin/ps5-share-init" <<'PS5SHARE'
#!/bin/sh
# First-boot: create the SHARE partition + fs if missing, so /userdata
# is a real disk-backed mount (916 GB on a 1 TB drive) instead of the
# 256 MB tmpfs fallback in /etc/init.d/S11share.
set -e
ROOT_DEV=$(findmnt -no SOURCE /)
DISK=$(/usr/bin/batocera-part prefix "$ROOT_DEV")
SHARE_NUM=$(/usr/bin/batocera-part share_internal_num)
SHARE_DEV="${DISK}${SHARE_NUM}"
[ -b "$DISK" ] || exit 0
# already created on a previous boot?
if [ -b "$SHARE_DEV" ] && blkid -L SHARE >/dev/null 2>&1; then
exit 0
fi
echo "ps5-share-init: extending GPT + creating $SHARE_DEV"
sgdisk -e "$DISK"
partprobe "$DISK"
sleep 1
sgdisk -n "$SHARE_NUM":0:0 -c "$SHARE_NUM":share -t "$SHARE_NUM":8300 "$DISK"
partprobe "$DISK"
sleep 1
mkfs.ext4 -L SHARE -F "$SHARE_DEV"
PS5SHARE
chmod +x "$CHROOT/usr/local/sbin/ps5-share-init"
# Hook into Batocera's init order: run BEFORE S11share so
# S11share's batocera-part share_internal call finds the
# partition we just created.
cat > "$CHROOT/etc/init.d/S07ps5share" <<'INITSHARE'
#!/bin/sh
# First-boot SHARE partition creator — see /usr/local/sbin/ps5-share-init
case "$1" in
start|"") /usr/local/sbin/ps5-share-init >> /tmp/ps5-share-init.log 2>&1 ;;
stop|restart|reload|*) ;;
esac
INITSHARE
chmod +x "$CHROOT/etc/init.d/S07ps5share"
# First-boot defaults for /userdata/system/batocera.conf — set
# display.empty=1 so every system (PSP, PS1, PS2, PS3, PS4,
# Switch, etc) is visible in EmulationStation even before
# ROMs are loaded. S12 runs after S11share has populated
# /userdata. Idempotent: only sets a key if not already
# present, so the user remains free to flip it back.
cat > "$CHROOT/etc/init.d/S12ps5defaults" <<'INITDEF'
#!/bin/sh
case "$1" in
start|"")
CONF=/userdata/system/batocera.conf
[ -f "$CONF" ] || exit 0
grep -qE '^display\.empty=' "$CONF" || echo 'display.empty=1' >> "$CONF"
;;
esac
INITDEF
chmod +x "$CHROOT/etc/init.d/S12ps5defaults"
# Batocera ships root passwordless. Leave root usable (a lot of
# Batocera scripts assume root) but ALSO add a ps5 user so the
# release-page convention works.
if ! grep -q "^ps5:" "$CHROOT/etc/passwd"; then
echo "ps5:x:1000:1000:PS5:/home/ps5:/bin/sh" >> "$CHROOT/etc/passwd"
echo "ps5:!::0:99999:7:::" >> "$CHROOT/etc/shadow"
echo "ps5:x:1000:" >> "$CHROOT/etc/group"
mkdir -p "$CHROOT/home/ps5"
chroot "$CHROOT" /bin/sh -c "chown -R 1000:1000 /home/ps5" 2>/dev/null || true
fi
# Both root and ps5 get pw 'ps5' — Batocera's chpasswd is busybox.
chroot "$CHROOT" /bin/sh -c "printf 'ps5\nps5\n' | passwd ps5 2>/dev/null; printf 'ps5\nps5\n' | passwd root 2>/dev/null" || true
echo "=== Batocera: grow-rootfs first-boot service ==="
mkdir -p "$CHROOT/usr/local/sbin" "$CHROOT/etc/systemd/system"
cat > "$CHROOT/usr/local/sbin/grow-rootfs" <<'GROW'
#!/bin/sh
set -e
ROOT=$(findmnt -no SOURCE / || mount | awk '$3=="/"{print $1; exit}')
DISK=$(lsblk -no PKNAME "$ROOT" 2>/dev/null | head -1)
PARTNUM=$(echo "$ROOT" | grep -oE '[0-9]+$' || true)
[ -z "$DISK" ] || [ -z "$PARTNUM" ] && exit 0
growpart "/dev/$DISK" "$PARTNUM" || true
resize2fs "$ROOT" || true
GROW
chmod +x "$CHROOT/usr/local/sbin/grow-rootfs"
cat > "$CHROOT/etc/systemd/system/grow-rootfs.service" <<SVC
[Unit]
Description=Grow rootfs to fill disk (first boot)
ConditionPathExists=/usr/local/sbin/grow-rootfs
ConditionFirstBoot=yes
After=local-fs.target
Before=basic.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/grow-rootfs
RemainAfterExit=yes
[Install]
WantedBy=sysinit.target
SVC
# Batocera switched to systemd in v33+. Try systemctl enable;
# tolerate buildroot quirks where /etc/systemd/system layout
# differs.
mkdir -p "$CHROOT/etc/systemd/system/sysinit.target.wants"
ln -sf ../grow-rootfs.service \
"$CHROOT/etc/systemd/system/sysinit.target.wants/grow-rootfs.service"

View File

@@ -0,0 +1 @@
../bazzite/build-rootfs.sh

View File

@@ -0,0 +1 @@
../bazzite/grow-rootfs

View File

@@ -0,0 +1 @@
../bazzite/grow-rootfs.service

View File

@@ -0,0 +1 @@
../bazzite/image.yaml

30
distros/bazzite/README.md Normal file
View File

@@ -0,0 +1,30 @@
# bazzite / bazzite-deck
Adds support for [Bazzite](https://bazzite.gg/) (uBlue's gaming-focused
atomic Fedora) and Bazzite-Deck (Steam Deck UI variant) on PS5 hardware.
These are **OCI atomic images** — distrobuilder doesn't apply.
`distros/bazzite/build-rootfs.sh` runs in place of the distrobuilder call:
1. `skopeo copy docker://ghcr.io/ublue-os/bazzite:stable` → OCI archive
2. `umoci unpack` → flat rootfs into `$CHROOT`
3. Promote `/usr/etc` defaults into `/etc`
4. Install the linux-ps5 RPM via `rpm-ostree`/`dnf`, then mask the
rpm-ostree services (we're a flat fs now)
5. Set up grow-rootfs systemd unit + DTM-TA-race amdgpu reprobe udev rule
`bazzite-deck` is built from `ghcr.io/ublue-os/bazzite-deck:stable` via the
same script (the `case "$DISTRO" in bazzite-*)` branch generates the OCI
reference automatically). All `distros/bazzite-deck/*` files are symlinks
into `distros/bazzite/`.
## Build locally
```bash
./build_image.sh --distro bazzite
./build_image.sh --distro bazzite-deck
```
Image size bumped to 24 GB (default). Compressed output is large (~3-5 GB
`.img.xz`) — too big for a 2 GB GitHub release asset, so this image is not
auto-published by the CI workflow.

401
distros/bazzite/build-rootfs.sh Executable file
View File

@@ -0,0 +1,401 @@
#!/bin/bash
# distros/bazzite/build-rootfs.sh — fetch the uBlue OCI image and prep $CHROOT.
# Called from docker/image-builder/entrypoint.sh for DISTRO=bazzite*.
#
# Bazzite is an OCI atomic image; we bypass distrobuilder entirely.
# DISTRO=bazzite -> ghcr.io/ublue-os/bazzite:stable
# DISTRO=bazzite-deck -> ghcr.io/ublue-os/bazzite-deck:stable
#
# Expects in env: DISTRO, CHROOT, KVER
# Expects on disk: /kernel-debs/*.rpm (linux-ps5 RPM), /repo/distros/bazzite/{grow-rootfs,grow-rootfs.service}
set -ex
: "${CHROOT:?ERROR: \$CHROOT unset/empty}"
[ -d "$CHROOT" ] || { echo "ERROR: \$CHROOT=$CHROOT not a directory"; exit 2; }
case "$CHROOT" in /) echo "ERROR: refuse to operate on /"; exit 2 ;; esac
# Bazzite is an OCI atomic image; bypass distrobuilder entirely.
# DISTRO=bazzite -> ghcr.io/ublue-os/bazzite:stable
# DISTRO=bazzite-deck -> ghcr.io/ublue-os/bazzite-deck:stable
# Anything else after `bazzite-` is treated as the same uBlue
# image-name pattern (bazzite-gnome, bazzite-nvidia, ...).
case "$DISTRO" in
bazzite) REF="ghcr.io/ublue-os/bazzite:stable" ;;
bazzite-*) REF="ghcr.io/ublue-os/${DISTRO}:stable" ;;
*) REF="ghcr.io/ublue-os/bazzite:stable" ;;
esac
echo "=== Bazzite: skopeo copy $REF ==="
OCI=$(mktemp -d)
skopeo copy --override-os linux --override-arch amd64 \
"docker://$REF" "oci:$OCI:bazzite"
echo "=== umoci unpack -> $CHROOT ==="
UNPACK=$(mktemp -d)
umoci unpack --keep-dirlinks --image "$OCI:bazzite" "$UNPACK"
# umoci layout: $UNPACK/{config.json, rootfs/}
mv "$UNPACK/rootfs"/* "$CHROOT/" 2>/dev/null || true
mv "$UNPACK/rootfs"/.[!.]* "$CHROOT/" 2>/dev/null || true
rm -rf "$UNPACK" "$OCI"
# ostree convention: /usr/etc holds the defaults; /etc is empty in
# the image. Promote /usr/etc to /etc so the system boots normally.
if [ -d "$CHROOT/usr/etc" ]; then
cp -an "$CHROOT/usr/etc/." "$CHROOT/etc/" || true
rm -rf "$CHROOT/usr/etc"
fi
# Stage PS5 kernel RPMs + grow-rootfs. /opt and /home are ostree
# symlinks in Bazzite, /var is a real dir — drop staging files there.
mkdir -p "$CHROOT/var/cache/ps5-rpms"
cp /kernel-debs/*.rpm "$CHROOT/var/cache/ps5-rpms/"
# /usr/local is a symlink to /var/usrlocal in ostree-based systems;
# mkdir the target before cp to avoid following-symlink-on-missing.
mkdir -p "$CHROOT/var/usrlocal/sbin"
cp /repo/distros/bazzite/grow-rootfs "$CHROOT/var/usrlocal/sbin/grow-rootfs"
chmod +x "$CHROOT/var/usrlocal/sbin/grow-rootfs"
cp /repo/distros/bazzite/grow-rootfs.service "$CHROOT/etc/systemd/system/grow-rootfs.service"
# Force NetworkManager wifi backend off iwd back to wpa_supplicant. Bazzite
# defaults to iwd; iwd is incompatible with the PS5 NXP IW620 mwifiex driver
# (no SSIDs scanned, NM hangs). wpa_supplicant works out of the box.
mkdir -p "$CHROOT/etc/NetworkManager/conf.d"
cat > "$CHROOT/etc/NetworkManager/conf.d/00-no-iwd.conf" <<'NMCONF'
[device]
wifi.backend=wpa_supplicant
NMCONF
# Chroot in: disable ostree stack, install PS5 kernel, user setup.
# Trap to always umount, even if the chroot script exits early.
cleanup_bazzite_mounts() {
for m in dev sys proc; do
mountpoint -q "$CHROOT/$m" && umount "$CHROOT/$m" || true
done
}
trap cleanup_bazzite_mounts RETURN ERR EXIT
mount --bind /proc "$CHROOT/proc"
mount --bind /sys "$CHROOT/sys"
mount --bind /dev "$CHROOT/dev"
# Bazzite has no /etc/resolv.conf inside the chroot (symlink target
# doesn't exist yet) — provide a working one so dnf can reach mirrors.
rm -f "$CHROOT/etc/resolv.conf"
cp /etc/resolv.conf "$CHROOT/etc/resolv.conf"
chroot "$CHROOT" /bin/bash -e <<"BAZIN"
# Disable rpm-ostree services — we're a flat fs now.
systemctl mask rpm-ostreed.service rpm-ostree-countme.service rpm-ostree-bootstatus.service 2>/dev/null || true
# Bazzite ships /usr/bin/dnf as a shell wrapper that refuses `install` /
# `remove` unless it detects a container or a dev-mode ostree deployment
# (points users at rpm-ostree instead). Neither condition holds on our
# flat-fs kexec-booted install, so `dnf install` prints a docs URL and
# exits 1. Replace the wrapper with a direct dnf5 symlink — with ostree
# gone the guard has no purpose here.
if [ -f /usr/bin/dnf ] && head -1 /usr/bin/dnf | grep -q "^#!.*bash"; then
rm -f /usr/bin/dnf
ln -s dnf5 /usr/bin/dnf
fi
# Drop the embedded ostree object store + deploy tree. With
# rpm-ostree masked, the running rootfs is the flat OCI
# extract — /sysroot/ostree/repo/objects/ is a deduplicated
# second copy of the same content (~5GB+), and /ostree/
# deploy/ holds yet another. Wiping them shrinks the disk
# image roughly in half. Leave the dir skeleton in case
# anything probes for it.
rm -rf /sysroot/ostree/repo/objects \
/sysroot/ostree/repo/refs \
/sysroot/ostree/deploy
mkdir -p /sysroot/ostree/repo/objects \
/sysroot/ostree/repo/refs/heads
# Bazzite/rpm-ostree convention: /root is a symlink to
# /var/roothome which doesn't exist in the OCI extract.
# dracut's hostonly enumeration follows the symlink, hits
# ENOENT, fails with `dracut-install: ERROR: installing '/root'`.
# Make /root a real dir so dracut + the kernel postinst's own
# dracut call both work.
mkdir -p /var/roothome
if [ -L /root ]; then
rm -f /root
mkdir -m 0700 /root
fi
# Install PS5 kernel via rpm --replacefiles (handles the file-
# level conflict between our /usr/include/* headers and
# Bazzite's kernel-headers; see fedora image.yaml comment).
# Bazzite ships kernel modules as a dir; our rpm wants a symlink.
rm -rf /lib/modules/*
rpm -Uvh --replacefiles --replacepkgs --nodeps /var/cache/ps5-rpms/*.rpm
rm -rf /var/cache/ps5-rpms
# cyan_skillfish (PS5 Oberon) GPU firmware MUST land in the
# rootfs uncompressed. The linux-ps5 amdgpu patches write into
# the request_firmware() buffer to skip Sony's signature header
# (gfx_v10_0_early_init + amdgpu_sdma_init_microcode). Firmware
# loaded from a .xz file is decompressed into pages the kernel
# maps PAGE_KERNEL_RO (fw_decompress_xz_pages -> fw_map_paged_buf
# -> vmap PAGE_KERNEL_RO), so the write oopses amdgpu at
# gfx_v10_0_early_init+0x415 and /dev/dri never appears.
# Distros that ship .zst (arch) or raw .bin (debian) decompress
# into writable buffers and are unaffected — this fix is for
# the rpm path only. linux-firmware dedupes blobs as symlinks
# (mec2 -> mec) and unxz refuses symlinks, so materialize the
# link targets first while the canonical .xz still exists.
# Upstream did this same fix in
# github.com/ps5-linux/ps5-linux-image@ed54e99 — same kernel
# patches, same firmware, same failure mode.
cd /usr/lib/firmware/amdgpu
for f in cyan_skillfish*.xz; do
if [ -L "$f" ]; then
tgt=$(readlink -f "$f")
rm "$f"
xz -dc "$tgt" > "${f%.xz}"
fi
done
unxz cyan_skillfish*.xz
cd /
# Pre-configure repo.etawen.dev so users can
# `dnf upgrade linux-ps5` after first boot. Per-package
# gpgcheck=0 (alien-converted RPMs aren't per-package
# signed); repodata IS signed by the mia PGP key.
cat > /etc/yum.repos.d/etawen-ps5.repo <<ETAWEN
[etawen-ps5]
name=Etawen PS5 kernel repo
baseurl=https://repo.etawen.dev/rpm/
enabled=1
gpgcheck=0
repo_gpgcheck=1
gpgkey=https://repo.etawen.dev/key.asc
ETAWEN
# amdgpu options — PS5 Oberon GPU needs dpm disabled or HDMI
# stays dark. Must land in initramfs (amdgpu loads early).
mkdir -p /etc/modprobe.d
cat > /etc/modprobe.d/ps5-amdgpu.conf <<AMDGPU
options amdgpu dpm=0 gpu_recovery=0
AMDGPU
# Build the initrd, then deploy bzImage+initrd to /boot/efi/
# for the PS5 kexec loader. (zz-update-boot is the deb-flow
# helper; bazzite never stages it, so we inline the copies.)
KVER=$(ls -1t /lib/modules | head -1)
dracut -f --kver "$KVER" "/boot/initrd.img-$KVER"
mkdir -p /boot/efi
cp "/boot/vmlinuz-$KVER" /boot/efi/bzImage
cp "/boot/initrd.img-$KVER" /boot/efi/initrd.img
# Suppress first-boot wizards. plasma-setup.service runs on every
# boot until /etc/plasma-setup-done exists, and its bootutil
# rewrites SDDM autologin to User=plasma-setup (clobbering our
# User=ps5) and starts the Plasma OOBE wizard, which prompts the
# user to create a fresh account. Our build pre-creates ps5; the
# wizard is unwanted.
touch /etc/plasma-setup-done
systemctl mask plasma-setup.service 2>/dev/null || true
# bazzite-hardware-setup.service runs on every boot until the
# marker files in /etc/bazzite/ match the image-info.json. Seed
# them so the script exits at its early-return; also mask it
# outright since the script calls `rpm-ostree kargs` which fails
# against our masked rpm-ostreed. The script's other work
# (zram, IOMMU karg, hw-specific kargs) isn't applicable on PS5
# anyway — we set our own cmdline in /boot/efi/cmdline.txt.
mkdir -p /etc/bazzite
jq -r '."image-name"' < /usr/share/ublue-os/image-info.json > /etc/bazzite/image_name
jq -r '."image-branch"' < /usr/share/ublue-os/image-info.json > /etc/bazzite/image_branch
jq -r '."fedora-version"' < /usr/share/ublue-os/image-info.json > /etc/bazzite/fedora_version
grep -oP '^HWS_VER=\K[0-9]+' /usr/libexec/bazzite-hardware-setup > /etc/bazzite/hws_version
systemctl mask bazzite-hardware-setup.service 2>/dev/null || true
# User setup. Bazzite exposes video/audio/input/render via
# systemd-userdbd, so `getent group video` returns a row —
# and `groupadd -f` short-circuits as "already exists" and
# does nothing. But useradd reads /etc/group directly (no
# NSS), sees an empty file, and bails with "group X does not
# exist". Materialize each group into /etc/group ourselves,
# preserving the NSS-assigned GID when there is one so
# existing file ownerships in the rootfs stay correct.
passwd -l root
# Ensure /etc/gshadow exists with the right perms; useradd
# refuses to "prepare new entry" silently if it's missing.
[ -e /etc/gshadow ] || { touch /etc/gshadow; chmod 0 /etc/gshadow; }
for g in wheel video audio input render; do
if ! grep -q "^${g}:" /etc/group; then
gid=$(getent group "$g" 2>/dev/null | cut -d: -f3 || true)
if [ -z "$gid" ]; then
# Pick the next free system gid (100-999).
gid=$(awk -F: 'BEGIN{m=100} $3>=100 && $3<1000 && $3>m {m=$3} END{print m+1}' /etc/group)
fi
echo "${g}:x:${gid}:" >> /etc/group
fi
# Always make sure /etc/gshadow has a row.
grep -q "^${g}:" /etc/gshadow || echo "${g}:!::" >> /etc/gshadow
done
if ! id ps5 >/dev/null 2>&1; then
useradd -m -s /bin/bash -G wheel,video,audio,input,render ps5
fi
echo "ps5:ps5" | chpasswd
sed -i 's/^# %wheel ALL=(ALL:ALL) ALL/%wheel ALL=(ALL:ALL) ALL/' /etc/sudoers || true
# Install pieces Bazzite's slim OCI image is missing.
# cloud-utils-growpart + parted: grow-rootfs needs growpart
# and partprobe — without them the rootfs stays sized to
# the build image (~10GB) on whatever USB it lands on.
# plasma-systemmonitor + ksystemstats: standard Plasma
# "System Monitor" app. Bazzite's container drops it.
# kdiff3 / gwenview / ark / okular / spectacle: rest of
# the Plasma utilities most people expect.
# chrony: NTP. PS5's RTC is wrong on boot; without an NTP
# client the system clock is years off and TLS breaks.
dnf install -y --setopt=install_weak_deps=False \
cloud-utils-growpart parted \
plasma-systemmonitor ksystemstats \
kdiff3 gwenview ark okular spectacle \
chrony \
|| echo "WARN: dnf install failed; some pkgs may be missing"
# Services
systemctl enable grow-rootfs.service NetworkManager sshd 2>/dev/null || true
# Time sync. Prefer systemd-timesyncd if present (lighter);
# fall back to chrony (which we just dnf-installed).
systemctl enable systemd-timesyncd 2>/dev/null \
|| systemctl enable chronyd 2>/dev/null || true
# Virtual terminals. Bazzite's preset disables getty@tty2-6;
# explicitly enable them so Ctrl+Alt+F2..F6 give text consoles.
for n in 2 3 4 5 6; do
systemctl enable getty@tty${n}.service 2>/dev/null || true
done
# Default DM (Bazzite ships KDE Plasma + SDDM)
systemctl enable sddm 2>/dev/null || systemctl enable gdm 2>/dev/null || true
# resolv.conf -> systemd-resolved stub
rm -f /etc/resolv.conf
ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
# Steam Deck UI's "Switch to Desktop" button calls SteamOS-
# Manager's SetTemporarySession(s) dbus method, which writes
# Session=<bare-alias> (literally "desktop"/"gamescope") into
# /etc/sddm.conf.d/zzt-steamos-temp-login.conf. That conf
# sorts AFTER zz-steamos-autologin.conf so it wins precedence
# at autologin time — but SDDM has no `desktop.desktop`
# session to resolve the alias to, so the button silently
# no-ops and the user stays on gamescope. (The bash
# steamos-session-select tool works fine because it resolves
# aliases itself before writing — only the dbus path is
# broken.) Fix it with alias symlinks SDDM can follow.
for cand in plasma-steamos-wayland-oneshot.desktop \
gnome-wayland-oneshot.desktop plasma.desktop; do
if [ -e "/usr/share/wayland-sessions/$cand" ]; then
ln -sf "$cand" /usr/share/wayland-sessions/desktop.desktop
break
fi
done
for cand in gamescope-session.desktop gamescope-session-plus.desktop; do
if [ -e "/usr/share/wayland-sessions/$cand" ]; then
ln -sf "$cand" /usr/share/wayland-sessions/gamescope.desktop
break
fi
done
# Autologin straight into Bazzite's gamescope session (Steam
# Big-Picture / Deck UI) — Bazzite is gaming-focused, and a
# field report said it landed on the Plasma desktop instead
# of gamemode. Pick whichever gamescope session file exists,
# fall back to plasma if Bazzite stripped them.
mkdir -p /etc/sddm.conf.d
SESSION=plasma
for s in gamescope-session-plus.desktop gamescope-session.desktop steam-wayland.desktop; do
if [ -e "/usr/share/wayland-sessions/$s" ] || [ -e "/usr/share/xsessions/$s" ]; then
SESSION="${s%.desktop}"
break
fi
done
cat > /etc/sddm.conf.d/autologin.conf <<SDDM
[Autologin]
User=ps5
Session=$SESSION
SDDM
# Gamescope-session fallback. Field report: bazzite-deck boots
# to a black screen on PS5 because gamescope can't grab the
# display (PSP/TA + Salina HDMI bridge weirdness — workaround
# is `steamos-session-select plasma` from a VT). Automate it:
# first-boot oneshot waits 60s for a gamescope process; if
# nothing shows up, flip the session to plasma and bounce
# SDDM. Only arm this when the chosen session is gamescope-
# flavoured. After first boot the user owns session choice
# via the standard steamos-session-select tool + the desktop
# shortcut we drop below.
case "$SESSION" in gamescope*|steam-wayland*)
mkdir -p /usr/local/sbin /etc/systemd/system/graphical.target.wants
cat > /usr/local/sbin/ps5-gamescope-recovery <<'POKE'
#!/bin/bash
# Wait up to 60s for gamescope to actually grab a display. If it doesn't,
# the user is staring at a black screen — fall back to plasma and bounce
# the display manager so they get a usable login session.
for _ in $(seq 1 60); do
sleep 1
pgrep -x gamescope >/dev/null 2>&1 && exit 0
done
logger -t ps5-gamescope-recovery "gamescope didn't start within 60s, switching to plasma"
runuser -u ps5 -- steamos-session-select plasma 2>/dev/null \
|| sed -i 's/^Session=.*/Session=plasma/' /etc/sddm.conf.d/autologin.conf
systemctl restart sddm
POKE
chmod +x /usr/local/sbin/ps5-gamescope-recovery
cat > /etc/systemd/system/ps5-gamescope-recovery.service <<RECOV
[Unit]
Description=Fall back to plasma if gamescope can't grab a display (first boot)
After=graphical.target
ConditionFirstBoot=yes
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/ps5-gamescope-recovery
RemainAfterExit=no
[Install]
WantedBy=graphical.target
RECOV
ln -sf ../ps5-gamescope-recovery.service \
/etc/systemd/system/graphical.target.wants/ps5-gamescope-recovery.service
# Desktop shortcut so the user can opt back into gamescope
# after a recovery (or after switching to plasma manually).
mkdir -p /home/ps5/Desktop
cat > /home/ps5/Desktop/Switch-to-Gamescope.desktop <<DESK
[Desktop Entry]
Version=1.0
Type=Application
Name=Switch to Gamescope (Big Picture)
Comment=Switch the autologin session back to gamescope / Steam Deck UI
Exec=bash -c 'steamos-session-select gamescope && systemctl restart sddm'
Icon=steam
Terminal=false
Categories=System;
DESK
chmod +x /home/ps5/Desktop/Switch-to-Gamescope.desktop
chown -R ps5:ps5 /home/ps5/Desktop 2>/dev/null || \
chown -R 1000:1000 /home/ps5/Desktop
;;
esac
# DTM TA race workaround. amdgpu's display-topology TA
# (Trusted Application) loads async via PSP; if DRM probes
# connectors before that finishes, you get
# [drm] Failed to add display topology, DTM TA is not initialized
# and the screen stays dark until the user manually toggles
# VT (ctrl+alt+F7 -> ctrl+alt+F1) which forces a re-probe.
# Mimic that automatically: after amdgpu binds, wait a beat
# then re-trigger DRM connector detection.
mkdir -p /usr/local/sbin /etc/udev/rules.d
cat > /usr/local/sbin/ps5-amdgpu-reprobe <<'POKE'
#!/bin/sh
# Wait for PSP/TA firmware to settle, then re-probe DRM connectors.
# Equivalent of the ctrl+alt+F7 / ctrl+alt+F1 dance.
(
sleep 3
for c in /sys/class/drm/card*-*/status; do
[ -w "$c" ] && echo detect > "$c"
done
) &
POKE
chmod +x /usr/local/sbin/ps5-amdgpu-reprobe
cat > /etc/udev/rules.d/70-ps5-amdgpu-reprobe.rules <<'UDEV'
# Re-trigger DRM hotplug after amdgpu binds, so the DTM TA-not-initialized
# race doesn't leave the user with a dark screen until they manually VT-cycle.
SUBSYSTEM=="drm", ACTION=="add", KERNEL=="card[0-9]*", RUN+="/usr/local/sbin/ps5-amdgpu-reprobe"
UDEV
BAZIN
# explicit cleanup (the trap covers the failure path)
cleanup_bazzite_mounts
trap - RETURN ERR EXIT

18
distros/bazzite/grow-rootfs Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
# Grows the root partition and filesystem to fill the disk.
# Runs once on first boot, then disables itself.
ROOT_DEV=$(findmnt -no SOURCE /) || { echo "Cannot find root device"; exit 1; }
DISK=$(lsblk -ndo PKNAME "$ROOT_DEV")
PART_NUM=$(cat /sys/class/block/$(basename "$ROOT_DEV")/partition 2>/dev/null)
if [ -z "$DISK" ] || [ -z "$PART_NUM" ]; then
echo "Cannot determine disk layout (DISK=$DISK PART_NUM=$PART_NUM)"
exit 1
fi
growpart "/dev/$DISK" "$PART_NUM" || true
partprobe "/dev/$DISK"
resize2fs "$ROOT_DEV"
systemctl disable grow-rootfs.service

View File

@@ -0,0 +1,14 @@
[Unit]
Description=Grow root filesystem to fill disk
After=systemd-remount-fs.service
# grow-rootfs uses findmnt + growpart on the live /, which needs the
# rootfs mounted RW and userspace tooling available. Drop the
# initramfs-era ordering the previous version used.
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/grow-rootfs
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,13 @@
# Bazzite is an ostree/OCI atomic image — distrobuilder doesn't handle it.
# This yaml is a placeholder for documentation; the actual build is custom
# in docker/image-builder/entrypoint.sh under the `bazzite*)` case (skopeo
# pull + umoci unpack + dnf-install our PS5 kernel RPM).
image:
name: ps5-bazzite
distribution: bazzite
release: stable
description: Bazzite (uBlue gaming Fedora) with PS5 kernel — ostree-flattened
architecture: x86_64
# Source ref consumed by entrypoint.sh:
# upstream_ref: ghcr.io/ublue-os/bazzite:stable

View File

@@ -19,10 +19,17 @@
export LIBSEAT_BACKEND=logind
export WLR_LIBINPUT_NO_DEVICES=1
# Launch flags:
# -gamepadui : controller-friendly Steam UI (Big Picture-like)
# NOT -steamos3 : would enable SteamOS atomic-OS-update polling. CachyOS is
# pacman-rolling, no atomupd manifest server, so Steam loops
# "Updater apply error: 2: null" and the OOBE blocks on the
# "Software updates" step. (Field-reported by users.) Use
# `sudo pacman -Syu` for OS updates.
exec /usr/bin/gamescope \
--backend drm \
-e \
--prefer-output DP-1 \
--sdr-gamut-wideness 0 \
-- \
/usr/bin/steam -gamepadui -steamos3
/usr/bin/steam -gamepadui

23
distros/cachyos/files/steamos-session-select Normal file → Executable file
View File

@@ -65,14 +65,27 @@ sudo "$(realpath "$0")" "$session"
case "$session" in
gamescope)
# Plasma's compositor + shell run under systemd --user (session 2),
# not the tty1 login session — terminate-session alone leaves them
# holding DRM and gamescope can't grab the display. Kill them first,
# then close the tty1 session so profile.d exec's gamescope-session-ps5.
pkill -u steam plasmashell || true
pkill -u steam kwin_wayland || true
pkill -u steam kwin_x11 || true
pkill -u steam Xwayland || true
if [[ -n "${XDG_SESSION_ID:-}" ]]; then
loginctl terminate-session "$XDG_SESSION_ID"
else
pkill -u steam kwin_wayland || true
pkill -u steam kwin_x11 || true
fi
;;
desktop|plasma|plasma-wayland|plasmax11)
exec sudo systemctl reboot
# Soft handoff: terminate the current session; agetty respawns
# tty1 and profile.d reads next-session=plasma. Avoids the reboot
# tax on return-to-desktop.
if [[ -n "${XDG_SESSION_ID:-}" ]]; then
loginctl terminate-session "$XDG_SESSION_ID"
else
pkill -u steam gamescope-session-ps5 || true
pkill -u steam gamescope || true
fi
;;
esac
esac

View File

@@ -43,6 +43,10 @@ packages:
- konsole
- dolphin
# Bluetooth
- bluez
- bluez-utils
# Audio / media
- pipewire
- wireplumber
@@ -186,8 +190,12 @@ actions:
# Enable #[multilib] / #Include block (\\\\ in YAML would break the sed address regex)
sed -i '/^#\[multilib\]/,/^#Include/s/^#//' /etc/pacman.conf
curl -fsSL "https://archlinux.org/mirrorlist/?country=all&protocol=https&use_mirror_status=on" \
| sed 's/^#Server/Server/' > /etc/pacman.d/mirrorlist
# Keep CI on a small stable mirror set. The full active global list can
# select slow mirrors and fail pacman's low-speed download timeout.
cat > /etc/pacman.d/mirrorlist <<'EOF'
Server = https://geo.mirror.pkgbuild.com/$repo/os/$arch
Server = https://mirrors.kernel.org/archlinux/$repo/os/$arch
EOF
pacman -Syy --noconfirm
@@ -226,6 +234,8 @@ actions:
ln -sf /etc/systemd/system/grow-rootfs.service \
/etc/systemd/system/local-fs.target.wants/grow-rootfs.service
systemctl enable bluetooth.service
# Stub out steamos-update so Steam's -steamos3 OOBE skips the SteamOS OTA
# step cleanly. Exit 7 = "no update available" per Valve's convention.
printf '#!/bin/bash\n# Stub: no SteamOS OTA on this image\nexit 7\n' \

View File

@@ -0,0 +1 @@
root=LABEL=__DISTRO__ rw rootwait console=ttyTitania0 console=tty0 mitigations=off idle=halt preempt=full

View File

@@ -0,0 +1,19 @@
#!/bin/bash
# Grows the root partition and filesystem to fill the disk.
# Runs once on first boot, then disables itself.
# If resize fails, the service stays enabled and retries next boot.
ROOT_DEV=$(findmnt -no SOURCE /) || { echo "Cannot find root device"; exit 1; }
DISK=$(lsblk -ndo PKNAME "$ROOT_DEV")
PART_NUM=$(cat /sys/class/block/$(basename "$ROOT_DEV")/partition 2>/dev/null)
if [ -z "$DISK" ] || [ -z "$PART_NUM" ]; then
echo "Cannot determine disk layout (DISK=$DISK PART_NUM=$PART_NUM)"
exit 1
fi
growpart "/dev/$DISK" "$PART_NUM" || true
partprobe "/dev/$DISK"
resize2fs "$ROOT_DEV"
systemctl disable grow-rootfs.service

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Grow root filesystem to fill disk
After=local-fs.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/grow-rootfs
[Install]
WantedBy=multi-user.target

252
distros/debian/image.yaml Normal file
View File

@@ -0,0 +1,252 @@
image:
name: ps5-debian
distribution: debian
release: bookworm
description: Debian 12 XFCE desktop for PS5
architecture: x86_64
source:
downloader: debootstrap
url: http://deb.debian.org/debian
variant: minbase
keyserver: keyserver.ubuntu.com
packages:
manager: apt
update: true
cleanup: true
repositories:
- name: sources.list
url: |-
deb http://deb.debian.org/debian bookworm main contrib non-free non-free-firmware
deb http://deb.debian.org/debian bookworm-updates main contrib non-free non-free-firmware
deb http://security.debian.org/debian-security bookworm-security main contrib non-free non-free-firmware
architectures:
- amd64
sets:
- packages:
# XFCE desktop (proven on PS5 via the Kali image)
- task-xfce-desktop
- lightdm
- lightdm-gtk-greeter
- xserver-xorg
- xserver-xorg-video-amdgpu
- dbus-x11
- x11-xserver-utils
# Bluetooth
- bluetooth
- bluez
- bluez-tools
# Audio / media
- pipewire
- pipewire-pulse
- wireplumber
- pavucontrol
# GPU
- mesa-vulkan-drivers
- mesa-va-drivers
# Networking
- network-manager
- network-manager-gnome
- openssh-server
- iputils-ping
- iw
- wpasupplicant
- wireless-regdb
- rfkill
# Firmware + PS5 boot support
- firmware-linux
- firmware-linux-nonfree
- firmware-amd-graphics
- firmware-realtek
- firmware-atheros
- firmware-misc-nonfree
- initramfs-tools
- kexec-tools
- kmod
- busybox
- zstd
- systemd-zram-generator
# Apps
- firefox-esr
- thunar
- mousepad
- ristretto
- xfce4-terminal
- xfce4-screenshooter
- file-roller
- synaptic
- gnome-disk-utility
- htop
# Base utilities
- sudo
- cloud-guest-utils
- parted
- e2fsprogs
- usbutils
- pciutils
- util-linux
- curl
- wget
- git
- ca-certificates
- vim
- nano
- tmux
- ethtool
# Build tools (for WiFi module — prebuilt, but keep headers usable)
- build-essential
- bc
- bison
- flex
- libssl-dev
- libelf-dev
# Fonts
- fonts-dejavu
- fonts-liberation2
- fonts-noto
- fontconfig
action: install
files:
- path: /etc/hostname
generator: hostname
- path: /etc/hosts
generator: hosts
- path: /etc/machine-id
generator: dump
- path: /etc/kernel/postinst.d/zz-update-boot
generator: copy
source: /tmp/build-staging/zz-update-boot
mode: "0755"
- path: /etc/fstab
generator: copy
source: /tmp/build-staging/fstab
- path: /usr/local/sbin/grow-rootfs
generator: copy
source: /tmp/build-staging/grow-rootfs
mode: "0755"
- path: /etc/systemd/system/grow-rootfs.service
generator: copy
source: /tmp/build-staging/grow-rootfs.service
- path: /opt/debs/
generator: copy
source: /tmp/build-staging/debs
actions:
- trigger: post-unpack
action: |-
#!/bin/bash
set -eux
echo 'Acquire::Retries "10";' > /etc/apt/apt.conf.d/80retry
apt-get update
- trigger: post-update
action: |-
#!/bin/bash
set -eux
passwd -l root
- trigger: post-packages
action: |-
#!/bin/bash
set -eux
export DEBIAN_FRONTEND=noninteractive
# Default user (ps5/ps5)
useradd -m -G sudo,video,input,render -s /bin/bash ps5
echo "ps5:ps5" | chpasswd
printf 'ps5\n' > /etc/hostname
if grep -q '^127\.0\.1\.1' /etc/hosts; then
sed -i 's/^127\.0\.1\.1.*/127.0.1.1 ps5/' /etc/hosts
else
printf '127.0.1.1 ps5\n' >> /etc/hosts
fi
# LightDM autologin into XFCE
mkdir -p /etc/lightdm/lightdm.conf.d
cat > /etc/lightdm/lightdm.conf.d/50-ps5-autologin.conf <<'EOF'
[Seat:*]
autologin-user=ps5
autologin-user-timeout=0
user-session=xfce
EOF
# SSH off by default (public default credentials)
sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
systemctl disable ssh.service || true
systemctl disable ssh.socket || true
systemctl set-default graphical.target
systemctl enable NetworkManager
systemctl enable lightdm
systemctl enable bluetooth.service
ln -sf /usr/share/zoneinfo/Etc/UTC /etc/localtime
printf 'Etc/UTC\n' > /etc/timezone
# zram swap
printf '[zram0]\nzram-size = ram / 2\ncompression-algorithm = zstd\n' > /etc/systemd/zram-generator.conf
# Disable screen blanking for demos
install -d -m 0755 /home/ps5/.config/autostart
cat > /home/ps5/.config/autostart/ps5-display.desktop <<'EOF'
[Desktop Entry]
Type=Application
Name=PS5 Display Defaults
Exec=sh -c 'xset s off -dpms; xset s noblank'
X-GNOME-Autostart-enabled=true
EOF
chown -R ps5:ps5 /home/ps5
- trigger: post-files
action: |-
#!/bin/bash
set -eux
systemctl enable grow-rootfs.service
mkdir -p /boot/efi
mkdir -p /etc/initramfs-tools/conf.d
printf 'RESUME=none\n' > /etc/initramfs-tools/conf.d/resume
# Install the PS5-patched kernel
dpkg -i /opt/debs/*.deb
rm -rf /opt/debs
apt-mark hold linux-ps5 || true
KVER=$(ls -1t /lib/modules | head -1)
depmod -a "$KVER"
# NOTE: PS5 WiFi (IW620) is not built here. The linux-ps5 headers ship
# host tools prebuilt on Ubuntu (needs glibc 2.38+) which Debian 12's
# glibc 2.36 can't run. WiFi modules will be added as prebuilt artifacts
# in a future update. Use ethernet or a USB WiFi dongle for now.
# Front-load amdgpu for PS5 display
grep -qxF amdgpu /etc/initramfs-tools/modules || printf '\namdgpu\n' >> /etc/initramfs-tools/modules
printf 'MODULES=most\nBUSYBOX=y\n' > /etc/initramfs-tools/conf.d/ps5-amdgpu
update-initramfs -c -k "$KVER"
/etc/kernel/postinst.d/zz-update-boot "$KVER"
mappings:
architecture_map: debian

View File

@@ -0,0 +1 @@
root=LABEL=__DISTRO__ rw rootwait console=ttyTitania0 console=tty0 video=DP-1:1920x1080@60 mitigations=off idle=halt preempt=full selinux=0

View File

@@ -0,0 +1,19 @@
#!/bin/bash
# Grows the root partition and filesystem to fill the disk.
# Runs once on first boot, then disables itself.
# If resize fails, the service stays enabled and retries next boot.
ROOT_DEV=$(findmnt -no SOURCE /) || { echo "Cannot find root device"; exit 1; }
DISK=$(lsblk -ndo PKNAME "$ROOT_DEV")
PART_NUM=$(cat /sys/class/block/$(basename "$ROOT_DEV")/partition 2>/dev/null)
if [ -z "$DISK" ] || [ -z "$PART_NUM" ]; then
echo "Cannot determine disk layout (DISK=$DISK PART_NUM=$PART_NUM)"
exit 1
fi
growpart "/dev/$DISK" "$PART_NUM" || true
partprobe "/dev/$DISK"
resize2fs "$ROOT_DEV"
systemctl disable grow-rootfs.service

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Grow root filesystem to fill disk
After=local-fs.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/grow-rootfs
[Install]
WantedBy=multi-user.target

280
distros/fedora/image.yaml Normal file
View File

@@ -0,0 +1,280 @@
image:
name: ps5-fedora
distribution: fedora
release: "44"
description: Fedora GNOME (Wayland) desktop for PS5
architecture: x86_64
source:
downloader: fedora-http
url: https://kojipkgs.fedoraproject.org
skip_verification: true
packages:
manager: dnf
update: true
cleanup: true
sets:
- packages:
# Full GNOME desktop + GDM display manager.
- gnome-shell
- gnome-session
- gnome-terminal
- gdm
- mutter
- nautilus
- gnome-control-center
- gnome-settings-daemon
- gnome-keyring
- gnome-backgrounds
- gnome-text-editor
- gnome-system-monitor
- gnome-disk-utility
- gnome-calculator
- gnome-tweaks
- gnome-extensions-app
- gnome-software
- gnome-initial-setup
- file-roller
- loupe
- evince
- baobab
- xdg-desktop-portal-gnome
- xdg-user-dirs-gtk
- xorg-x11-server-Xwayland
- polkit
# Bluetooth
- bluez
- bluez-tools
# Audio / media
- pipewire
- wireplumber
- pipewire-pulseaudio
# Display / GPU (AMD) — userspace GL/Vulkan only; the kernel amdgpu driver
# comes from the linux-ps5 rpm. amdgpu is loaded LATE (by udev after pivot),
# exactly like the working Arch/Ubuntu images — NOT forced into the initramfs.
- mesa-dri-drivers
- mesa-vulkan-drivers
- mesa-va-drivers
- libinput
# Networking
- NetworkManager
- NetworkManager-wifi
- network-manager-applet
- linux-firmware
- iw
- wpa_supplicant
- wireless-regdb
- util-linux
# System
- glibc-langpack-en
- openssh-server
- sudo
- nano
- dracut
- kmod
- e2fsprogs
- parted
- cloud-utils-growpart
- kexec-tools
- zram-generator-defaults
- firefox
# Fonts
- dejavu-sans-fonts
- liberation-fonts
- google-noto-sans-fonts
- google-noto-emoji-fonts
- fontconfig
action: install
files:
- path: /etc/hostname
generator: hostname
- path: /etc/hosts
generator: hosts
- path: /etc/machine-id
generator: dump
- path: /etc/kernel/postinst.d/zz-update-boot
generator: copy
source: /tmp/build-staging/zz-update-boot
mode: "0755"
- path: /etc/fstab
generator: copy
source: /tmp/build-staging/fstab
- path: /usr/local/sbin/grow-rootfs
generator: copy
source: /tmp/build-staging/grow-rootfs
mode: "0755"
- path: /etc/systemd/system/grow-rootfs.service
generator: copy
source: /tmp/build-staging/grow-rootfs.service
- path: /opt/rpms/
generator: copy
source: /tmp/build-staging/rpms
actions:
- trigger: post-unpack
action: |-
#!/bin/bash
set -eux
echo "retries=10" >> /etc/dnf/dnf.conf
dnf makecache || true
- trigger: post-update
action: |-
#!/bin/bash
set -eux
passwd -l root
- trigger: post-packages
action: |-
#!/bin/bash
set -eux
fc-cache -f 2>/dev/null || true
# SSH — password auth only, root login via sudo
sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
systemctl enable sshd
systemctl enable NetworkManager
systemctl enable gdm
systemctl enable bluetooth.service
systemctl set-default graphical.target
# The PS5 kernel patch writes into the request_firmware() buffer to skip
# Sony's signature header (gfx_v10_0_early_init, amdgpu_sdma_init_microcode).
# Firmware loaded from a .xz file is decompressed into pages the kernel
# maps PAGE_KERNEL_RO, so that write oopses and /dev/dri never appears.
# Fedora is the only distro shipping .xz firmware — Ubuntu/Arch use .zst
# (writable vzalloc buffer) and Debian/Kali ship raw .bin, which is why
# only this image black-screened. Keep the PS5 GPU firmware uncompressed.
# linux-firmware dedupes identical blobs as symlinks (mec2 -> mec) and
# unxz refuses symlinks, so materialize links first while targets exist.
cd /usr/lib/firmware/amdgpu
for f in cyan_skillfish*.xz; do
if [ -L "$f" ]; then
tgt=$(readlink -f "$f")
rm "$f"
xz -dc "$tgt" > "${f%.xz}"
fi
done
unxz cyan_skillfish*.xz
ls -la cyan_skillfish*
- trigger: post-files
action: |-
#!/bin/bash
set -eux
systemctl enable grow-rootfs.service
# Built in a chroot → filesystem never SELinux-labelled. Disable SELinux.
mkdir -p /etc/selinux
if [ -f /etc/selinux/config ]; then
sed -i 's/^SELINUX=.*/SELINUX=disabled/' /etc/selinux/config
else
printf 'SELINUX=disabled\nSELINUXTYPE=targeted\n' > /etc/selinux/config
fi
# --- Default user + GDM autologin ---
useradd -m -G wheel,video,input,render -s /bin/bash ps5
echo "ps5:ps5" | chpasswd
echo "%wheel ALL=(ALL:ALL) NOPASSWD: ALL" > /etc/sudoers.d/wheel
# GDM autologin so the GNOME desktop appears without a login prompt.
mkdir -p /etc/gdm
sed -i 's/^\[daemon\]/[daemon]\nAutomaticLoginEnable=True\nAutomaticLogin=ps5/' /etc/gdm/custom.conf 2>/dev/null || \
cat > /etc/gdm/custom.conf <<'EOF'
[daemon]
AutomaticLoginEnable=True
AutomaticLogin=ps5
[security]
[xdmcp]
[chooser]
[debug]
EOF
# Skip gnome-initial-setup for the autologin user.
mkdir -p /home/ps5/.config
echo 'yes' > /home/ps5/.config/gnome-initial-setup-done
chown -R ps5:ps5 /home/ps5
# zram swap
printf '[zram0]\nzram-size = ram / 2\ncompression-algorithm = zstd\n' > /etc/systemd/zram-generator.conf
# --- Install custom kernel rpm (its %post builds initramfs + deploys boot) -
# amdgpu is NOT forced into the initramfs — it loads late via udev, exactly
# like the working Arch/Ubuntu images.
mkdir -p /boot/efi
rpm -ivh /opt/rpms/*.rpm
rm -rf /opt/rpms
KVER=$(ls -1t /lib/modules | head -1)
[ -f "/boot/efi/bzImage" ] && [ -f "/boot/efi/initrd.img" ]
# --- PS5 internal WiFi: firmware-stage + autoload service ---
# The mlan.ko/moal.ko modules and their modprobe.d options are already
# shipped by linux-ps5.rpm (built by kernel-builder/build.sh, which now
# applies all PS5 mwifiex patches in one place). The rpm ALSO ships
# /etc/modules-load.d/moal which would auto-load moal at boot before
# firmware has been copied from /boot/efi — kill that and let our
# ps5-iw620.service do the copy+load in the right order.
rm -f /etc/modules-load.d/moal
install -d /usr/local/sbin /etc/systemd/system /etc/modprobe.d /usr/share/doc/ps5-iw620
cat > /etc/modprobe.d/ps5-iw620-noautoload.conf <<'EOF'
blacklist moal
blacklist mlan
EOF
cat > /usr/local/sbin/ps5-iw620-load <<'EOF'
#!/bin/sh
set -eu
FW_NAME=nxp/pcieuartiw620_combo_v1.bin
FW_DST=/lib/firmware/$FW_NAME
FW_SRC=
for candidate in "/boot/efi/lib/$FW_NAME" "/boot/lib/$FW_NAME" "$FW_DST"; do
[ -f "$candidate" ] && { FW_SRC="$candidate"; break; }
done
[ -z "$FW_SRC" ] && FW_SRC="$(find /boot /boot/efi -path "*/$FW_NAME" -type f 2>/dev/null | head -n 1)"
[ -n "$FW_SRC" ] && [ -f "$FW_SRC" ] && install -D -m 0644 "$FW_SRC" "$FW_DST"
[ -f "$FW_DST" ] || { echo "PS5 IW620 firmware not found; skipping"; exit 0; }
modprobe -r moal mlan 2>/dev/null || true
modprobe cfg80211 || true
modprobe moal
rfkill unblock all 2>/dev/null || true
nmcli radio wifi on 2>/dev/null || true
EOF
chmod 0755 /usr/local/sbin/ps5-iw620-load
cat > /etc/systemd/system/ps5-iw620.service <<'EOF'
[Unit]
Description=Load PS5 IW620 internal WiFi
RequiresMountsFor=/boot/efi
Wants=systemd-udev-settle.service
After=local-fs.target systemd-udev-settle.service
Before=NetworkManager.service network-pre.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/ps5-iw620-load
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
depmod -a "$KVER"
systemctl enable ps5-iw620.service

View File

@@ -0,0 +1 @@
root=LABEL=__DISTRO__ rw rootwait console=ttyTitania0 console=tty0 mitigations=off idle=halt preempt=full

View File

@@ -0,0 +1,19 @@
#!/bin/bash
# Grows the root partition and filesystem to fill the disk.
# Runs once on first boot, then disables itself.
# If resize fails, the service stays enabled and retries next boot.
ROOT_DEV=$(findmnt -no SOURCE /) || { echo "Cannot find root device"; exit 1; }
DISK=$(lsblk -ndo PKNAME "$ROOT_DEV")
PART_NUM=$(cat /sys/class/block/$(basename "$ROOT_DEV")/partition 2>/dev/null)
if [ -z "$DISK" ] || [ -z "$PART_NUM" ]; then
echo "Cannot determine disk layout (DISK=$DISK PART_NUM=$PART_NUM)"
exit 1
fi
growpart "/dev/$DISK" "$PART_NUM" || true
partprobe "/dev/$DISK"
resize2fs "$ROOT_DEV"
systemctl disable grow-rootfs.service

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Grow root filesystem to fill disk
After=local-fs.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/grow-rootfs
[Install]
WantedBy=multi-user.target

319
distros/proxmox/image.yaml Normal file
View File

@@ -0,0 +1,319 @@
image:
name: ps5-proxmox
distribution: debian
release: bookworm
description: Proxmox VE 8 (headless hypervisor) for PS5
architecture: x86_64
source:
downloader: debootstrap
url: http://deb.debian.org/debian
variant: minbase
keyserver: keyserver.ubuntu.com
packages:
manager: apt
update: true
cleanup: true
repositories:
- name: sources.list
url: |-
deb http://deb.debian.org/debian bookworm main contrib non-free non-free-firmware
deb http://deb.debian.org/debian bookworm-updates main contrib non-free non-free-firmware
deb http://security.debian.org/debian-security bookworm-security main contrib non-free non-free-firmware
architectures:
- amd64
- name: pve-no-subscription.list
url: |-
deb [arch=amd64] http://download.proxmox.com/debian/pve bookworm pve-no-subscription
architectures:
- amd64
sets:
# NOTE: proxmox-ve itself is NOT installed here. It hard-depends on a Proxmox
# kernel that cannot boot the PS5, so a post-packages action first installs an
# equivs stub that satisfies that dependency, then installs proxmox-ve.
- packages:
# Build tooling for the equivs kernel stub + out-of-tree WiFi module
- equivs
- build-essential
- bc
- bison
- flex
- libssl-dev
- libelf-dev
- git
# Bluetooth
- bluetooth
- bluez
- bluez-tools
# Networking (Proxmox uses ifupdown2 + a vmbr0 bridge)
- ifupdown2
- openssh-server
- iproute2
- iputils-ping
- curl
- wget
- ca-certificates
- chrony
# Firmware + PS5 boot support
- firmware-linux
- firmware-linux-nonfree
- firmware-amd-graphics
- initramfs-tools
- kexec-tools
- kmod
- busybox
- zstd
- systemd-zram-generator
# Base utilities
- sudo
- cloud-guest-utils
- parted
- e2fsprogs
- usbutils
- pciutils
- rfkill
- util-linux
- vim
- nano
- tmux
- ethtool
action: install
files:
- path: /etc/hostname
generator: hostname
- path: /etc/hosts
generator: hosts
- path: /etc/machine-id
generator: dump
- path: /etc/kernel/postinst.d/zz-update-boot
generator: copy
source: /tmp/build-staging/zz-update-boot
mode: "0755"
- path: /etc/fstab
generator: copy
source: /tmp/build-staging/fstab
- path: /etc/apt/trusted.gpg.d/proxmox-release-bookworm.gpg
generator: copy
source: /tmp/build-staging/proxmox-release-bookworm.gpg
- path: /usr/local/sbin/grow-rootfs
generator: copy
source: /tmp/build-staging/grow-rootfs
mode: "0755"
- path: /etc/systemd/system/grow-rootfs.service
generator: copy
source: /tmp/build-staging/grow-rootfs.service
- path: /opt/debs/
generator: copy
source: /tmp/build-staging/debs
actions:
- trigger: post-unpack
action: |-
#!/bin/bash
set -eux
echo 'Acquire::Retries "10";' > /etc/apt/apt.conf.d/80retry
# The pve repo is active during this first apt-get update, so its signing
# key must be present now (the files: key copy happens later). Inline it.
install -d /etc/apt/trusted.gpg.d
cat > /etc/apt/trusted.gpg.d/proxmox-release-bookworm.asc <<'PVEKEY'
-----BEGIN PGP PUBLIC KEY BLOCK-----
Comment: Use "gpg --dearmor" for unpacking
mQINBGODZZwBEADMA2dbTBXHRkvaOApNhPSRhyuhVfImTCGrUEFMMaUZ0vrEZRf7
wpG7MTVlrQ2gOMshieGU1Oo+Kat5z0MN3g5Q+tck/OG43NQXkoXUkfsV3fiGZ34d
MyiNEYDJB3EcVnX+99OWYmhP2ZcY0rgkSxBFKYpwphclw0gTu7osFu8FB+xFykgi
qqT5PjJryLg8ltE/srt7XTLRusMvPHdrw3OUF6xDIu7YsCsZ2CQu/5BlbWmbhG6J
t3Du7la6t17RFa/jhdRuRPL37VXMLnvc4hQXxsyQgP13kkKXzSzNwgVKcJxzAz4Y
eADAjtQmrnYnwRQahfiob7snTqtxdgE1pPBvSZS/1MXdjGU2nYFcuaOjXJKy2f8n
tpjtXTkTiEDB36OF78K2E9OifrTuqliHylVrF5fPdNax993xcY/VA9DRaUp1WzQy
7Aa95v25vfmdzRlEnlEmGKmXA0XJhUs+dy0vy+9uWwES9z9pL056FcH7NKfST/nF
DwamTVWugKzhmADRSTIdiJ4hW9CfN7gFJxHsodmqUQ80EtJvjzzmtqXMNAv6Yu4o
0H/9dNXlBZP4O4yazRWmZ9hcETbaupaP1sPGKdbYPaeU+eGDZkbhjAnYQXlg3h87
nRlQUbWw/oa2CqBA7Z4udpQoeaTfogcHHiZSBIozy/LC5QfPKk/gu3PYPQARAQAB
tDpQcm94bW94IEJvb2t3b3JtIFJlbGVhc2UgS2V5IDxwcm94bW94LXJlbGVhc2VA
cHJveG1veC5jb20+iQJUBBMBCgA+FiEE9OE2xnzc5Brm3m/IEUCvj2OeDDkFAmOD
ZZwCGwMFCRLMAwAFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQEUCvj2OeDDm5
9w/8CCIfeNtNkrs9Q6WFZEd4Ot+an3UxU00M3QO74LAeLPj8wbCRG1iN3j19sv0e
d6vSyLz9UX79HkiiAHta9GA15MmZa6uTrABBfF8xPpDUPadpPXSAQmaUhr3NgLIB
6jUVWEoBuHpxSwE3DEGgNwypTqgAr0f30mr+iCOd3DcwgkhfIPwWX6GBRWEn8QUj
U7M7jSm9ExtLGy+sBoXsFc4h8I2Q9Yrfe85oRZIHCRKsc1o8TuxvCB3YPntOSZJU
VjV+o8PzTTjWhCjuY+OMKyiiOgbfrtsRhB4PzQ6ZG8655Q+QjAy9+boN0OO/lzRb
/Jpup6zpOvOIWGouvZ77FtCquPzwBiOvxHm7wE2TTVTE48DJDdmzKNFaXf1DQMHA
THLEiU4iI6KoBw+MYPCKSauas77dw4Ftm6jQA+BjtulzBMLT4nq65oQ8lAB7ukBH
CYrI1qQIoGq0c3VuYcO36uW9kRI9InNSM6jymeZJ+SvrREvh+Izzwm1zf+oWtrw2
cyFFF5pCtuaB3i2B1L0tPxi9NWEF7d3e43bkg10TK19Ea0UqgdnMdCHFvHDFz+BA
LuYbTFey1WjOXavOEVfWkC0fpyGjFiMNWUp6FGrxfnOiG25hln+eCWWiWzcTgVJ6
7mqm2XbzSMa8Z+7u6L+BTa8P7OZmuPyCjzIJtE1E1CXH+/o=
=YZF1
-----END PGP PUBLIC KEY BLOCK-----
PVEKEY
apt-get update
- trigger: post-update
action: |-
#!/bin/bash
set -eux
# Proxmox is headless and its web UI / console log in as root@pam, so root
# MUST have a usable password (do NOT lock it like the desktop images).
echo "root:proxmox" | chpasswd
- trigger: post-packages
action: |-
#!/bin/bash
set -eux
export DEBIAN_FRONTEND=noninteractive
# Proxmox requires the hostname to resolve to a non-loopback-style address.
# The image is assembled inside Docker (hostname = container id), so pin a
# stable name and a hosts entry BEFORE proxmox-ve's postinst runs.
printf 'ps5pve\n' > /etc/hostname
if grep -q '^127\.0\.1\.1' /etc/hosts; then
sed -i 's/^127\.0\.1\.1.*/127.0.1.1 ps5pve.local ps5pve/' /etc/hosts
else
printf '127.0.1.1 ps5pve.local ps5pve\n' >> /etc/hosts
fi
hostname ps5pve || true
# Satisfy proxmox-ve's hard kernel dependency with an equivs stub so the
# real (un-bootable on PS5) Proxmox kernel is never installed. The PS5
# kernel .deb is installed later in post-files and provides the modules.
cat > /tmp/ps5-kernel-stub.ctl <<'EOF'
Section: misc
Priority: optional
Standards-Version: 3.9.2
Package: ps5-proxmox-kernel-stub
Version: 1.0
Provides: proxmox-default-kernel, proxmox-kernel-6.8, pve-firmware, linux-image-amd64
Description: PS5 kernel stub so proxmox-ve accepts the PS5-patched kernel
Lets proxmox-ve install without pulling a Proxmox kernel that cannot boot
on the PS5. The PS5-patched kernel is installed separately.
EOF
( cd /tmp && equivs-build ps5-kernel-stub.ctl )
apt-get install -y /tmp/ps5-proxmox-kernel-stub_1.0_all.deb
rm -f /tmp/ps5-proxmox-kernel-stub_1.0_all.deb /tmp/ps5-kernel-stub.ctl
# postfix would prompt interactively; preseed a no-op local config.
echo "postfix postfix/main_mailer_type select No configuration" | debconf-set-selections
# Install Proxmox VE (no recommends keeps the image lean; the stub blocks
# the PVE kernel + pve-firmware).
apt-get install -y --no-install-recommends \
-o Dpkg::Options::=--force-confold \
proxmox-ve postfix open-iscsi
# Allow root password login (test image; default creds root/proxmox).
sed -i 's/^#*PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config
sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
systemctl set-default multi-user.target
systemctl enable ssh.service || true
systemctl enable chrony || true
systemctl enable bluetooth.service || true
# zram swap: half of RAM, zstd compressed
printf '[zram0]\nzram-size = ram / 2\ncompression-algorithm = zstd\n' > /etc/systemd/zram-generator.conf
# Keep the shared image timezone-neutral.
ln -sf /usr/share/zoneinfo/Etc/UTC /etc/localtime
printf 'Etc/UTC\n' > /etc/timezone
- trigger: post-files
action: |-
#!/bin/bash
set -eux
systemctl enable grow-rootfs.service
mkdir -p /boot/efi
# zram, not disk hibernation — don't embed a build-container resume target.
mkdir -p /etc/initramfs-tools/conf.d
printf 'RESUME=none\n' > /etc/initramfs-tools/conf.d/resume
# Install the PS5-patched kernel (image + modules + headers).
dpkg -i /opt/debs/*.deb
rm -rf /opt/debs
apt-mark hold linux-ps5 || true
KVER=$(ls -1t /lib/modules | head -1)
depmod -a "$KVER"
# --- First-boot network: bridge the PS5 ethernet into vmbr0 (DHCP) -------
# Proxmox needs a vmbr0 bridge for VMs/containers. The PS5 ethernet name
# varies, so detect it on first boot and write /etc/network/interfaces.
cat > /etc/network/interfaces <<'EOF'
auto lo
iface lo inet loopback
EOF
cat > /usr/local/sbin/ps5-pve-net <<'EOF'
#!/bin/bash
# Detect the first wired interface and bridge it into vmbr0 with DHCP.
set -eu
IF=$(for i in /sys/class/net/en*; do [ -e "$i" ] || continue; basename "$i"; done | head -n1)
[ -z "${IF:-}" ] && IF=$(for i in /sys/class/net/eth*; do [ -e "$i" ] || continue; basename "$i"; done | head -n1)
if [ -n "${IF:-}" ] && ! grep -q 'vmbr0' /etc/network/interfaces; then
cat >> /etc/network/interfaces <<NET
auto $IF
iface $IF inet manual
auto vmbr0
iface vmbr0 inet dhcp
bridge-ports $IF
bridge-stp off
bridge-fd 0
NET
ifup vmbr0 || systemctl restart networking || true
fi
systemctl disable ps5-pve-net.service || true
EOF
chmod 0755 /usr/local/sbin/ps5-pve-net
cat > /etc/systemd/system/ps5-pve-net.service <<'EOF'
[Unit]
Description=PS5 Proxmox first-boot network bridge setup
After=local-fs.target
Before=pveproxy.service
ConditionPathExists=!/etc/network/interfaces.d/.ps5-net-done
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/ps5-pve-net
ExecStartPost=/bin/sh -c 'mkdir -p /etc/network/interfaces.d && touch /etc/network/interfaces.d/.ps5-net-done'
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
systemctl enable ps5-pve-net.service
# NOTE: PS5 internal WiFi (IW620) is intentionally NOT built for Proxmox.
# Proxmox is a headless hypervisor whose VM/CT networking runs over the
# vmbr0 ethernet bridge (WiFi interfaces cannot be bridged reliably), and
# the trimmed linux-ps5 headers can't rebuild the kernel host tools on
# Debian 12's older glibc. Connect the PS5 via ethernet. (The desktop
# images that need WiFi run on newer-glibc bases where this works.)
depmod -a "$KVER"
# Front-load amdgpu so the PS5 console comes up during boot.
grep -qxF amdgpu /etc/initramfs-tools/modules || printf '\namdgpu\n' >> /etc/initramfs-tools/modules
printf 'MODULES=most\nBUSYBOX=y\n' > /etc/initramfs-tools/conf.d/ps5-amdgpu
update-initramfs -c -k "$KVER"
/etc/kernel/postinst.d/zz-update-boot "$KVER"
mappings:
architecture_map: debian

Binary file not shown.

View File

@@ -5,6 +5,13 @@ set -e
BOOT_PART="/boot/efi"
KVER="$1"
[ -z "$KVER" ] && KVER="$(ls -1t /lib/modules | head -1)"
if [ -f "$BOOT_PART/bzImage" ]; then
mv "$BOOT_PART/bzImage" "$BOOT_PART/bzImage.old"
fi
if [ -f "$BOOT_PART/initrd.img" ]; then
mv "$BOOT_PART/initrd.img" "$BOOT_PART/initrd.img.old"
fi
cp "/boot/vmlinuz-$KVER" "$BOOT_PART/bzImage"
cp "/boot/initrd.img-$KVER" "$BOOT_PART/initrd.img"
echo ">> Kernel $KVER deployed to $BOOT_PART"

View File

@@ -9,11 +9,20 @@ KVER="$1"
DISTRO="unknown"
[ -f /etc/ps5-distro ] && DISTRO="$(cat /etc/ps5-distro)"
if [ -f "$BOOT_PART/bzImage" ]; then
mv "$BOOT_PART/bzImage" "$BOOT_PART/bzImage.old"
fi
if [ -f "$BOOT_PART/initrd-${DISTRO}.img" ]; then
mv "$BOOT_PART/initrd-${DISTRO}.img" "$BOOT_PART/initrd-${DISTRO}.img.old"
fi
cp "/boot/vmlinuz-$KVER" "$BOOT_PART/bzImage"
cp "/boot/initrd.img-$KVER" "$BOOT_PART/initrd-${DISTRO}.img"
# Ubuntu 26.04 is default boot — also update the generic initrd.img
if [ "$DISTRO" = "ubuntu2604" ]; then
if [ -f "$BOOT_PART/initrd.img" ]; then
mv "$BOOT_PART/initrd.img" "$BOOT_PART/initrd.img.old"
fi
cp "/boot/initrd.img-$KVER" "$BOOT_PART/initrd.img"
fi

42
distros/steamos/README.md Normal file
View File

@@ -0,0 +1,42 @@
# steamos
SteamOS 3 image variant for PS5, built by extracting the rootfs from
Valve's official Steam Deck recovery image and swapping in our
`linux-ps5` kernel.
## Source
- Upstream: <https://steamdeck-images.steamos.cloud/recovery/steamdeck-repair-latest.img.bz2>
(Steam Deck recovery image; SteamOS Holo Arch-based.)
- ~3.4 GB compressed bz2, ~10 GB decompressed.
- Valve CDN — no per-IP throttle observed; first-time cache pull on a
cold runner is ~3-5 min, subsequent runs are instant via
`/data/cache/ps5/downloads/`.
## What the build does
1. Resolve `steamdeck-repair-latest.img.bz2` → its dated filename so
the cache key is stable.
2. Decompress → losetup -P.
3. Mount the first **erofs** partition (rootfs-A; A and B are
identical) read-only, `cp -a` to `$CHROOT`.
4. Mount the first **ext4 < 2 GB** partition that contains `/lib`
(var-A), `cp -a` to `$CHROOT/var` — SteamOS splits `/var` off the
rootfs and first-boot fails without it.
5. Extract our `linux-ps5-*.pkg.tar.zst` and merge `usr/`, `boot/`,
`etc/` into `$CHROOT`.
6. Delete SteamOS's `linux-neptune` kernel + modules so grub boots
ours.
7. `depmod -a $KVER`.
The standard image-builder flow then packs `$CHROOT` into an ext4
image with our EFI partition.
## Caveats
- The Steam UI session expects the Deck's `jupiter` hardware quirks
(gamepad, ALS, fan curve daemons) — most of those won't apply on
PS5. Falling back to plasma desktop on the first session is normal.
- SteamOS is read-only by design (steamos-readonly enable). The image
we build here is regular ext4; if you want the read-only A/B atomic
experience, that needs a separate variant.

650
distros/steamos/build-rootfs.sh Executable file
View File

@@ -0,0 +1,650 @@
#!/bin/bash
# distros/steamos/build-rootfs.sh — fetch Valve's Steam Deck recovery image,
# extract its rootfs to $CHROOT, swap in our linux-ps5 kernel. Called from
# docker/image-builder/entrypoint.sh for DISTRO=steamos.
#
# SteamOS 3 (Holo, Arch-based) ships as a single .img.bz2 with multiple
# partitions: A/B rootfs (btrfs on recovery, erofs on production), A/B
# var (ext4), an EFI partition, and a home partition. We take the
# rootfs-A + var-A slots, copy them flat to $CHROOT, then drop in our
# linux-ps5 kernel pkg.tar.zst and rebuild the initramfs with the
# modules amdgpu actually needs to bring up the PS5 Oberon GPU.
#
# Expects in env: DISTRO, CHROOT, KVER, ROOT_LABEL, EFI_LABEL
# Expects on disk: /kernel-debs/linux-ps5-*.pkg.tar.zst,
# /repo/distros/steamos/{return-to-gaming-mode.desktop,
# steam-session-switch-listener.{py,service}},
# /repo/distros/arch/{grow-rootfs,grow-rootfs.service}
set -ex
: "${CHROOT:?ERROR: \$CHROOT unset/empty}"
[ -d "$CHROOT" ] || { echo "ERROR: \$CHROOT=$CHROOT not a directory"; exit 2; }
case "$CHROOT" in /) echo "ERROR: refuse to operate on /"; exit 2 ;; esac
STEAMOS_URL="${STEAMOS_URL:-https://steamdeck-images.steamos.cloud/recovery/steamdeck-repair-latest.img.bz2}"
echo "=== SteamOS: resolve latest image URL ==="
# python3 is in the upstream image-builder Dockerfile; curl isn't.
RESOLVED_URL=$(python3 -c "import urllib.request; print(urllib.request.urlopen('$STEAMOS_URL').geturl())")
IMG_NAME=$(basename "$RESOLVED_URL")
echo ">> resolved $IMG_NAME"
CACHED="/build/cache/$IMG_NAME"
if [ ! -s "$CACHED" ]; then
echo ">> downloading $IMG_NAME (~3.4 GB from Valve CDN)"
wget --tries=3 -O "$CACHED.part" "$RESOLVED_URL"
mv "$CACHED.part" "$CACHED"
fi
echo ">> using $CACHED ($(du -h "$CACHED" | cut -f1))"
echo "=== SteamOS: decompress + loop ==="
STEAMOS_IMG=/build/steamos-src.img
bunzip2 -kc "$CACHED" > "$STEAMOS_IMG"
SLOOP=$(losetup -Pf --show "$STEAMOS_IMG")
sleep 1
# kpartx fallback when loopNpX devices didn't appear under /dev/
if [ ! -e "${SLOOP}p1" ]; then
kpartx -av "$SLOOP"
sleep 1
fi
# Build candidate partition list — either /dev/loopNpX or
# /dev/mapper/loopNpX depending on how kpartx mapped them.
SLOOP_BASE=$(basename "$SLOOP")
PART_PATHS=()
for cand in "${SLOOP}"p* "/dev/mapper/${SLOOP_BASE}"p*; do
[ -e "$cand" ] && PART_PATHS+=("$cand")
done
# The RECOVERY image uses btrfs for the rootfs (production
# SteamOS uses erofs A/B; recovery needs writes so it's btrfs).
# Take the first btrfs partition; that's rootfs. Fall back to
# erofs in case Valve ever changes it.
SROOT=""
SROOT_FS=""
for fstype in btrfs erofs; do
for p in "${PART_PATHS[@]}"; do
t=$(blkid -o value -s TYPE "$p" 2>/dev/null || echo "")
if [ "$t" = "$fstype" ]; then
SROOT="$p"; SROOT_FS="$fstype"; break 2
fi
done
done
if [ -z "$SROOT" ]; then
echo "ERROR: no btrfs/erofs rootfs in SteamOS image. partitions seen:"
for p in "${PART_PATHS[@]}"; do
echo " $p type=$(blkid -o value -s TYPE "$p" 2>/dev/null) size=$(blockdev --getsize64 "$p" 2>/dev/null)"
done
exit 1
fi
echo ">> rootfs slot: $SROOT ($SROOT_FS)"
SMNT=$(mktemp -d)
mount -t "$SROOT_FS" -o ro "$SROOT" "$SMNT"
# If btrfs, look for SteamOS's @rootfs subvolume — the top-level
# btrfs view may be empty / hold only subvol entries, in which
# case we need to remount with subvol=@rootfs (or @, depending
# on the image's layout).
if [ "$SROOT_FS" = "btrfs" ] && [ ! -d "$SMNT/usr" ]; then
echo ">> top-level btrfs has no /usr; scanning subvolumes"
btrfs subvolume list "$SMNT" 2>&1 | head -20 || true
umount "$SMNT"
for sub in @rootfs @ rootfs root; do
if mount -t btrfs -o "ro,subvol=$sub" "$SROOT" "$SMNT" 2>/dev/null; then
if [ -d "$SMNT/usr" ]; then
echo ">> rootfs at subvol=$sub"
break
fi
umount "$SMNT"
fi
done
if [ ! -d "$SMNT/usr" ]; then
echo "ERROR: couldn't locate rootfs in btrfs at any known subvol"
exit 1
fi
fi
echo "=== SteamOS: copy rootfs ($(du -sh "$SMNT" | cut -f1)) -> \$CHROOT ==="
cp -a "$SMNT"/. "$CHROOT/"
umount "$SMNT"
rmdir "$SMNT"
# SteamOS splits /var off the rootfs onto its own partition.
# First-boot fails without /var/lib/dbus etc.
VARMNT=$(mktemp -d)
for p in "${PART_PATHS[@]}"; do
t=$(blkid -o value -s TYPE "$p" 2>/dev/null || echo "")
sz=$(blockdev --getsize64 "$p" 2>/dev/null || echo 0)
if [ "$t" = "ext4" ] && [ "$sz" -lt $((2 * 1024 * 1024 * 1024)) ]; then
if mount -o ro "$p" "$VARMNT" 2>/dev/null; then
if [ -d "$VARMNT/lib" ] || [ -d "$VARMNT/log" ]; then
echo "=== SteamOS: copy var ($(du -sh "$VARMNT" | cut -f1)) -> \$CHROOT/var ==="
mkdir -p "$CHROOT/var"
cp -a "$VARMNT"/. "$CHROOT/var/" || true
umount "$VARMNT"
break
fi
umount "$VARMNT" 2>/dev/null || true
fi
fi
done
rmdir "$VARMNT"
kpartx -dv "$SLOOP" 2>/dev/null || true
losetup -d "$SLOOP"
rm -f "$STEAMOS_IMG"
echo "=== SteamOS: install linux-ps5 kernel pkg ==="
PKG=$(ls /kernel-debs/linux-ps5-*.pkg.tar.zst 2>/dev/null | head -1)
[ -z "$PKG" ] && { echo "ERROR: no linux-ps5 pkg.tar.zst in /kernel-debs/"; exit 1; }
TMP=$(mktemp -d)
# Upstream image-builder doesn't ship libarchive-tools (bsdtar). GNU tar
# 1.32+ handles .pkg.tar.zst via --zstd; image-builder runs Ubuntu 24.04
# with tar 1.35.
tar --zstd -xf "$PKG" -C "$TMP"
for d in usr boot etc; do
[ -d "$TMP/$d" ] && cp -a "$TMP/$d"/. "$CHROOT/$d/" 2>/dev/null || true
done
rm -rf "$TMP"
# Redetect KVER from the installed module dir — the entrypoint's
# top-level detect_kver pulls a bare semver like '7.0.10' out of the
# pkg filename, but the linux-ps5 pkg actually installs modules under
# something like '7.0.10-ps5' (with a localversion suffix). Use what's
# actually on disk so depmod / mkinitramfs find the modules.
KVER=$(ls -1 "$CHROOT/usr/lib/modules" 2>/dev/null \
| grep -v "$(ls -1 "$CHROOT/usr/lib/modules" 2>/dev/null \
| grep -E 'neptune|^[0-9]+\.[0-9]+\.[0-9]+-[^-]+$' \
| grep -v 'ps5' || true)" | head -1)
[ -z "$KVER" ] && KVER=$(ls -1 "$CHROOT/usr/lib/modules" 2>/dev/null | grep ps5 | head -1)
[ -z "$KVER" ] && KVER=$(ls -1 "$CHROOT/usr/lib/modules" 2>/dev/null | head -1)
echo ">> KVER=$KVER"
# Drop SteamOS's own kernel(s) so grub boots ours
rm -f "$CHROOT"/boot/vmlinuz-linux* "$CHROOT"/boot/initrd*-neptune* \
"$CHROOT"/boot/initramfs-linux*.img 2>/dev/null || true
for d in "$CHROOT"/usr/lib/modules/*; do
[ -d "$d" ] || continue
[ "$(basename "$d")" = "$KVER" ] && continue
rm -rf "$d"
done
if [ -x "$CHROOT/usr/bin/depmod" ]; then
chroot "$CHROOT" /usr/bin/depmod -a "$KVER" || true
else
depmod -b "$CHROOT" "$KVER" || true
fi
[ -f "$CHROOT/boot/vmlinuz-$KVER" ] || \
cp "$CHROOT"/boot/vmlinuz-* "$CHROOT/boot/vmlinuz-$KVER" 2>/dev/null || true
mkdir -p "$CHROOT/boot/efi"
cp "$CHROOT/boot/vmlinuz-$KVER" "$CHROOT/boot/efi/bzImage" 2>/dev/null || true
# Symlink chroot's modules into host /lib/modules so the build
# container's mkinitramfs/depmod can see them. PS5 kernel pkg
# installs modules under /usr/lib/modules.
echo "=== SteamOS: build initrd via host mkinitramfs ==="
mkdir -p /lib/modules
ln -sfn "$CHROOT/usr/lib/modules/$KVER" "/lib/modules/$KVER"
cat > /etc/initramfs-tools/modules <<'INITMODS'
# USB host controllers (PS5 boot drive is USB 3 — xhci must be present).
xhci_pci
xhci_hcd
ehci_pci
ehci_hcd
ohci_pci
ohci_hcd
# Storage
usb-storage
uas
# Filesystems we may root on
ext4
btrfs
vfat
# Block stack
sd_mod
loop
dm_mod
# AMD GPU + display for early framebuffer
amdgpu
INITMODS
# Force MODULES=most so we include everything for the PS5
# regardless of what's loaded in the build container.
sed -i 's/^MODULES=.*/MODULES=most/' /etc/initramfs-tools/initramfs.conf
# amdgpu options for the PS5 Oberon GPU: dpm=0 keeps HDMI alive
# (DPM transitions stall the DP→HDMI bridge into a stuck-low
# state, displayed as a black screen the moment KWin tries to
# take DRM master from gamescope — exact symptom: 'failed to
# display topology' in Plasma + black screen with cursor after
# Switch-to-Desktop). gpu_recovery=0 avoids the linux-ps5
# patched recovery path that hard-faults on Oberon. Bazzite
# already ships these for the same reason.
#
# Land in both initramfs and rootfs so amdgpu picks them up at
# both probe times (early initramfs phase + post-switch_root).
mkdir -p "$CHROOT/etc/modprobe.d" /etc/modprobe.d
cat > "$CHROOT/etc/modprobe.d/ps5-amdgpu.conf" <<'AMDGPU'
options amdgpu dpm=0 gpu_recovery=0
AMDGPU
cp "$CHROOT/etc/modprobe.d/ps5-amdgpu.conf" /etc/modprobe.d/ps5-amdgpu.conf
mkinitramfs -k "$KVER" -o "$CHROOT/boot/efi/initrd.img"
# Also drop a copy at the standard location inside the rootfs.
cp "$CHROOT/boot/efi/initrd.img" "$CHROOT/boot/initramfs-$KVER.img"
rm -f "/lib/modules/$KVER"
# SteamOS recovery image ships without a default.target symlink and
# relies on the cmdline to set systemd.unit=. Without either,
# systemd hangs at "solid cursor on tty1" forever. Fix both so
# either path reaches graphical.target.
ln -sf /usr/lib/systemd/system/graphical.target "$CHROOT/etc/systemd/system/default.target"
# sshd: recovery image ships it disabled. We want it on by
# default so we can ssh in from the LAN without poking the
# console. Enable + set deck's password to 'deck' (matches
# the build-time convention used for the other distros).
mkdir -p "$CHROOT/etc/systemd/system/multi-user.target.wants"
ln -sf /usr/lib/systemd/system/sshd.service \
"$CHROOT/etc/systemd/system/multi-user.target.wants/sshd.service"
chroot "$CHROOT" /usr/bin/bash -c 'echo "deck:deck" | chpasswd' 2>/dev/null || \
chroot "$CHROOT" /bin/sh -c 'echo "deck:deck" | chpasswd' 2>/dev/null || true
# also unlock the account in case it's locked
chroot "$CHROOT" /usr/bin/passwd -u deck 2>/dev/null || true
# Enable sddm. The recovery image ships sddm.service in
# /usr/lib/systemd/system but NEVER symlinks it into
# graphical.target.wants — relies on a first-boot Valve setup
# script that we've cut out. Without this, graphical.target
# is reached but no display manager starts → black screen
# even with working GPU init.
mkdir -p "$CHROOT/etc/systemd/system/graphical.target.wants"
ln -sf /usr/lib/systemd/system/sddm.service \
"$CHROOT/etc/systemd/system/graphical.target.wants/sddm.service"
# Switch-to-Desktop / Return-to-Gaming buttons: gamescope/Steam
# and steamos-manager may invoke a session named literally
# "desktop" or "gamescope" (bare aliases) — those land both in
# SDDM's session lookup AND in our steamos-session-select shim
# below. Add symlinks so the SDDM lookup path resolves, and
# match the same naming below in the shim's case statement.
ln -sfn plasma.desktop "$CHROOT/usr/share/wayland-sessions/desktop.desktop"
ln -sfn plasmax11.desktop "$CHROOT/usr/share/xsessions/desktop.desktop"
ln -sfn gamescope-wayland.desktop "$CHROOT/usr/share/wayland-sessions/gamescope.desktop"
# AMD GPU firmware comes from the SteamOS rootfs as .bin.zst
# (zstd-compressed). Our linux-ps5 7.0.10 kernel is built
# WITHOUT CONFIG_FW_LOADER_COMPRESS_ZSTD=y so the kernel's
# firmware loader looks for the bare .bin only, fails -2,
# amdgpu logs "Fatal error during GPU init", no display.
# Decompress every firmware blob in place; keep the .zst
# alongside (cheap, harmless) so the kernel finds the .bin
# without us having to enumerate which specific blobs amdgpu
# asks for on this revision (currently pfp + sdma; could
# grow). Side benefit: covers wifi/bluetooth/etc firmware
# for free.
echo "=== SteamOS: decompress *.bin.zst firmware (kernel can't load compressed) ==="
fw_count=0
while IFS= read -r -d '' zst; do
out="${zst%.zst}"
if [ ! -e "$out" ]; then
zstd -dq -o "$out" "$zst" && fw_count=$((fw_count+1))
fi
done < <(find "$CHROOT/lib/firmware" "$CHROOT/usr/lib/firmware" \
-name '*.bin.zst' -print0 2>/dev/null)
echo ">> decompressed $fw_count firmware files"
# mkinitramfs runs BEFORE this point in the script and bakes
# the amdgpu module into the initramfs (per our modules list).
# But Debian's mkinitramfs does NOT copy /lib/firmware/* into
# the initramfs by default — so when amdgpu probes in early
# userspace (initramfs phase, before switch_root), it requests
# firmware from initramfs's /lib/firmware/ which doesn't have
# it → ENOENT → fatal GPU init → no display, identical
# symptom to the .zst-only-firmware bug above. Inject the
# decompressed amdgpu blobs INTO the initrd image now (it's
# a zstd cpio newc archive — extract, add, repack).
INITRD="$CHROOT/boot/efi/initrd.img"
if [ -f "$INITRD" ] && [ -d "$CHROOT/lib/firmware/amdgpu" ]; then
echo "=== SteamOS: inject amdgpu firmware into initramfs ==="
IWORK=$(mktemp -d)
( cd "$IWORK" && zstd -dc "$INITRD" | cpio -idmu --quiet --no-absolute-filenames )
mkdir -p "$IWORK/usr/lib/firmware/amdgpu"
cp "$CHROOT/lib/firmware/amdgpu"/*.bin "$IWORK/usr/lib/firmware/amdgpu/" 2>/dev/null || true
# Some kernels probe /lib/firmware first, some /usr/lib/firmware.
# Make both reachable.
ln -sfn usr/lib/firmware "$IWORK/lib/firmware" 2>/dev/null || true
( cd "$IWORK" && find . | cpio -o -H newc --quiet | zstd -19 --quiet ) > "$INITRD.new"
mv "$INITRD.new" "$INITRD"
cp "$INITRD" "$CHROOT/boot/initramfs-$KVER.img"
rm -rf "$IWORK"
echo ">> initramfs repacked with firmware ($(du -h "$INITRD" | cut -f1))"
fi
# Persistent journal so post-boot failures are debuggable from
# outside (read it by mounting the rootfs). Needs the
# systemd-journal group ownership + setgid bit or journald
# refuses to use it and silently stays volatile.
mkdir -p "$CHROOT/var/log/journal"
chown 0:980 "$CHROOT/var/log/journal" 2>/dev/null || \
chroot "$CHROOT" chown 0:systemd-journal /var/log/journal 2>/dev/null || true
chmod 2755 "$CHROOT/var/log/journal"
# SteamOS fstab references its A/B atomic partset paths
# (/dev/disk/by-partsets/{self,shared}/...) which don't exist
# on a flat-rootfs flash — every mount unit fails and stalls
# local-fs.target dependencies (NetworkManager etc.).
sed -i '/by-partsets/s|^|#FLAT-ROOTFS-DISABLED# |' "$CHROOT/etc/fstab"
# …but we still need /efi mounted at boot. ps5-stage-firmware
# reads the NXP IW620 WLAN blob from /boot/efi/lib/nxp/, and
# cmdline.txt / kexec.sh / bzImage live there too. Append a
# label-based mount line so the FAT boot partition is up
# before sysinit.target. /boot/efi is a symlink to /efi in
# the recovery image; mount the canonical target.
cat >> "$CHROOT/etc/fstab" <<FSTAB
LABEL=$EFI_LABEL /efi vfat defaults,nofail,umask=0077 0 2
FSTAB
# steamos-customizations also ships explicit .mount units for
# esp/efi/home that pull in partset-aware fsck + blockdev
# targets. Mask esp.mount + home.mount (we don't have those
# partitions), but leave efi.mount alone — it'd race the
# fstab line above. systemd just generates a fresh efi.mount
# from our fstab entry.
for u in esp.mount home.mount; do
ln -sf /dev/null "$CHROOT/etc/systemd/system/$u"
done
# The recovery image ships /etc/sddm.conf.d/zz-steamos-autologin.conf
# with Session=plasma — it's normally written when a Deck user
# picks "Switch to Desktop" from Steam, but the recovery image
# bakes it in pre-set to KDE. With it present, SDDM autologs
# into Plasma instead of the gamescope-wayland session.
#
# Just `rm` isn't enough: on first boot ps5-gamescope-recovery
# (or steam-jupiter falling back to KDE because gamescope can't
# grab a display) writes this file back with Session=plasma,
# and we land in Plasma on every subsequent boot too.
# Overwrite with gamescope-wayland.desktop so even when the
# file gets recreated by Steam/recovery, we still autologin
# into Big Picture.
mkdir -p "$CHROOT/etc/sddm.conf.d"
cat > "$CHROOT/etc/sddm.conf.d/zz-steamos-autologin.conf" <<'EOF'
[Autologin]
Session=gamescope-wayland.desktop
EOF
# Replace /usr/bin/steamos-session-select with a dumb shim that
# writes the sddm autologin file directly + restarts sddm. The
# vendored version delegates to steamosctl/steamos-manager whose
# Steam Deck hardware code paths silently no-op on PS5 — the
# symptom is "Return to Gaming Mode just reloads Desktop" because
# the autologin Session= never actually changes. This shim bypasses
# the whole DBus dance.
if [ -f "$CHROOT/usr/bin/steamos-session-select" ]; then
mv "$CHROOT/usr/bin/steamos-session-select" \
"$CHROOT/usr/bin/steamos-session-select.orig"
cat > "$CHROOT/usr/bin/steamos-session-select" <<'SHIM'
#!/bin/bash
# PS5-flat-rootfs shim: write a sddm autologin override directly +
# restart sddm. Bypasses the vendored script that delegates to
# steamosctl/steamos-manager (which silently no-ops on PS5).
#
# Uses sudo (deck has passwordless sudo on this build) rather than
# the recovery image's pkexec → steamos-priv-write path: that helper
# only whitelists /sys/class/backlight and similar paths, NOT
# /etc/sddm.conf.d, so any pkexec write attempt fails with an
# unbound-variable error from the priv-write script. Sudo is simpler
# and works from any context (Konsole, desktop shortcut, even SSH).
#
# Conf filename 'zzz-session-override.conf':
# * sorts AFTER zz-steamos-autologin.conf so this file wins precedence
# * does NOT match sddm's ExecStartPre 'rm /etc/sddm.conf.d/zzt-...'
# drop-in pattern, so the override survives systemctl restart sddm
set -e
session="${1:-gamescope}"
case "$session" in
plasma-wayland|plasma-wayland-persistent) target="plasma.desktop" ;;
plasma|plasma-x11|plasma-x11-persistent) target="plasmax11.desktop" ;;
desktop) target="plasma.desktop" ;;
gamescope) target="gamescope-wayland.desktop" ;;
*) target="gamescope-wayland.desktop" ;;
esac
sudo tee /etc/sddm.conf.d/zzz-session-override.conf >/dev/null <<EOF
[Autologin]
User=deck
Session=$target
Relogin=true
EOF
sudo systemctl restart sddm
SHIM
chmod 755 "$CHROOT/usr/bin/steamos-session-select"
fi
# Steam Big Picture's Power-Menu "Switch to Desktop" button
# fires a dbus call:
# dbus-send --system --dest=org.freedesktop.DisplayManager \
# /org/freedesktop/DisplayManager/Seat0 \
# org.freedesktop.DisplayManager.Seat.SwitchToUser \
# string:doorstop string:<session>
# On the real Steam Deck this lands in Valve's patched SDDM
# which writes /etc/sddm.conf.d/zzt-steamos-temp-login.conf.
# Stock Arch SDDM (what the recovery image ships) accepts the
# call but does nothing useful with it — the button is a no-op,
# and re-adding -steamos3 to the Steam launcher to route the
# button through our shim triggers Steam's A/B atomic-update
# reboot loop.
#
# Workaround: a tiny daemon that BecomeMonitor()s the system
# bus, catches the SwitchToUser call, and runs the same
# zzz-session-override.conf write + sddm restart that our shim
# does. Steam's button now works without the -steamos3 flag.
mkdir -p "$CHROOT/usr/local/sbin" "$CHROOT/etc/systemd/system"
install -m 755 /repo/distros/steamos/steam-session-switch-listener.py \
"$CHROOT/usr/local/sbin/steam-session-switch-listener" 2>/dev/null \
|| cp /repo/distros/steamos/steam-session-switch-listener.py \
"$CHROOT/usr/local/sbin/steam-session-switch-listener"
chmod +x "$CHROOT/usr/local/sbin/steam-session-switch-listener"
cp /repo/distros/steamos/steam-session-switch-listener.service \
"$CHROOT/etc/systemd/system/steam-session-switch-listener.service"
mkdir -p "$CHROOT/etc/systemd/system/multi-user.target.wants"
ln -sf ../steam-session-switch-listener.service \
"$CHROOT/etc/systemd/system/multi-user.target.wants/steam-session-switch-listener.service"
# Replace the recovery image's broken Return.desktop. The vendor
# shortcut delegates to steamosctl, which silently no-ops on
# PS5. Ours calls steamos-session-select gamescope directly
# (our shim above does the real work). Drop it on deck's
# Desktop AND in /usr/share/applications so it's discoverable
# from the app menu too.
mkdir -p "$CHROOT/home/deck/Desktop" "$CHROOT/usr/share/applications"
cp /repo/distros/steamos/return-to-gaming-mode.desktop \
"$CHROOT/home/deck/Desktop/Return.desktop"
cp /repo/distros/steamos/return-to-gaming-mode.desktop \
"$CHROOT/usr/share/applications/return-to-gaming-mode.desktop"
chmod 755 "$CHROOT/home/deck/Desktop/Return.desktop"
# The recovery image ships /home/deck owned by root:root (the
# vendor install flow chowns it later during first-boot OOBE,
# which we've masked out). Without this fix, KDE Plasma fails
# to create /home/deck/.config on first login — error popup:
# "Configuration file /home/deck/.config/kcminitrc not writable.
# Please contact your system administrator."
# Chown the whole tree to deck:deck (uid/gid 1000) so Plasma
# init can write its caches/configs.
chroot "$CHROOT" chown -R deck:deck /home/deck 2>/dev/null || \
chown -R 1000:1000 "$CHROOT/home/deck"
# The recovery image's pacman trust DB is populated for
# archlinux only — not the holo (SteamOS Valve CI) keys —
# so `pacman -Syu` on first boot fails every package with
# "unknown trust" from the GitLab CI Package Builder key.
# The holo-keyring package IS installed; just needs --populate.
chroot "$CHROOT" pacman-key --init 2>/dev/null || true
chroot "$CHROOT" pacman-key --populate holo 2>/dev/null || true
# First-boot rootfs grow. Reuses the arch distro's grow-rootfs +
# service (same Arch-userland recipe — growpart + resize2fs at
# boot). Our build pins a fixed-size rootfs (~14 GB); the user
# flashes onto multi-hundred-GB USB drives. Without this, sda1
# stays 11 GB and Steam/flatpak run out of space within an hour.
install -m 755 /repo/distros/arch/grow-rootfs \
"$CHROOT/usr/local/sbin/grow-rootfs"
install -m 644 /repo/distros/arch/grow-rootfs.service \
"$CHROOT/etc/systemd/system/grow-rootfs.service"
mkdir -p "$CHROOT/etc/systemd/system/local-fs.target.wants"
ln -sf ../grow-rootfs.service \
"$CHROOT/etc/systemd/system/local-fs.target.wants/grow-rootfs.service"
# Disable the atomic OS updater. The Steam UI calls
# /usr/bin/steamos-update on session start, which tries to
# apply Valve's A/B atomic OTA via partsets we don't have +
# neptune kernel != our linux-ps5. Result: "Unable to
# download the required update" nag every session. Replace
# with a noop that returns "no update available" (exit 7)
# while honoring the --supports-duplicate-detection probe.
if [ -f "$CHROOT/usr/bin/steamos-update" ]; then
mv "$CHROOT/usr/bin/steamos-update" "$CHROOT/usr/bin/steamos-update.orig"
cat > "$CHROOT/usr/bin/steamos-update" <<'SHIM'
#!/bin/bash
# Disabled — flat-rootfs PS5 build can't apply SteamOS A/B atomic OTAs.
if [ "$1" = "--supports-duplicate-detection" ]; then
echo "supports duplicate detection" >&2
exit 0
fi
echo "No update available (disabled — flat-rootfs PS5 build)" >&2
exit 7
SHIM
chmod 755 "$CHROOT/usr/bin/steamos-update"
fi
# Even with steamos-update shimmed, the Steam Big Picture UI
# downloads the OS update tarball directly from
# update.steamos.cloud and calls steamos-reboot --reboot-other
# to apply it. --reboot-other is Valve's "switch active A/B
# partset slot then reboot" — since we have no partsets it
# just reboots into the same image, Steam sees the update
# still pending, downloads again, reboots. Infinite loop.
# Shim steamos-reboot so --reboot-other is a silent noop;
# plain reboot still works.
# Strip -steamos3 + -steampal from the gamescope-session launcher.
# These two flags are what makes the Steam client believe it's
# running on a real Deck with the A/B atomic update model, so
# after every bootstrap download Steam calls logind.Reboot to
# "apply" the update — on a flat-rootfs build the reboot does
# nothing and we just come back to the same image, loop forever.
# Keep -steamdeck (Big Picture UI fidelity) and -gamepadui
# (gamepad-driven UI). Also tack on -noverifyfiles so the
# 493 MB client redownload doesn't repeat every session.
sed -i 's|^steamargs=("-steamos3" "-steampal" "-steamdeck" "-gamepadui")|steamargs=("-steamdeck" "-gamepadui" "-noverifyfiles")|' \
"$CHROOT/usr/lib/steamos/steam-launcher" 2>/dev/null || true
# /usr/bin/steam → /usr/bin/steam-jupiter (symlink). The
# steam-jupiter wrapper is Valve's OOBE script that does
# `rm -rf ~/.steam ~/.local/share/Steam` at the top of every
# launch — because the recovery image is meant to live as a
# single-shot first-boot installer and the A/B atomic system
# handles persistence. On our flat-rootfs build that wipe is
# the cause of the "Steam downloads ~493 MB every session"
# loop. Replace with one that just execs the real Steam
# binary with the deck flags.
if [ -f "$CHROOT/usr/bin/steam-jupiter" ]; then
mv "$CHROOT/usr/bin/steam-jupiter" "$CHROOT/usr/bin/steam-jupiter.orig"
cat > "$CHROOT/usr/bin/steam-jupiter" <<'SHIM'
#!/bin/bash
# Patched for flat-rootfs PS5 build:
# - skip OOBE rm -rf so Steam state persists across the reboot Steam
# would otherwise fire post-bootstrap
# - drop -steamdeck so this desktop entry doesn't trigger the Deck-
# specific reboot-to-apply update path either. Gaming Mode entry
# path keeps its UI flags via /usr/lib/steamos/steam-launcher.
set -euo pipefail
exec /usr/lib/steam/steam -skipinitialbootstrap "$@"
SHIM
chmod 755 "$CHROOT/usr/bin/steam-jupiter"
fi
# Steam binary calls logind.Reboot via DBus directly after the
# bootstrap finishes downloading (its "apply the update by
# rebooting" flow that assumes Deck-style A/B atomic). The
# reboot happens BEFORE the bootstrap can mark itself
# complete, so next boot Steam still thinks installed=0 and
# re-downloads — loop. Block reboot/power-off from the deck
# user via polkit so Steam can't trigger it. Manual reboots
# via SSH/Konsole as root still work.
mkdir -p "$CHROOT/etc/polkit-1/rules.d"
cat > "$CHROOT/etc/polkit-1/rules.d/05-disable-deck-reboot.rules" <<'PK'
// Block Steam (running as deck) from issuing reboot/poweroff via DBus.
// On a real Steam Deck the A/B atomic update model wants Steam to be
// able to "reboot to apply" — here it traps us in a download loop
// because nothing actually applies. Manual reboots still work as root
// (sudo reboot via SSH or Konsole in Desktop Mode).
polkit.addRule(function(action, subject) {
if (subject.user === "deck" && (
action.id === "org.freedesktop.login1.reboot" ||
action.id === "org.freedesktop.login1.reboot-multiple-sessions" ||
action.id === "org.freedesktop.login1.power-off" ||
action.id === "org.freedesktop.login1.power-off-multiple-sessions" ||
action.id === "org.freedesktop.login1.set-reboot-to-firmware-setup"
)) {
return polkit.Result.NO;
}
});
PK
chmod 644 "$CHROOT/etc/polkit-1/rules.d/05-disable-deck-reboot.rules"
if [ -f "$CHROOT/usr/bin/steamos-reboot" ]; then
mv "$CHROOT/usr/bin/steamos-reboot" "$CHROOT/usr/bin/steamos-reboot.orig"
cat > "$CHROOT/usr/bin/steamos-reboot" <<'SHIM'
#!/bin/bash
# Shimmed for flat-rootfs PS5 build — no A/B partsets, so --reboot-other
# (Steam OS-update apply path) restarts SDDM instead of doing the A/B
# slot switch + reboot. Gamescope-session relaunches cleanly into Steam
# login. Plain reboot still works.
case "${1:-}" in
--reboot-other)
# Clear pending atomupd state so Steam does not immediately
# re-prompt for the same "update" on the next session.
rm -rf /var/lib/steamos-atomupd/.cache \
/home/.steamos/offload/var/lib/steamos-atomupd/.cache \
2>/dev/null || true
echo "steamos-reboot: --reboot-other -> sddm restart (no A/B partsets)" >&2
systemctl restart sddm
exit 0
;;
*)
exec /sbin/reboot "$@"
;;
esac
SHIM
chmod 755 "$CHROOT/usr/bin/steamos-reboot"
fi
# FINAL OVERRIDE — the earlier zz-steamos-autologin.conf write
# isn't sticking on the flashed image (file shows the recovery image's
# stock mtime / Session=plasma after boot). Something between that
# write and the partition copy in the standard image-builder flow is
# overlaying it back. Last-write-wins: redo it here at the end of the
# script, and ALSO drop a sentinel zzz-session-override.conf that
# sorts even later — same one our shim writes — so SDDM merge order
# absolutely lands on gamescope-wayland for first boot.
mkdir -p "$CHROOT/etc/sddm.conf.d"
cat > "$CHROOT/etc/sddm.conf.d/zz-steamos-autologin.conf" <<'AUTOLOGIN_END'
[Autologin]
Session=gamescope-wayland.desktop
AUTOLOGIN_END
cat > "$CHROOT/etc/sddm.conf.d/zzz-session-override.conf" <<'OVERRIDE_END'
[Autologin]
User=deck
Session=gamescope-wayland.desktop
Relogin=true
OVERRIDE_END
echo "=== verify autologin file content ==="
ls -la "$CHROOT/etc/sddm.conf.d/"
grep -H Session= "$CHROOT/etc/sddm.conf.d/"*.conf || true
echo "=== SteamOS: rootfs prepared at \$CHROOT ($(du -sh "$CHROOT" | cut -f1)), initrd $(du -h "$CHROOT/boot/efi/initrd.img" | cut -f1) ==="

View File

@@ -0,0 +1,8 @@
[Desktop Entry]
Type=Application
Name=Return to Gaming Mode
Comment=Switch the autologin session back to gamescope / Steam Deck UI
Exec=bash -c 'steamos-session-select gamescope'
Icon=steamdeck-gaming-return
Terminal=false
Categories=System;

View File

@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Listens on the system bus for Steam Big Picture's Switch-to-Desktop
dbus call (org.freedesktop.DisplayManager.Seat.SwitchToUser) and performs
the session swap directly — runs as root so no pkexec / polkit needed.
Flow:
1. Steam UI's Power-Menu Switch-to-Desktop fires
dbus-send --system --dest=org.freedesktop.DisplayManager \\
/org/freedesktop/DisplayManager/Seat0 \\
org.freedesktop.DisplayManager.Seat.SwitchToUser \\
string:doorstop string:plasma
2. This listener catches the call (BecomeMonitor mode on system bus)
3. Writes /etc/sddm.conf.d/zzt-steamos-temp-login.conf for plasma
4. Runs 'steam -shutdown' as the deck user — gamescope-session ends
cleanly because Steam (gamescope's child) exits, DRM master is
released the gentle way, sddm bounces and autologs into plasma.
5. The zzt- conf is one-shot — sddm's ExecStartPre wipes it on the
next sddm restart, so closing plasma drops back to gamescope.
"""
import dbus, dbus.mainloop.glib, subprocess, sys, traceback
from gi.repository import GLib
LOG = "[steam-session-switch-listener]"
# zzz- sorts AFTER zz-steamos-autologin.conf (so it overrides), AND it
# doesn't match the ExecStartPre rm pattern that only deletes 'zzt-...'
# — so the conf survives `systemctl restart sddm` cycles.
ZZT = "/etc/sddm.conf.d/zzz-session-override.conf"
SESSION_MAP = {
"plasma": "plasma.desktop",
"plasma-wayland": "plasma.desktop",
"plasma-wayland-persistent": "plasma.desktop",
"desktop": "plasma.desktop",
"plasma-x11": "plasmax11.desktop",
"plasma-x11-persistent": "plasmax11.desktop",
"gamescope": "gamescope-wayland.desktop",
}
def handle_switch(session):
target = SESSION_MAP.get(session, "gamescope-wayland.desktop")
print(f"{LOG} writing zzt- conf -> {target}", flush=True)
with open(ZZT, "w") as f:
f.write(f"[Autologin]\nUser=deck\nSession={target}\nRelogin=true\n")
# Restart sddm directly. Since we used zzz-session-override.conf,
# the ExecStartPre that only kills zzt-... doesn't wipe it. sddm
# restart reads the conf (zzz- sorts after zz-steamos-autologin.conf,
# so it wins) and autologs into the requested session.
print(f"{LOG} systemctl restart sddm", flush=True)
subprocess.Popen(["systemctl", "restart", "sddm"], stdin=subprocess.DEVNULL)
def on_message(_bus, message):
try:
if message.get_interface() != "org.freedesktop.DisplayManager.Seat":
return
if message.get_member() != "SwitchToUser":
return
args = list(message.get_args_list())
if len(args) < 2:
return
user, session = str(args[0]), str(args[1])
print(f"{LOG} caught SwitchToUser({user!r}, {session!r})", flush=True)
handle_switch(session)
except Exception:
# NEVER let an exception kill the monitor — log and continue
traceback.print_exc()
def main():
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
bus = dbus.SystemBus()
bus_obj = bus.get_object("org.freedesktop.DBus", "/org/freedesktop/DBus")
monitoring = dbus.Interface(bus_obj, "org.freedesktop.DBus.Monitoring")
monitoring.BecomeMonitor(
["type='method_call',"
"interface='org.freedesktop.DisplayManager.Seat',"
"member='SwitchToUser'"],
dbus.UInt32(0),
)
bus.add_message_filter(on_message)
print(f"{LOG} listening on system bus for SwitchToUser", flush=True)
GLib.MainLoop().run()
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,14 @@
[Unit]
Description=Translate Steam Big Picture Switch-to-Desktop dbus call into steamos-session-select
Documentation=https://gist.github.com/Rishikant181/e26fb23d4c57db74bddaa0a57b26cd26
After=dbus.service sddm.service
PartOf=sddm.service
[Service]
Type=simple
ExecStart=/usr/local/sbin/steam-session-switch-listener
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target

View File

@@ -36,6 +36,9 @@ packages:
- openssh-server
- build-essential
- network-manager
- bluetooth
- bluez
- bluez-tools
- linux-firmware
- initramfs-tools
- kmod
@@ -132,6 +135,7 @@ actions:
set -eux
systemctl enable grow-rootfs.service
systemctl enable systemd-resolved
systemctl enable bluetooth.service
mkdir -p /boot/efi
# Disable kdump postinst hook — it fails in chroot (can't resolve root device)
rm -f /etc/kernel/postinst.d/kdump-tools

View File

@@ -9,6 +9,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
parted dosfstools e2fsprogs kmod \
initramfs-tools fdisk gdisk udev kpartx \
xz-utils bzip2 zstd \
python3 \
&& rm -rf /var/lib/apt/lists/*
# Install Go (Ubuntu 24.04 only has 1.22, distrobuilder needs newer)
@@ -26,6 +27,19 @@ RUN mkdir -p /root/go/src/github.com/lxc/ && \
cp /root/go/bin/distrobuilder /usr/local/bin/ && \
rm -rf /root/go/src /root/go/pkg
# Fedora 41+ base images ship as OCI archives; distrobuilder's fedora-http
# downloader unpacks them with umoci/skopeo. Ubuntu's apt umoci (0.4.7) is
# built with an old Go that crashes under qemu on the self-hosted arm64 runner
# ("fatal error: lfstack.push invalid packing"), because the emulated amd64
# process inherits the host's 52-bit-VA high addresses. Build umoci from source
# with the Go toolchain installed above (>=1.21 packs high addresses correctly).
RUN apt-get update && apt-get install -y --no-install-recommends \
skopeo \
&& rm -rf /var/lib/apt/lists/*
RUN go install github.com/opencontainers/umoci/cmd/umoci@v0.5.0 && \
cp /root/go/bin/umoci /usr/local/bin/umoci && \
rm -rf /root/go/pkg
WORKDIR /build
COPY docker/image-builder/entrypoint.sh /entrypoint.sh

View File

@@ -5,7 +5,7 @@ set -ex
IMG_SIZE="${IMG_SIZE:-32000}"
SKIP_CHROOT="${SKIP_CHROOT:-false}"
DISTROS="${DISTROS:-ubuntu2604 arch alpine cachyos}"
DISTROS="${DISTROS:-ubuntu2604 arch cachyos}"
STAGING="/tmp/build-staging"
EFI_LABEL="boot"
IMG="/output/ps5-multi.img"
@@ -42,10 +42,6 @@ for DISTRO in $DISTROS; do
cp /repo/distros/${DISTRO}/grow-rootfs.service "$STAGING/"
cp /kernel-debs/*.deb "$STAGING/debs/"
;;
alpine)
cp /repo/distros/${DISTRO}/grow-rootfs "$STAGING/"
cp /repo/distros/alpine/grow-rootfs.openrc "$STAGING/"
;;
arch)
cp /repo/distros/${DISTRO}/grow-rootfs "$STAGING/"
cp /repo/distros/arch/grow-rootfs.service "$STAGING/"
@@ -87,75 +83,6 @@ for DISTRO in $DISTROS; do
echo "$DISTRO" > "$CHROOT/etc/ps5-distro"
fi
# --- Alpine kernel gap: no kernel installed via image.yaml ---
# This runs even with --skip-chroot because Alpine's rootfs never includes a kernel;
# we must always extract it from the .deb artifacts and generate an initrd.
if [ "$DISTRO" = "alpine" ]; then
echo "=== Alpine: installing kernel from .deb artifacts ==="
# Extract modules + vmlinuz from the linux-image .deb
ALPINE_STAGING="/tmp/alpine-kernel-staging"
rm -rf "$ALPINE_STAGING"
mkdir -p "$ALPINE_STAGING"
for deb in /kernel-debs/linux-image-*.deb; do
[ -f "$deb" ] || continue
dpkg-deb -x "$deb" "$ALPINE_STAGING"
done
# Identify kernel version from the extracted .deb before copying
KVER=$(ls -1 "$ALPINE_STAGING/lib/modules" 2>/dev/null | head -1)
if [ -n "$KVER" ]; then
# Resolve the real modules path inside the chroot.
# Alpine may use usr-merge (/lib -> usr/lib), so we must follow
# symlinks to find the actual directory on disk.
if [ -L "$CHROOT/lib" ]; then
MODDIR="$CHROOT/usr/lib/modules"
else
MODDIR="$CHROOT/lib/modules"
fi
mkdir -p "$MODDIR"
# Remove any stale modules from a previous build
rm -rf "$MODDIR/$KVER"
cp -a "$ALPINE_STAGING/lib/modules/$KVER" "$MODDIR/"
mkdir -p "$CHROOT/boot"
cp "$ALPINE_STAGING/boot/vmlinuz-$KVER" "$CHROOT/boot/vmlinuz-$KVER"
echo ">> Alpine: modules copied to $MODDIR/$KVER"
ls -la "$MODDIR/"
fi
rm -rf "$ALPINE_STAGING"
if [ -n "$KVER" ]; then
echo "=== Alpine: generating initrd ==="
chroot "$CHROOT" depmod -a "$KVER" 2>/dev/null || true
# Bind-mount essentials and run mkinitfs inside the alpine chroot
mount --bind /dev "$CHROOT/dev"
mount --bind /proc "$CHROOT/proc"
mount --bind /sys "$CHROOT/sys"
chroot "$CHROOT" mkinitfs -k "$KVER" -o "/boot/initrd.img-$KVER" "$KVER" || true
umount "$CHROOT/sys" "$CHROOT/proc" "$CHROOT/dev"
# Populate /boot/efi/ for boot partition assembly
mkdir -p "$CHROOT/boot/efi"
cp "$CHROOT/boot/vmlinuz-$KVER" "$CHROOT/boot/efi/bzImage"
# mkinitfs may output as initramfs-<flavor> — find whatever was generated
if [ -f "$CHROOT/boot/initrd.img-$KVER" ]; then
cp "$CHROOT/boot/initrd.img-$KVER" "$CHROOT/boot/efi/initrd.img"
else
# mkinitfs default output: /boot/initramfs-vanilla or similar
INITRD=$(ls -1t "$CHROOT"/boot/initramfs-* "$CHROOT"/boot/initrd* 2>/dev/null | head -1)
if [ -n "$INITRD" ]; then
cp "$INITRD" "$CHROOT/boot/efi/initrd.img"
else
echo "WARNING: No initrd found for alpine after mkinitfs"
fi
fi
echo ">> Alpine: kernel $KVER staged to boot/efi/"
else
echo "WARNING: No kernel modules found in .deb for alpine, skipping initrd generation"
fi
fi
done
# ======================================================================
@@ -259,9 +186,6 @@ for DISTRO in $DISTROS; do
cp "$EFIDIR/initrd.img" "/tmp/mnt_boot/initrd-${DISTRO}.img"
elif [ -n "$KVER" ] && [ -f "$BOOTDIR/initrd.img-$KVER" ]; then
cp "$BOOTDIR/initrd.img-$KVER" "/tmp/mnt_boot/initrd-${DISTRO}.img"
elif [ -f "$BOOTDIR/initramfs-vanilla" ]; then
# Alpine mkinitfs names its output initramfs-vanilla
cp "$BOOTDIR/initramfs-vanilla" "/tmp/mnt_boot/initrd-${DISTRO}.img"
fi
# Clean up /boot/efi contents from the rootfs (they're on the boot partition now)

View File

@@ -10,8 +10,34 @@ EFI_LABEL="boot"
CHROOT="/build/chroot"
IMG="/output/ps5-${DISTRO}.img"
# Detect kernel version from the staged kernel package (used by bazzite +
# batocera rootfs builders which install the linux-ps5 kernel manually).
detect_kver() {
for f in /kernel-debs/linux-ps5-*.rpm /kernel-debs/linux-ps5_*.deb \
/kernel-debs/linux-ps5-*.pkg.tar.zst; do
[ -f "$f" ] || continue
echo "$(basename "$f")" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1
return 0
done
}
KVER="$(detect_kver)"
[ -n "$KVER" ] || { echo "WARN: could not detect KVER from /kernel-debs/"; }
export KVER
if [ "$SKIP_CHROOT" = "true" ] && [ -d "$CHROOT/bin" ]; then
echo "=== Reusing cached $DISTRO rootfs ==="
elif [ -x "/repo/distros/${DISTRO}/build-rootfs.sh" ]; then
# Distros with their own build-rootfs.sh (bazzite, bazzite-deck, batocera, ...)
# are responsible for populating $CHROOT themselves — distrobuilder is
# bypassed entirely. Most often used for OCI atomic images (bazzite via
# skopeo+umoci) or pre-built rootfs images (batocera squashfs).
echo "=== Building $DISTRO rootfs via distros/${DISTRO}/build-rootfs.sh ==="
[ -n "$CHROOT" ] || { echo "FATAL: \$CHROOT empty"; exit 1; }
mkdir -p "$CHROOT"
rm -rf "$CHROOT"/* "$CHROOT"/.[!.]* 2>/dev/null || true
DISTRO="$DISTRO" CHROOT="$CHROOT" KVER="$KVER" \
ROOT_LABEL="$ROOT_LABEL" EFI_LABEL="$EFI_LABEL" \
bash "/repo/distros/${DISTRO}/build-rootfs.sh"
else
echo "=== Building $DISTRO rootfs ==="
# --- Stage files for distrobuilder's copy generators ---
@@ -34,9 +60,22 @@ EOF
cp /repo/distros/${DISTRO}/grow-rootfs.service "$STAGING/"
cp /kernel-debs/*.deb "$STAGING/debs/"
;;
alpine)
proxmox)
cp /repo/distros/${DISTRO}/grow-rootfs "$STAGING/"
cp /repo/distros/${DISTRO}/grow-rootfs.service "$STAGING/"
cp /repo/distros/${DISTRO}/proxmox-release-bookworm.gpg "$STAGING/"
cp /kernel-debs/*.deb "$STAGING/debs/"
;;
debian)
cp /repo/distros/${DISTRO}/grow-rootfs "$STAGING/"
cp /repo/distros/${DISTRO}/grow-rootfs.service "$STAGING/"
cp /kernel-debs/*.deb "$STAGING/debs/"
;;
fedora)
cp /repo/distros/${DISTRO}/grow-rootfs "$STAGING/"
cp /repo/distros/alpine/grow-rootfs.openrc "$STAGING/"
cp /repo/distros/${DISTRO}/grow-rootfs.service "$STAGING/"
mkdir -p "$STAGING/rpms"
cp /kernel-debs/*.rpm "$STAGING/rpms/"
;;
arch)
cp /repo/distros/${DISTRO}/grow-rootfs "$STAGING/"
@@ -62,6 +101,12 @@ EOF
;;
esac
find "$STAGING" -type f \
! -path "$STAGING/debs/*" \
! -path "$STAGING/pkgs/*" \
! -path "$STAGING/rpms/*" \
-exec sed -i 's/\r$//' {} +
# --- Build rootfs ---
rm -rf "$CHROOT"/* "$CHROOT"/.[!.]* 2>/dev/null || true
@@ -77,69 +122,12 @@ case "$DISTRO" in
;;
esac
# --- Alpine kernel gap: no kernel installed via image.yaml ---
# Extract kernel from .deb, copy modules + bzImage, then chroot to run mkinitfs
if [ "$DISTRO" = "alpine" ]; then
echo "=== Alpine: installing kernel from .deb artifacts ==="
ALPINE_STAGING="/tmp/alpine-kernel-staging"
rm -rf "$ALPINE_STAGING"
mkdir -p "$ALPINE_STAGING"
for deb in /kernel-debs/linux-image-*.deb; do
[ -f "$deb" ] || continue
dpkg-deb -x "$deb" "$ALPINE_STAGING"
done
KVER=$(ls -1 "$ALPINE_STAGING/lib/modules" 2>/dev/null | head -1)
if [ -n "$KVER" ]; then
# Resolve the real modules path (Alpine may use usr-merge: /lib -> usr/lib)
if [ -L "$CHROOT/lib" ]; then
MODDIR="$CHROOT/usr/lib/modules"
else
MODDIR="$CHROOT/lib/modules"
fi
mkdir -p "$MODDIR"
rm -rf "$MODDIR/$KVER"
cp -a "$ALPINE_STAGING/lib/modules/$KVER" "$MODDIR/"
mkdir -p "$CHROOT/boot"
cp "$ALPINE_STAGING/boot/vmlinuz-$KVER" "$CHROOT/boot/vmlinuz-$KVER"
echo ">> Alpine: modules copied to $MODDIR/$KVER"
fi
rm -rf "$ALPINE_STAGING"
if [ -n "$KVER" ]; then
chroot "$CHROOT" depmod -a "$KVER" 2>/dev/null || true
mount --bind /dev "$CHROOT/dev"
mount --bind /proc "$CHROOT/proc"
mount --bind /sys "$CHROOT/sys"
chroot "$CHROOT" mkinitfs -k "$KVER" -o "/boot/initrd.img-$KVER" "$KVER" || true
umount "$CHROOT/sys" "$CHROOT/proc" "$CHROOT/dev"
# Populate /boot/efi/ for boot partition assembly
mkdir -p "$CHROOT/boot/efi"
cp "$CHROOT/boot/vmlinuz-$KVER" "$CHROOT/boot/efi/bzImage"
if [ -f "$CHROOT/boot/initrd.img-$KVER" ]; then
cp "$CHROOT/boot/initrd.img-$KVER" "$CHROOT/boot/efi/initrd.img"
else
INITRD=$(ls -1t "$CHROOT"/boot/initramfs-* "$CHROOT"/boot/initrd* 2>/dev/null | head -1)
if [ -n "$INITRD" ]; then
cp "$INITRD" "$CHROOT/boot/efi/initrd.img"
else
echo "WARNING: No initrd found for alpine after mkinitfs"
fi
fi
echo ">> Alpine: kernel $KVER staged to boot/efi/"
else
echo "WARNING: No kernel modules found in .deb for alpine"
fi
fi
# --- Create GPT disk image ---
echo "=== Creating ${IMG_SIZE}MB disk image ==="
TMPIMG="/build/ps5-${DISTRO}.img"
dd if=/dev/zero of="$TMPIMG" bs=1M count=$IMG_SIZE conv=fsync status=progress
TMPIMG="/output/.ps5-${DISTRO}.img.tmp"
rm -f "$TMPIMG"
truncate -s "${IMG_SIZE}M" "$TMPIMG"
sync
parted -s "$TMPIMG" mklabel gpt
parted -s "$TMPIMG" mkpart primary ext4 500MiB 100%
@@ -177,8 +165,20 @@ cp -a "$CHROOT"/* /tmp/usb_root/
sync
echo "=== Assembling boot partition ==="
mv /tmp/usb_root/boot/efi/* /tmp/usb_efi/ 2>/dev/null || true
sed "s|__DISTRO__|$ROOT_LABEL|" /repo/boot/cmdline.txt > /tmp/usb_efi/cmdline.txt
# Batocera mounts the FAT32 partition at /boot (not /boot/efi) so its
# batocera-part SHARE auto-detection works (greps /proc/mounts for /boot).
# Detect either layout and copy from the right place.
if [ -d /tmp/usb_root/boot/efi ] && [ -n "$(ls -A /tmp/usb_root/boot/efi 2>/dev/null)" ]; then
mv /tmp/usb_root/boot/efi/* /tmp/usb_efi/ 2>/dev/null || true
elif [ -f /tmp/usb_root/boot/bzImage ]; then
# batocera-style: bzImage + cmdline + everything else lives directly
# in /boot. Move everything into the EFI partition so the PS5 loader
# can find bzImage/initrd.img/cmdline.txt at the FAT root.
mv /tmp/usb_root/boot/* /tmp/usb_efi/ 2>/dev/null || true
fi
CMDLINE_TEMPLATE="/repo/distros/${DISTRO}/cmdline.txt"
[ -f "$CMDLINE_TEMPLATE" ] || CMDLINE_TEMPLATE="/repo/boot/cmdline.txt"
sed "s|__DISTRO__|$ROOT_LABEL|" "$CMDLINE_TEMPLATE" > /tmp/usb_efi/cmdline.txt
cp /repo/boot/vram.txt /tmp/usb_efi/
cp /repo/boot/kexec.sh /tmp/usb_efi/
sync

View File

@@ -28,10 +28,20 @@ cp /out/staging/.config "$STAGING/boot/config-$KVER"
mkdir -p "$STAGING/usr/lib/modules"
cp -a "/out/staging/lib/modules/$KVER" "$STAGING/usr/lib/modules/"
# Userspace bits the kernel-builder staged at /out/staging/{etc,usr/local}:
# ps5-stage-firmware service + script (bridges nxp blob from /boot/efi),
# ps5-iw620 modprobe.d options. Without these the WLAN driver loads but
# request_firmware() returns -2 and wlan_pcie probe fails.
for d in etc usr/local; do
[ -d "/out/staging/$d" ] && { mkdir -p "$STAGING/$d"; cp -a "/out/staging/$d/." "$STAGING/$d/"; }
done
# Kernel headers (for out-of-tree module builds)
if [ -d /out/staging/headers ]; then
# UAPI headers (/usr/include/linux/, /usr/include/asm/, etc.)
cp -a /out/staging/headers/usr "$STAGING/usr"
# $STAGING/usr already exists (modules were copied there), so merge
# contents instead of creating a nested usr/usr/include/ directory.
cp -a /out/staging/headers/usr/. "$STAGING/usr/"
# Build headers (/usr/lib/modules/$KVER/build/)
mkdir -p "$STAGING/usr/lib/modules/$KVER"
cp -a /out/staging/headers/lib/modules/$KVER/build "$STAGING/usr/lib/modules/$KVER/build"

View File

@@ -0,0 +1,15 @@
FROM --platform=linux/amd64 fedora:44
# Only packaging tools needed — kernel is already compiled
RUN dnf install -y rpm-build kmod && dnf clean all
WORKDIR /out/staging
# Pre-built artifacts are bind-mounted at /out/staging
# Output packages go to /out
COPY docker/kernel-builder-rpm/build.sh /build.sh
COPY docker/kernel-builder-rpm/linux-ps5.spec /linux-ps5.spec
RUN chmod +x /build.sh
CMD ["/build.sh"]

View File

@@ -0,0 +1,63 @@
#!/bin/bash
# Packages pre-built kernel artifacts as an rpm.
# Runs inside Docker as root.
# Expects build artifacts staged in /out/staging by the kernel build step.
set -e
if [ ! -f /out/staging/boot/bzImage ]; then
echo "Error: no staged artifacts in /out/staging — run the kernel build step first"
exit 1
fi
# Determine version from staged modules directory
KVER=$(ls /out/staging/lib/modules/)
PKGNAME="linux-ps5"
VERSION=${KVER%%-*}
echo "==> Packaging kernel $KVER as rpm"
RPMROOT=$(mktemp -d)
STAGE="$RPMROOT/stage"
# Copy staged boot artifacts
mkdir -p "$STAGE/boot"
cp /out/staging/boot/bzImage "$STAGE/boot/vmlinuz-$KVER"
cp /out/staging/System.map "$STAGE/boot/System.map-$KVER"
cp /out/staging/.config "$STAGE/boot/config-$KVER"
# Copy pre-installed modules (Fedora uses /usr/lib/modules)
mkdir -p "$STAGE/usr/lib/modules"
cp -a "/out/staging/lib/modules/$KVER" "$STAGE/usr/lib/modules/"
# Build headers for out-of-tree module builds. UAPI headers (/usr/include)
# are intentionally excluded — they would conflict with Fedora's
# kernel-headers package.
if [ -d "/out/staging/headers/lib/modules/$KVER/build" ]; then
cp -a "/out/staging/headers/lib/modules/$KVER/build" \
"$STAGE/usr/lib/modules/$KVER/build"
fi
# Userspace bits staged by kernel-builder at /out/staging/{etc,usr/local}:
# ps5-stage-firmware.service + ps5-bt-quiet.service + their /usr/local/sbin
# helpers + /etc/modprobe.d/moal.conf + /etc/modules-load.d/moal. Without
# these, moal loads but request_firmware() returns -2 (firmware still on
# ESP, never staged) and there's no boot-time BT phantom-hci cleanup. The
# .deb and .pkg.tar.zst packagers already copy these; this was the missing
# symmetric step on the rpm side.
for d in etc usr/local; do
if [ -d "/out/staging/$d" ]; then
mkdir -p "$STAGE/$d"
cp -a "/out/staging/$d/." "$STAGE/$d/"
fi
done
rpmbuild -bb \
--define "_topdir $RPMROOT/rpmbuild" \
--define "stagedir $STAGE" \
--define "kver $KVER" \
--define "ver $VERSION" \
/linux-ps5.spec
cp "$RPMROOT/rpmbuild/RPMS/x86_64/${PKGNAME}-${VERSION}-1.x86_64.rpm" /out/
echo "==> Done: /out/${PKGNAME}-${VERSION}-1.x86_64.rpm"

View File

@@ -0,0 +1,62 @@
# Binary repackaging of pre-built kernel artifacts — no compilation here.
# Invoked by build.sh with: stagedir, kver, ver defined.
%global debug_package %{nil}
%global _build_id_links none
%global __os_install_post %{nil}
%global __strip /bin/true
Name: linux-ps5
Version: %{ver}
Release: 1
Summary: PS5 Linux kernel %{kver} (image + modules + headers)
License: GPL-2.0-only
URL: https://kernel.org
ExclusiveArch: x86_64
AutoReqProv: no
Provides: kernel = %{ver}
Provides: kernel-core = %{ver}
Provides: kernel-modules = %{ver}
Provides: kernel-devel = %{ver}
%description
Linux kernel %{kver} with PlayStation 5 support patches
(https://github.com/ps5-linux/ps5-linux-patches).
%install
cp -a %{stagedir}/. %{buildroot}/
%files
/boot/vmlinuz-%{kver}
/boot/System.map-%{kver}
/boot/config-%{kver}
/usr/lib/modules/%{kver}
%config(noreplace) /etc/modprobe.d/moal.conf
%config(noreplace) /etc/modules-load.d/moal
/etc/systemd/system/ps5-stage-firmware.service
/etc/systemd/system/ps5-bt-quiet.service
/etc/systemd/system/sysinit.target.wants/ps5-stage-firmware.service
/etc/systemd/system/multi-user.target.wants/ps5-bt-quiet.service
/usr/local/sbin/ps5-stage-firmware
/usr/local/sbin/ps5-bt-quiet
%post
echo ">> linux-ps5 post-install: kernel %{kver}"
depmod -a %{kver} || true
# Rebuild initramfs (hardware-independent — the build host is not the PS5)
if command -v dracut >/dev/null 2>&1; then
echo ">> Rebuilding initramfs with dracut for %{kver}"
dracut --force --no-hostonly "/boot/initrd.img-%{kver}" %{kver}
fi
# Copy kernel + initrd to EFI partition
if [ -d /boot/efi ]; then
echo ">> Copying /boot/vmlinuz-%{kver} -> /boot/efi/bzImage"
cp "/boot/vmlinuz-%{kver}" /boot/efi/bzImage
echo ">> Copying /boot/initrd.img-%{kver} -> /boot/efi/initrd.img"
cp "/boot/initrd.img-%{kver}" /boot/efi/initrd.img
echo ">> Kernel %{kver} deployed to /boot/efi"
else
echo ">> /boot/efi not found, skipping EFI deploy"
fi

View File

@@ -4,9 +4,9 @@ ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential bc bison dwarves flex kmod cpio gawk \
libdw-dev libelf-dev libssl-dev \
libdw-dev libelf-dev libssl-dev zlib1g-dev \
python3 rsync dpkg-dev debhelper zstd \
perl git xz-utils ccache \
perl git ca-certificates xz-utils ccache \
gcc-x86-64-linux-gnu binutils-x86-64-linux-gnu libc6-dev-amd64-cross \
&& rm -rf /var/lib/apt/lists/*

View File

@@ -34,4 +34,129 @@ make headers_install INSTALL_HDR_PATH="$HDR/usr"
export srctree=/src SRCARCH=x86
CC="${CROSS_COMPILE}gcc" HOSTCC=gcc MAKE=make /src/scripts/package/install-extmod-build "$HDR/lib/modules/$KVER/build"
# ---- NXP IW620 mwifiex driver (the PS5's onboard wifi) ----
# Build the out-of-tree moal+mlan modules against the kernel we just built
# and stage them into /out/staging so they get bundled in linux-ps5.{deb,rpm,
# pkg.tar.zst}. Without this, users get no wifi until they manually run
# ps5-linux-mwifiex/install.sh on the target (which needs gcc + headers).
echo "=== Building NXP IW620 mwifiex driver ==="
MWIFIEX_REPO="${MWIFIEX_REPO:-https://github.com/ps5-linux/ps5-linux-mwifiex.git}"
MWIFIEX_REF="${MWIFIEX_REF:-main}"
MWIFIEX_NXP_REPO=https://github.com/nxp-imx/mwifiex.git
MWIFIEX_NXP_REF=lf-6.18.2_1.0.0
PS5_MW=/tmp/ps5-linux-mwifiex
NXP_MW=/tmp/nxp-mwifiex
rm -rf "$PS5_MW" "$NXP_MW"
git clone "$MWIFIEX_REPO" "$PS5_MW"
git -C "$PS5_MW" checkout "$MWIFIEX_REF" 2>/dev/null || true
git clone --depth 1 --branch "$MWIFIEX_NXP_REF" "$MWIFIEX_NXP_REPO" "$NXP_MW"
git -C "$NXP_MW" apply "$PS5_MW/ps5-iw620.patch"
git -C "$NXP_MW" apply "$PS5_MW/ps5-iw620-cmd-timeout-recover.patch"
git -C "$NXP_MW" apply "$PS5_MW/ps5-iw620-kernel71-compat.patch"
# Build against the kernel source we just built (out-of-tree build needs
# the in-tree build dir, not just headers; same as install-extmod-build).
make -C "$NXP_MW" CONFIG_OBJTOOL= KERNELDIR=/src ARCH=x86 -j"$(nproc)"
[ -f "$NXP_MW/mlan.ko" ] && [ -f "$NXP_MW/moal.ko" ] \
|| { echo "ERROR: mwifiex build did not produce mlan.ko/moal.ko"; exit 1; }
EXTRA_DIR="/out/staging/lib/modules/$KVER/extra/ps5-iw620"
mkdir -p "$EXTRA_DIR"
install -m 0644 "$NXP_MW/mlan.ko" "$EXTRA_DIR/mlan.ko"
install -m 0644 "$NXP_MW/moal.ko" "$EXTRA_DIR/moal.ko"
# Modprobe + modules-load.d so the driver auto-loads at boot.
mkdir -p /out/staging/etc/modprobe.d /out/staging/etc/modules-load.d
echo moal > /out/staging/etc/modules-load.d/moal
cat > /out/staging/etc/modprobe.d/moal.conf <<'MPCONF'
# PS5 IW620 mwifiex (NXP moal/mlan, built out-of-tree by kernel-builder).
softdep moal pre: cfg80211 mlan
options moal fw_name=nxp/pcieuartiw620_combo_v1.bin pcie_int_mode=1 drv_mode=1 cfg80211_wext=4 sta_name=mlan ext_scan=1 auto_fw_reload=0 wifi_reset_config=0 sched_scan=0 ps_mode=2 auto_ds=2 amsdu_disable=1
MPCONF
# Rebuild module index so modprobe moal works without depmod -a post-install.
depmod -b /out/staging "$KVER"
# ---- PS5 runtime helpers packaged with the kernel ----
mkdir -p /out/staging/usr/local/sbin
mkdir -p /out/staging/etc/systemd/system/sysinit.target.wants
mkdir -p /out/staging/etc/systemd/system/multi-user.target.wants
cat > /out/staging/usr/local/sbin/ps5-stage-firmware <<'HELPER'
#!/bin/sh
set -eu
FW=nxp/pcieuartiw620_combo_v1.bin
if [ -f /run/ostree-booted ]; then
DST_DIR=/etc/firmware
echo "$DST_DIR" > /sys/module/firmware_class/parameters/path 2>/dev/null || true
else
DST_DIR=/lib/firmware
fi
DST=$DST_DIR/$FW
[ -e "$DST" ] || for d in /efi /boot/efi /boot; do
if [ -f "$d/lib/$FW" ]; then
install -Dm644 "$d/lib/$FW" "$DST"
break
fi
done
modprobe -r moal mlan 2>/dev/null || true
modprobe moal 2>/dev/null || true
HELPER
chmod +x /out/staging/usr/local/sbin/ps5-stage-firmware
cat > /out/staging/etc/systemd/system/ps5-stage-firmware.service <<'UNIT'
[Unit]
Description=Stage PS5 NXP IW620 wifi firmware from EFI partition
After=local-fs.target
Before=systemd-modules-load.service network-pre.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/ps5-stage-firmware
RemainAfterExit=yes
[Install]
WantedBy=sysinit.target
UNIT
ln -sf ../ps5-stage-firmware.service \
/out/staging/etc/systemd/system/sysinit.target.wants/ps5-stage-firmware.service
cat > /out/staging/usr/local/sbin/ps5-bt-quiet <<'HELPER'
#!/bin/sh
for i in $(seq 1 15); do
[ -n "$(ls /sys/class/bluetooth/hci* 2>/dev/null)" ] && break
sleep 1
done
for h in /sys/class/bluetooth/hci*; do
[ -e "$h/address" ] || continue
idx=$(basename "$h" | sed 's/hci//')
addr=$(cat "$h/address")
if [ "$addr" = "00:00:00:00:00:00" ]; then
hciconfig hci$idx down 2>/dev/null || btmgmt -i $idx power off 2>/dev/null || true
else
bluetoothctl -- select "$addr" >/dev/null 2>&1 || true
bluetoothctl -- power on >/dev/null 2>&1 || true
fi
done
HELPER
chmod +x /out/staging/usr/local/sbin/ps5-bt-quiet
cat > /out/staging/etc/systemd/system/ps5-bt-quiet.service <<'UNIT'
[Unit]
Description=PS5: silence broken hciN + power the working one
After=bluetooth.service
Wants=bluetooth.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/ps5-bt-quiet
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
UNIT
ln -sf ../ps5-bt-quiet.service \
/out/staging/etc/systemd/system/multi-user.target.wants/ps5-bt-quiet.service
echo "=== Build artifacts staged in /out/staging ==="

View File

@@ -18,9 +18,20 @@ cp "$STAGING/System.map" "$PKG/boot/System.map-$KVER"
cp "$STAGING/.config" "$PKG/boot/config-$KVER"
cp -a "$STAGING/lib/modules/$KVER" "$PKG/lib/modules/"
# Userspace bits the kernel-builder staged at /out/staging/{etc,usr/local}:
# ps5-stage-firmware service + script (bridges nxp blob from /boot/efi),
# ps5-iw620 modprobe.d options. Without these the WLAN driver loads but
# request_firmware() returns -2 and wlan_pcie probe fails.
for d in etc usr/local; do
[ -d "$STAGING/$d" ] && { mkdir -p "$PKG/$d"; cp -a "$STAGING/$d/." "$PKG/$d/"; }
done
# Kernel headers (for out-of-tree module builds)
if [ -d "$STAGING/headers" ]; then
cp -a "$STAGING/headers/usr" "$PKG/usr"
# $PKG/usr may already exist from other staged files, so merge contents
# instead of creating a nested usr/usr/include/ directory.
mkdir -p "$PKG/usr"
cp -a "$STAGING/headers/usr/." "$PKG/usr/"
# Point /lib/modules/$KVER/build at the installed headers
HDR_DEST="/usr/lib/modules/$KVER/build"
mkdir -p "$PKG/usr/lib/modules/$KVER"