diff --git a/build_image.sh b/build_image.sh index 8cbdbeb..14d2013 100755 --- a/build_image.sh +++ b/build_image.sh @@ -17,7 +17,7 @@ usage() { echo "Usage: $0 [--distro ] [--kernel ] [--img-size ] [--clean]" echo "" echo "Options:" - echo " --distro Distribution to build: ubuntu2604, arch, cachyos, fedora, proxmox, debian, 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" @@ -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" ;; fedora) FORMAT="rpm" ;; all) FORMAT="all" ;; *) FORMAT="deb" ;; esac + case "$DISTRO" in + arch|cachyos) FORMAT="arch" ;; + fedora|bazzite*) FORMAT="rpm" ;; + all) FORMAT="all" ;; + *) FORMAT="deb" ;; + esac fi KERNEL_BUILDER_PLATFORM="linux/amd64" diff --git a/distros/batocera/README.md b/distros/batocera/README.md new file mode 100644 index 0000000..cd1027c --- /dev/null +++ b/distros/batocera/README.md @@ -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). diff --git a/distros/batocera/build-rootfs.sh b/distros/batocera/build-rootfs.sh new file mode 100755 index 0000000..4cd645b --- /dev/null +++ b/distros/batocera/build-rootfs.sh @@ -0,0 +1,346 @@ +#!/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 + +# 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. +BATOCERA_VER="${BATOCERA_VER:-43}" +BATOCERA_BUILD="${BATOCERA_BUILD:-20260430}" +BATOCERA_URL="${BATOCERA_URL:-https://mirrors.o2switch.fr/batocera/x86_64/stable/last/batocera-x86_64-${BATOCERA_VER}-${BATOCERA_BUILD}.img.gz}" + +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" < "$CHROOT/etc/modules-load.d/uinput.conf" < /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" < "$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" < 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 + +# 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" +# 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 + # 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 < /etc/modprobe.d/ps5-amdgpu.conf </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= (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 < /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 < /home/ps5/Desktop/Switch-to-Gamescope.desktop </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 diff --git a/distros/bazzite/grow-rootfs b/distros/bazzite/grow-rootfs new file mode 100755 index 0000000..daaf31e --- /dev/null +++ b/distros/bazzite/grow-rootfs @@ -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 diff --git a/distros/bazzite/grow-rootfs.service b/distros/bazzite/grow-rootfs.service new file mode 100644 index 0000000..7937ff4 --- /dev/null +++ b/distros/bazzite/grow-rootfs.service @@ -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 diff --git a/distros/bazzite/image.yaml b/distros/bazzite/image.yaml new file mode 100644 index 0000000..8f78e0c --- /dev/null +++ b/distros/bazzite/image.yaml @@ -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 diff --git a/docker/image-builder/entrypoint.sh b/docker/image-builder/entrypoint.sh index 0d5defe..4b8b024 100755 --- a/docker/image-builder/entrypoint.sh +++ b/docker/image-builder/entrypoint.sh @@ -10,8 +10,30 @@ 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 ===" + rm -rf "$CHROOT"/* "$CHROOT"/.[!.]* 2>/dev/null || true + bash "/repo/distros/${DISTRO}/build-rootfs.sh" else echo "=== Building $DISTRO rootfs ===" # --- Stage files for distrobuilder's copy generators --- @@ -139,7 +161,17 @@ cp -a "$CHROOT"/* /tmp/usb_root/ sync echo "=== Assembling boot partition ===" -mv /tmp/usb_root/boot/efi/* /tmp/usb_efi/ 2>/dev/null || true +# 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