alarm-tegra-x1 / patches/README.md

raw file 16411 bytes
# patches

Everything needed to make **Arch Linux ARM boot and be usable on the Nintendo
Switch**, on top of what `alarm-build` already produces.

## Why patches are needed at all

The only kernel with working Switch display support is switchroot's **L4T
4.9.140**. Current Arch Linux ARM ships **systemd 261**, whose baseline is
kernel 4.15. systemd 261 also removed cgroup v1 support and offers no
boot-argument escape hatch, so it cannot start on 4.9.140:

```
systemd[1]: Failed to mount early API filesystems
...
No working init found  (or a systemd boot that never reaches a login)
```

The fix taken here is to **not run systemd**: the initramfs hands PID 1 to a
small shell script. The system boots, networking works, and SSH is reachable.
A *proper* fix means a newer Switch-capable kernel (see the project README,
"Azkali/L4S").

## The pieces

| Piece | Now lives in | Regenerated by |
|---|---|---|
| Patched L4T initramfs | `switchroot/ubuntu-jammy/initramfs` | `patches/patch-initramfs.sh` |
| Initramfs source change | `patches/initramfs.patch` | (hand-edited) |
| PID 1 (`/sbin/init`) | `rootfs/sbin-init` → `src/rootfs.rs` | `alarm-build rootfs` |
| PID 1 script (`/etc/alarm-init.sh`) | `rootfs/alarm-init.sh` → `src/rootfs.rs` | `alarm-build rootfs` |
| hekate entry (rootdev, usb_logging, …) | `src/payload.rs` | `alarm-build payload` |
| Mainline boot script (`rdinit`, `rootwait`) | `device-tree/boot-mainline.cmd` | `mkimage` + `alarm-build payload` |
| Edits to *upstream* kernel files (boot markers, DSI hotplug, touchscreen) | `patches/kernel-*.patch` | hand-edit the tree, then `git diff` |
| Joy-Con rails driver (switchroot patch 0016) | `drivers/joycon.c` → `src/kernel.rs` | `alarm-build kernel` |
| DNS, `pacman` Landlock, SSH root login | `src/rootfs.rs` | `alarm-build rootfs` |
| Runtime packages (openssh, wpa_supplicant, iw, dhcpcd) | `src/rootfs.rs` | `alarm-build rootfs` |
| WiFi credentials | **not kept** — see `sdcard-snapshot/wpa_supplicant.conf.template` | manual (secret) |
| Exact files as they are on the working card | `sdcard-snapshot/` | — |

## 1. The initramfs

The initramfs runs *before* the root filesystem exists, so it cannot be fixed
from the Arch side — it has to be patched as a binary asset.

`switchroot/ubuntu-jammy/initramfs.orig` is the pristine switchroot file;
`patch-initramfs.sh` turns it into the patched one:

```sh
patches/patch-initramfs.sh                 # -> switchroot/ubuntu-jammy/initramfs
```

What `initramfs.patch` changes, and why:

- **`fsck -y` → `fsck.ext4 -yD`** — the initramfs's `fsck` does not repair the
  ext4 filesystem properly, leaving it unmountable.
- **`mount -o noatime` → `mount -t ext4 -o noatime`** — without an explicit
  type the mount fails / misdetects.
- **skip `resize2fs`** — the root filesystem is already the right size; running
  it wasted time and could fail.
- **do not `mount --move` /proc, /sys, /dev** — that is systemd's job. Instead
  copy the essential device nodes, unmount the virtual filesystems, and drop a
  small PID 1 at `/sbin/init`:

  ```sh
  #!/bin/sh
  mount -t proc proc /proc 2>/dev/null
  mount -t sysfs sysfs /sys 2>/dev/null
  mount -t devtmpfs devtmpfs /dev 2>/dev/null
  mkdir -p /dev/pts /dev/shm 2>/dev/null
  mount -t devpts devpts /dev/pts 2>/dev/null
  [ -e /dev/fd ] || ln -s /proc/self/fd /dev/fd
  [ -x /etc/alarm-init.sh ] && exec /etc/alarm-init.sh
  exec /bin/sh
  ```

  This replaces Arch's `/sbin/init` (`→ /usr/lib/systemd/systemd`) for that boot
  only; the root filesystem itself is untouched.
- **disable the initramfs getty on `ttyGS0`** — `/etc/alarm-init.sh` starts its
  own shell there, and two would fight.
- **accept both Tegra XUDC names** — `usb-serial-mode-start` hardcodes the L4T
  device name `700d0000.xudc` when it waits for the USB gadget controller.
  Mainline names the same device after its DT node, `usb@700d0000`, i.e.
  `700d0000.usb`. Without this the script never finds a UDC, logs *"No known UDC
  device found"* and exits — leaving a mainline boot with **no console at all**.

> Repacking pitfall: `init` must be at the **top level** of the cpio archive.
> Repacking with the wrong working directory produces `./root/init` and the
> kernel reports `No working init found`. The script always packs from inside
> the extracted directory.

### Traps found getting a mainline kernel through this initramfs

Every one of these is silent in a different way, and each cost real debugging
time. They are recorded because the initramfs is still shipped (the boot script
loads it) even though the mainline boot path now bypasses it entirely — see
"The mainline boot path" below.

| Trap | What you see | Why |
|---|---|---|
| `tegra_gpio` format mismatch | drops into `exec /bin/bash` and hangs forever | the "forced debug" check parses `/sys/kernel/debug/tegra_gpio`. Mainline's `gpio-tegra.c` creates a file of the **same name** (`debugfs_create_devm_seqfile`, `gpio-tegra.c:655`) with a completely different format, so the parse yields `0` — which the script reads as "both volume buttons held" |
| `ply-image` can block | boot stops with no output at all | the boot logos are drawn by a framebuffer client. With no working framebuffer (`simple-framebuffer` fails with `-22`) it blocks, and it is called unconditionally at several points, including straight after the gadget setup |
| userspace `mount` hangs | stops right after `Checking dev node for errors` | `mount -t ext4 -o noatime /dev/mmcblk0p2 /mnt` hangs **inside the userspace `mount` binary** — the kernel logs nothing, no ext4 message, no error — while the kernel itself mounts the same filesystem fine |
| `fsck.ext4` is a dangling symlink | silently does nothing | it points at `fsck.ext3`, which does not exist in the archive |
| `cut` is missing | `cut: command not found` | used by diagnostics written for a fuller userspace |
| `boot_m` / `boot_p` cmdline args | `mount_boot_disk` finds nothing | the L4T boot script supplies them; ours does not |
| `CONFIG_DEVTMPFS_MOUNT` | `/dev` has no device nodes when `/init` starts | that option only mounts devtmpfs from `prepare_namespace()`, which never runs when the initramfs provides `/init`. The initramfs does mount it itself — but not until line 88, after any early code |

`ply-image` is neutralised with a no-op stub at `/bin/ply-image`, installed by
`patch-initramfs.sh` after the patch is applied. The `tegra_gpio` parse is
removed outright (`vol_btn` is pinned to `3`), because the forced-debug shell is
useless on a machine with no console.

## 2. The mainline boot path (`rdinit=/nonexistent`)

For the mainline kernel the initramfs is **skipped entirely**. The boot script
passes:

```
rdinit=/nonexistent
```

so `kernel_init` falls through to `prepare_namespace()`, the kernel mounts
`/dev/mmcblk0p2` itself, and `/sbin/init` from the root filesystem runs. This is
deliberate: the initramfs's userspace `mount` hangs on that filesystem while the
kernel's own mount works, and bypassing it also sidesteps every trap in the
table above.

`/sbin/init` is therefore **our own PID 1**, installed by `alarm-build rootfs`
from `rootfs/sbin-init` (replacing the image's symlink to
`/usr/lib/systemd/systemd`). It:

- mounts proc, sysfs, devtmpfs and devpts, and sets the hostname;
- builds the USB CDC-ACM gadget on `700d0000.usb` and binds it;
- verifies and, if necessary, forces the padctl port's USB role to `device`;
- publishes its complete state to `ALARM_USERS.txt` on the FAT partition, so a
  boot with no console is still readable on a PC;
- hands over to `/etc/alarm-init.sh`.

`rootfs/alarm-init.sh` then starts the console shell on `ttyGS0`, brings up WiFi
and `sshd`, and keeps a shell alive as PID 1.

Both scripts are real files under `rootfs/`, pulled in with `include_str!`, so
they can be shell-checked (`sh -n`) and read normally instead of being escaped
Rust string literals.

### Reading a boot that has no console

The Switch has no wired UART and the panel scanout is block-linear, so anything
drawn into it renders as speckle. Two channels work:

1. **The FAT partition.** `/sbin/init` and `/etc/alarm-init.sh` write their
   state — mounts, UDC list and state, ttyGS nodes, process list, `dmesg` — to
   `ALARM_USERS.txt` and `ALARM_UP.txt` there, readable on any PC.
2. **ramoops plus a warm reboot.** The kernel logs continuously into the ramoops
   region at `0xB0000000`, which hekate preserves across a warm reset. A panic
   reboots warm via the Tegra PMC and the boot script dumps that region to the
   SD card, where `dd` can read it.

Note the panic path needed fixing before either channel was usable: PSCI's
`SYSTEM_RESET` is non-functional on this hardware, and the Tegra PMC restart
handler was registered at `SYS_OFF_PRIO_LOW` — *behind* PSCI's priority 129 on
the same notifier chain — so every panic simply hung. See the project README.

## 3. The rootfs (`/etc/alarm-init.sh`)

Generated by `alarm-build rootfs` (`src/rootfs.rs`) from `rootfs/alarm-init.sh`.
It starts the console shell on `ttyGS0`, brings up `wlan0` with `wpa_supplicant`
+ `dhcpcd`, starts `sshd`, and keeps a shell alive as PID 1.

The console shell is started as:

```sh
stty -F /dev/ttyGS0 sane
setsid /bin/bash --rcfile /etc/alarm-console.bashrc -i < /dev/ttyGS0 > /dev/ttyGS0 2>&1 &
```

Three things there are deliberate:

- **All three descriptors bound explicitly.** `agetty -n -l /bin/bash ttyGS0`
  produced a prompt but no command output: input reached the shell, but its
  output never came back down the port.
- **`stty sane` first.** Without it a CR-only terminal can leave every command
  unsubmitted, because nothing translates the carriage return into a newline.
- **`--rcfile` disabling bracketed paste.** Bash emits `ESC[?2004h`; minicom
  echoes it partially and the stray fragment gets executed as a bogus command
  (it appears as a `:s` substitution error on connect).

### USB networking (NCM)

`/sbin/init` also presents a **CDC-NCM Ethernet function** alongside the serial
console, so the PC gets a network path to the Switch with **no WiFi and no PCIe
involved**. That is what makes `ssh` — and therefore a headless desktop over
VNC — possible while the display question is still open.

**NCM, not ECM.** ECM sends one Ethernet frame per USB transfer and measured
~33 KiB/s here — roughly a thousandth of what USB 2.0 can do. NCM aggregates many
frames per transfer and is what the host's `cdc_ncm` driver expects. `qmult` is
raised to 10 as well; note it is only writable while the function is *unbound*,
so it is set during gadget setup rather than after the gadget is live (`echo 10 >
.../qmult` on a bound gadget fails with `Device or resource busy`).

The link comes up fully configured, so nothing has to be done by hand each boot:
`usb0` is given `192.168.7.2/24`, a default route via `192.168.7.1`, and
`/etc/resolv.conf`. The host side uses `192.168.7.1/24`:

```sh
# on the PC, once, against whatever interface the NCM function created
sudo ip addr add 192.168.7.1/24 dev <iface>
sudo ip link set <iface> up
ssh [email protected]
```

To give the Switch internet, the PC has to route and NAT — and since UFW is
active, it needs a *route* rule, not just NAT:

```sh
sudo sysctl -w net.ipv4.ip_forward=1
sudo iptables -t nat -A POSTROUTING -o wlan0 -j MASQUERADE
sudo iptables -A FORWARD -i <iface> -o wlan0 -j ACCEPT
sudo iptables -A FORWARD -i wlan0 -o <iface> -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
sudo ufw route allow in on <iface> out on wlan0
```

The kernel side is already built in: `USB_F_NCM`, `USB_U_ETHER` and
`USB_CONFIGFS_NCM` are all `=y`. Note that a build with `ALARM_NO_MODULES=1`
would silently omit them if they were `=m` — the same trap that hid the
host-side `USB_ACM` driver for so long.

`sshd` is already running (`/etc/alarm-init.sh`), but the ALARM image ships a
**locked root account**, so set a password once from the serial console before
expecting SSH to work — see §5.


WiFi is skipped when `/etc/wpa_supplicant/wpa_supplicant.conf` is absent, so an
unconfigured card still boots (serial console only). The build writes only
`wpa_supplicant.conf.example`; copy it and fill in the PSK:

```sh
cp /etc/wpa_supplicant/wpa_supplicant.conf.example /etc/wpa_supplicant/wpa_supplicant.conf
wpa_passphrase "YourSSID" "YourPassphrase" > /etc/wpa_supplicant/wpa_supplicant.conf
```

## 4. The hekate entry

`alarm-build payload` now writes:

```ini
[L4T ALARM]
l4t=1
boot_prefixes=/switchroot/alarm/
id=ALARM
rootdev=mmcblk0p2
rootlabel_retries=1
r2p_action=self
fbconsole=0
bootargs_extra=usb_logging auto_rootdev_disable
```

- `rootdev=mmcblk0p2` pins the root filesystem (SD card, partition 2).
- `usb_logging` creates the USB serial gadget — without it there is no console.
- `auto_rootdev_disable` stops the initramfs from overriding `rootdev`.

## 5. The rootfs build adaptations

`src/rootfs.rs` additionally:

- installs `openssh wpa_supplicant iw dhcpcd` (the ALARM tarball ships none);
- points `/etc/resolv.conf` at real nameservers instead of the dangling
  systemd-resolved symlink;
- comments out `DownloadUser` in `pacman.conf` — systemd 258+ uses Landlock for
  unprivileged downloads, which the 4.9 kernel does not implement, so
  `pacman -Sy` fails without this;
- writes `/etc/ssh/sshd_config.d/10-alarm.conf` allowing root + password login.

Note: the root **password is not set** by the build. The ALARM tarball ships a
locked root account. From the serial console, set one:

```sh
passwd root
```

`passwd` may fail because it routes through PAM (systemd's PAM modules are
absent). If so, write the hash straight into `/etc/shadow`:

```sh
HASH=$(openssl passwd -6 'yourpassword')
cp /etc/shadow /etc/shadow.bak
grep -v '^root:' /etc/shadow > /tmp/shadow.new
echo "root:${HASH}:0:0:99999:7:::" >> /tmp/shadow.new
cat /tmp/shadow.new > /etc/shadow
```

## 6. `sdcard-snapshot/`

The exact files from the working card, for reference:

- `L4T-alarm.ini` — as it was during bring-up (includes `loglevel=8` and
  systemd debug args that are no longer needed).
- `alarm-init.sh` — the hand-written PID 1 script before it was folded into the
  build.
- `wpa_supplicant.conf` — **deliberately not kept.** It holds a live credential,
  and `wpa_passphrase` leaves the plaintext passphrase in a comment above the
  hash. Use `wpa_supplicant.conf.template` as the shape and generate your own;
  `.ndignore` blocks any file of that name from being packed.
- `resolv.conf`, `pacman.conf`, `sshd_config` — the edited system files.
  `sshd_config` allows root + password login, which is a deliberate choice for a
  device reachable only over a USB point-to-point link; harden it if this machine
  ever joins a real network.

## 7. Kernel patches to upstream files

Most of this project's kernel-side work *adds* files — the device tree, the panel
driver, `switch-fb` — and `alarm-build kernel` installs them by copying. But some
changes have to edit an upstream file in place:

| Patch | What it does |
|---|---|
| `kernel-boot-markers.patch` | writes stage markers to `0xB0000000` from `head.S` and `start_kernel()`, so a kernel that dies before its first `printk` still leaves a trace |
| `kernel-display-hotplug.patch` | `tegra_dsi_host_attach()` calls `drm_kms_helper_connector_hotplug_event()` instead of `drm_helper_hpd_irq_event()` |
| `kernel-stmfts-switch.patch` | the three Switch touchscreen quirks — event layout, interrupt unmasking, `input_mt_sync_frame` |

`install_switch_patches()` in `src/kernel.rs` applies every `patches/kernel-*.patch`
to the tree before building, and skips the ones already applied.

The already-applied test is worth knowing, because it is what makes repeat builds
work: it runs `git apply --check --reverse`, which succeeds only when the tree
already contains the change. A patch that applies in *neither* direction is
therefore a bug, not a no-op — and that is exactly how the boot-markers patch was
caught: its `init/main.c` half was in the tree while its `head.S` half had been
partially reverted, leaving a stray blank line where the marker block used to be.

Before this existed, nothing re-applied these patches at all. A re-cloned kernel
tree would have silently lost the DSI hotplug fix and the touchscreen quirks —
the same class of silent failure the rest of this project documents.