diff --git a/.gitea/workflows/build-kernel.yml b/.gitea/workflows/build-kernel.yml index 7add102..de478e9 100644 --- a/.gitea/workflows/build-kernel.yml +++ b/.gitea/workflows/build-kernel.yml @@ -51,23 +51,33 @@ jobs: if [ ! -e /proc/sys/fs/binfmt_misc/qemu-aarch64 ]; then docker run --privileged --rm tonistiigi/binfmt --install arm64 fi + # Per-version image tag. The matrix entries can run concurrently on a + # single runner sharing one Docker daemon; a fixed tag (e.g. iec-kbuild) + # is then a shared mutable name and the builds race — whichever `docker + # build` finishes last wins the tag, so both `docker create` calls + # resolve to the same image and every job emits the same vermagic. + # A unique tag per kernel version isolates them. Docker tags allow only + # [a-zA-Z0-9._-], so map ':' '/' '+' (all present in 1:6.12.93-1+rpt1) + # to '-'; the tag just needs to be unique and valid, not reversible. + img="iec-kbuild:${KERNEL_VERSION//[:\/+]/-}" # Builder image: toolchain + matching raspberrypi kernel headers. docker build --platform linux/arm64 \ --build-arg DEBIAN_SUITE="$DEBIAN_SUITE" \ --build-arg HEADERS_PKG="$HEADERS_PKG" \ --build-arg KERNEL_VERSION="$KERNEL_VERSION" \ - -t iec-kbuild . + -t "$img" . # Compile inside the container. We use `docker cp` instead of the bind # mount that build-in-docker.sh uses for local builds: under the runner's # docker-in-docker, /workspace is a volume the host daemon can't see, so # `-v "$PWD:/build"` mounts an empty dir and make finds no Makefile. - cid=$(docker create --platform linux/arm64 --entrypoint sleep iec-kbuild infinity) + cid=$(docker create --platform linux/arm64 --entrypoint sleep "$img" infinity) docker start "$cid" docker cp ./. "$cid:/build" docker exec "$cid" /usr/local/bin/docker-entrypoint.sh clean docker exec "$cid" /usr/local/bin/docker-entrypoint.sh docker cp "$cid:/build/iec_listener.ko" ./iec_listener.ko docker rm -f "$cid" + docker rmi "$img" || true - name: Stage build output (modules/iec_listener_.ko) working-directory: kernel @@ -89,12 +99,19 @@ jobs: path: kernel/out if-no-files-found: error - # Release flow only: merges all built modules into one modules/ folder, wraps it - # in a single repo-named top folder alongside selftest.sh, zips it and attaches - # it to the Gitea release for the pushed tag. Skipped entirely on pull requests. - release: + # Merge all built modules into one modules/ folder, wrap it in a single + # repo-named top folder alongside the helper scripts (selftest.sh, launch.sh) + # and the iecpoc Python frontend (so launch.sh can pip-install it on the Pi). + # Runs on BOTH triggers and uploads the assembled FOLDER as an artifact: on a PR + # this lets you download and smoke-test the exact tree a release would ship; on a + # tag the release job below zips this same tree and attaches it. + # + # We intentionally upload the folder, not a pre-made .zip: artifacts are always + # transported as a zip by the platform, so uploading a .zip would nest one zip + # inside another. Uploading the tree makes the platform's wrapper zip *be* the + # package archive (a single, clean layer). + package: needs: build - if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: - name: Checkout @@ -104,21 +121,67 @@ jobs: uses: https://github.com/christopherHX/gitea-download-artifact@v4 with: path: dist - # Merge every artifact back into the shared modules/ folder. + # Merge every per-kernel artifact back into the shared modules/ folder. merge-multiple: true - - name: Assemble release package + - name: Determine version label + id: ver + run: | + # Tags ship under the tag name; PR builds get a traceable pr-. + case "${{ github.ref }}" in + refs/tags/*) v="${{ github.ref_name }}" ;; + *) v="pr${{ github.event.pull_request.number }}-$(echo "${{ github.sha }}" | cut -c1-7)" ;; + esac + echo "ver=$v" >> "$GITHUB_OUTPUT" + + - name: Assemble package tree run: | repo="${{ github.event.repository.name }}" root="package/$repo" mkdir -p "$root" cp -r dist/modules "$root"/ # modules/iec_listener_.ko - cp kernel/selftest.sh "$root"/ # selftest.sh only at the package root - (cd package && zip -r "../${repo}-${{ github.ref_name }}.zip" "$repo") + cp kernel/selftest.sh "$root"/ # one-shot hardware self-test + cp launch.sh "$root"/ # load module -> run frontend -> unload + # iecpoc Python frontend + its packaging metadata. launch.sh pip-installs + # this on the Pi; the build reads pyproject.toml's readme = "README.md", + # so README.md must travel with it. __pycache__ is stripped to stay clean. + cp pyproject.toml README.md "$root"/ + cp -r iecpoc "$root"/ + find "$root/iecpoc" -name __pycache__ -type d -prune -exec rm -rf {} + + + - name: Upload package + uses: https://github.com/christopherHX/gitea-upload-artifact@v4 + with: + name: ${{ github.event.repository.name }}-${{ steps.ver.outputs.ver }} + # Upload the contents of package/, so the artifact zip contains the + # single top folder package// -> /... + path: package + if-no-files-found: error + + # Release flow only: download the assembled tree the package job uploaded, zip it + # into the named release asset and attach it to the Gitea release for the pushed + # tag. No re-assembly here (the tree is taken as-is). Skipped on PRs. + release: + needs: package + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + steps: + - name: Download package + uses: https://github.com/christopherHX/gitea-download-artifact@v4 + with: + # The single artifact the package job produced for this tag; extracts to + # dist//... + name: ${{ github.event.repository.name }}-${{ github.ref_name }} + path: dist + + - name: Zip release asset + run: | + repo="${{ github.event.repository.name }}" + (cd dist && zip -r "../${repo}-${{ github.ref_name }}.zip" "$repo") - name: Publish to Gitea release uses: https://gitea.com/actions/gitea-release-action@v1.3.6 with: api_key: ${{ secrets.GITEA_TOKEN }} files: |- - *.zip + ${{ github.event.repository.name }}-${{ github.ref_name }}.zip diff --git a/README.md b/README.md index 01fae8a..dff03c0 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,39 @@ See [`docs/wiring.md`](docs/wiring.md) for the level-shifter circuit and the pre-C64 bring-up checklist, and [`docs/kernel-notes.md`](docs/kernel-notes.md) for the resolved kernel real-time decisions. +## Running the frontend on the Pi + +A tagged build publishes a release zip that bundles everything needed to run on +a Pi without building anything: + +``` +comodore-iec-emu/ + selftest.sh one-shot wiring self-test (no C64 connected) + launch.sh load module → run iecpoc → unload + pyproject.toml iecpoc packaging metadata + README.md + iecpoc/ the Python frontend + modules/ iec_listener_.ko, one per supported kernel +``` + +Unzip it on the Pi and run **`launch.sh`**. It installs the frontend into a local +`.venv`, detects the running kernel and picks the matching `.ko` (by *vermagic*, +not filename), `insmod`s it, runs `iecpoc`, and **always `rmmod`s on exit** — so +the Pi is left exactly as before: + +```bash +unzip comodore-iec-emu-*.zip && cd comodore-iec-emu +sudo ./selftest.sh # first: check the wiring (want 0x1F) +sudo ./launch.sh # then: load + run the frontend (address 4) +sudo ./launch.sh --address 8 # listen as device 8 +sudo ./launch.sh -- --raw # forward extra args (after --) to iecpoc +``` + +The module is picked automatically; pass `--ko PATH` only to force a specific +file. `launch.sh` needs `python3` plus `python3-venv` (and network on first run +for the build backend). See [`kernel/README.md`](kernel/README.md) for the +self-test details and the per-kernel module selection. + ## Status - **Phase 0 (userspace + tests):** complete, runs on any host. diff --git a/kernel/README.md b/kernel/README.md index 0eece06..97c7912 100644 --- a/kernel/README.md +++ b/kernel/README.md @@ -20,14 +20,45 @@ before connecting the real bus (see PLAN.md §10). ### Running it `selftest.sh` is non-persistent (nothing is installed into `/lib/modules`, no -autoload): it checks vermagic, loads the module, runs the self-test, and always -unloads it again. +autoload): it detects the running kernel, picks the matching module, loads it, +runs the self-test, and always unloads it again. + +A released package ships several kernel builds side by side, plus the launcher +and the Python frontend: + +``` +comodore-iec-emu/ +├── selftest.sh # one-shot wiring self-test (this document) +├── launch.sh # load module → run the iecpoc frontend → unload +├── pyproject.toml # iecpoc packaging metadata (launch.sh pip-installs it) +├── README.md +├── iecpoc/ # the Python userspace decoder/trace +└── modules/ + ├── iec_listener_1-6.12.93-1+rpt1.ko # built for 6.12.x (bookworm) + └── iec_listener_1-6.18.34-1+rpt1.ko # built for 6.18.x (trixie) +``` + +`selftest.sh` only verifies the wiring (it loads and immediately unloads). To +actually capture and decode C64 traffic, use **`launch.sh`**, which installs the +frontend, loads the matching module, runs `iecpoc`, and unloads on exit — see the +[top-level README](../README.md#running-the-frontend-on-the-pi). + +You don't pick the file yourself: the script reads `uname -r`, then scans +`modules/` (and a few fallback locations) and selects the `.ko` whose **vermagic** +matches the running kernel. Matching is by vermagic rather than filename because +the packaged name carries a Debian epoch/revision (`1:6.12.93-1+rpt1`) that +`uname -r` (`6.12.93+rpt-rpi-v8`) does not. ```bash -sudo ./selftest.sh # auto-finds ./ or ~/iec_listener.ko, address 4 -sudo ./selftest.sh ~/iec_listener.ko --address 5 # runs with ~/iec_listener.ko, address 5 +sudo ./selftest.sh # auto-selects the matching module, address 4 +sudo ./selftest.sh --address 5 # same, address 5 +sudo ./selftest.sh ~/iec_listener.ko --address 5 # force a specific module file ``` +If no module matches the running kernel, the script lists the modules it found +(with the kernel each was built for) and exits — rebuild for the current kernel, +or pass a path explicitly. + It exits non-zero unless the result is a full pass (`0x1F`), so it is usable in scripts/CI. diff --git a/kernel/selftest.sh b/kernel/selftest.sh index ed4b336..1b39b7a 100755 --- a/kernel/selftest.sh +++ b/kernel/selftest.sh @@ -62,13 +62,66 @@ done case "$ADDRESS" in ''|*[!0-9]*) die "address must be 0-30 (got '$ADDRESS')" ;; esac { [ "$ADDRESS" -ge 0 ] && [ "$ADDRESS" -le 30 ]; } || die "address out of range 0-30: $ADDRESS" +command -v modinfo >/dev/null || die "modinfo not found (install kmod)" + +# ---- locate the module matching the running kernel ---------------------- +# A released package ships one module per supported kernel under modules/. +# The build workflow names each file iec_listener_.ko, with +# ':' and '/' in the version replaced by '-' (1:6.12.93-1+rpt1 -> +# iec_listener_1-6.12.93-1+rpt1.ko); local builds produce a bare +# iec_listener.ko. The glob below ($MODULE*.ko) matches both. +# +# We select by vermagic, not by filename: the package name carries a Debian +# epoch/revision (1:6.12.93-1+rpt1) that `uname -r` (6.12.93+rpt-rpi-v8) does +# not, so the .ko whose vermagic matches the running kernel is the one to load. +RUNNING="$(uname -r)" + +# Where to look, de-duplicated ($(dirname "$0") is often "." when run locally). +SEARCH_DIRS=() +for d in "$(dirname "$0")/modules" "$(dirname "$0")" "./modules" "." "$HOME"; do + skip=0 + for s in "${SEARCH_DIRS[@]:-}"; do [ "$s" = "$d" ] && skip=1; done + [ "$skip" = 0 ] && SEARCH_DIRS+=("$d") +done + if [ -z "$KO" ]; then - for cand in "./${MODULE}.ko" "$HOME/${MODULE}.ko" "$(dirname "$0")/${MODULE}.ko"; do - [ -f "$cand" ] && { KO="$cand"; break; } + info "running kernel: $RUNNING" + for dir in "${SEARCH_DIRS[@]}"; do + [ -d "$dir" ] || continue + for cand in "$dir"/${MODULE}*.ko; do + [ -f "$cand" ] || continue + vm="$(modinfo -F vermagic "$cand" 2>/dev/null)" || continue + if [ "${vm%% *}" = "$RUNNING" ]; then + KO="$cand"; break 2 + fi + done done fi -[ -n "$KO" ] && [ -f "$KO" ] || die "module not found; pass the path, e.g. sudo $0 ~/${MODULE}.ko" -command -v modinfo >/dev/null || die "modinfo not found (install kmod)" + +if [ -z "$KO" ]; then + msg="no module matching the running kernel ($RUNNING) found. + Pass a path explicitly (sudo $0 path/to/${MODULE}.ko), or rebuild for + this kernel. Modules seen:" + any=0 + for dir in "${SEARCH_DIRS[@]}"; do + [ -d "$dir" ] || continue + for cand in "$dir"/${MODULE}*.ko; do + [ -f "$cand" ] || continue + if vm="$(modinfo -F vermagic "$cand" 2>/dev/null)" && [ -n "$vm" ]; then + note="built for ${vm%% *}" + else + # modinfo couldn't read it - usually not an ELF .ko at all + # (e.g. a still-zipped release file). Show what it really is. + note="UNREADABLE: $(file -b "$cand" 2>/dev/null || echo 'not a kernel module') - unzip/rebuild?" + fi + msg="$msg"$'\n'" $cand ($note)" + any=1 + done + done + [ "$any" = 1 ] || msg="$msg"$'\n'" (none)" + die "$msg" +fi +[ -f "$KO" ] || die "module not found: $KO" info "module: $KO" info "address: $ADDRESS" diff --git a/launch.sh b/launch.sh new file mode 100755 index 0000000..2780313 --- /dev/null +++ b/launch.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# +# launch.sh - load the IEC listener module, run the userspace frontend, unload. +# +# This is the "run the whole thing" entry point for a released package. Unlike +# selftest.sh (a one-shot wiring check that loads and immediately unloads), this +# keeps the module loaded for as long as the frontend runs. In order it: +# 1. installs the iecpoc Python frontend into a local virtualenv (.venv), +# 2. detects the running kernel and picks the matching iec_listener_*.ko, +# 3. insmods it (creating /dev/iec0), +# 4. runs `iecpoc`, streaming the decoded C64 traffic to the terminal, +# 5. on exit (Ctrl-C / EOF / error) ALWAYS rmmods again - the Pi is left +# exactly as before. +# +# The module is never left loaded by this script; the EXIT trap unloads it even +# if the frontend crashes or you interrupt it. +# +# Usage: +# sudo ./launch.sh [--address N] [--device PATH] [--ko FILE] [-- ] +# +# Examples: +# sudo ./launch.sh # address 4, /dev/iec0, annotated trace +# sudo ./launch.sh --address 8 # listen as device 8 +# sudo ./launch.sh -- --raw # forward --raw to iecpoc +# sudo ./launch.sh --ko modules/iec_listener_1-6.12.93-1+rpt1.ko +# +# Defaults: address=4 (printer), device=/dev/iec0. + +set -euo pipefail + +MODULE="iec_listener" +ADDRESS=4 +DEVICE="/dev/iec0" +KO="" +VENV=".venv" +LOADED=0 +declare -a PASS=() + +# Run relative to the script's own directory so modules/, iecpoc/, pyproject.toml +# and .venv resolve the same way whether invoked as ./launch.sh or by full path. +HERE="$(cd "$(dirname "$0")" && pwd)" +cd "$HERE" + +# ---- pretty output (color only on a tty) -------------------------------- +if [ -t 1 ]; then + C_OK=$'\033[32m'; C_ERR=$'\033[31m'; C_INFO=$'\033[36m'; C_RST=$'\033[0m' +else + C_OK=""; C_ERR=""; C_INFO=""; C_RST="" +fi +ok() { printf '%s OK %s %s\n' "$C_OK" "$C_RST" "$*"; } +info() { printf '%s ==>%s %s\n' "$C_INFO" "$C_RST" "$*"; } +die() { printf '%sFAIL%s %s\n' "$C_ERR" "$C_RST" "$*" >&2; exit 1; } + +# ---- always unload on the way out --------------------------------------- +cleanup() { + if [ "$LOADED" = 1 ] && lsmod | grep -q "^${MODULE}\b"; then + info "rmmod ${MODULE} (releases the bus, removes ${DEVICE})" + rmmod "$MODULE" 2>/dev/null && ok "unloaded - Pi left as before" \ + || info "rmmod failed; unload manually: sudo rmmod ${MODULE}" + fi +} +trap cleanup EXIT + +# ---- args --------------------------------------------------------------- +# Anything unrecognised (and everything after a literal --) is forwarded to +# iecpoc, so e.g. --raw / --logfile FILE just work. +while [ $# -gt 0 ]; do + case "$1" in + -a|--address) ADDRESS="${2:-}"; shift 2 ;; + --address=*) ADDRESS="${1#*=}"; shift ;; + -d|--device) DEVICE="${2:-}"; shift 2 ;; + --device=*) DEVICE="${1#*=}"; shift ;; + --ko) KO="${2:-}"; shift 2 ;; + --ko=*) KO="${1#*=}"; shift ;; + --) shift; PASS+=("$@"); break ;; + -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) PASS+=("$1"); shift ;; + esac +done + +# ---- preconditions ------------------------------------------------------ +[ "$(id -u)" -eq 0 ] || die "must run as root (use: sudo $0 ...)" +case "$ADDRESS" in ''|*[!0-9]*) die "address must be 0-30 (got '$ADDRESS')" ;; esac +{ [ "$ADDRESS" -ge 0 ] && [ "$ADDRESS" -le 30 ]; } || die "address out of range 0-30: $ADDRESS" +command -v modinfo >/dev/null || die "modinfo not found (install kmod)" +command -v python3 >/dev/null || die "python3 not found (sudo apt install python3)" + +# ---- 1. python frontend: venv + install (once) -------------------------- +# iecpoc declares no third-party runtime deps, so this is offline-safe except +# for the build backend (setuptools), which pip fetches the first time. The +# install is skipped on later runs once the entry point exists. +[ -f "$HERE/pyproject.toml" ] || die "pyproject.toml not found next to $0; package incomplete?" +if [ ! -x "$VENV/bin/iecpoc" ]; then + if [ ! -x "$VENV/bin/python" ]; then + info "creating virtualenv: $VENV" + python3 -m venv "$VENV" || die "could not create venv (sudo apt install python3-venv)" + fi + info "installing iecpoc frontend into $VENV" + "$VENV/bin/pip" install --quiet --disable-pip-version-check . \ + || die "pip install failed (needs network for the build backend, or: sudo apt install python3-pip)" + ok "frontend installed" +fi + +# ---- 2. pick the module matching the running kernel ---------------------- +# Same vermagic-based selection as selftest.sh: match by what the .ko was built +# against (uname -r), not by filename. See selftest.sh for the full rationale. +RUNNING="$(uname -r)" +if [ -z "$KO" ]; then + info "running kernel: $RUNNING" + for dir in "$HERE/modules" "$HERE" "$HERE/kernel"; do + [ -d "$dir" ] || continue + for cand in "$dir"/${MODULE}*.ko; do + [ -f "$cand" ] || continue + vm="$(modinfo -F vermagic "$cand" 2>/dev/null)" && [ -n "$vm" ] || continue + if [ "${vm%% *}" = "$RUNNING" ]; then KO="$cand"; break 2; fi + done + done +fi +[ -n "$KO" ] && [ -f "$KO" ] || die \ +"no module matching the running kernel ($RUNNING) found. + Pass one with --ko PATH, or run ./selftest.sh to list what's available." + +VERMAGIC="$(modinfo -F vermagic "$KO" 2>/dev/null)" || die "cannot read vermagic from $KO" +[ "${VERMAGIC%% *}" = "$RUNNING" ] || die \ +"kernel mismatch: module built for '${VERMAGIC%% *}' but running '$RUNNING'." +info "module: $KO" + +# ---- 3. load (reload if a stale copy is already in) --------------------- +if lsmod | grep -q "^${MODULE}\b"; then + info "a copy is already loaded; removing it first" + rmmod "$MODULE" || die "rmmod failed (is ${DEVICE} in use?)" +fi +info "insmod ${MODULE} address=${ADDRESS}" +insmod "$KO" address="$ADDRESS" || die "insmod failed; check: dmesg | tail" +LOADED=1 +[ -c "$DEVICE" ] || die "${DEVICE} was not created" +ok "loaded; ${DEVICE} present" + +# ---- 4. run the frontend (foreground; rmmod runs in the EXIT trap) ------- +info "running iecpoc (Ctrl-C to stop) - address ${ADDRESS}, device ${DEVICE}" +echo +set +e +"$VENV/bin/iecpoc" --address "$ADDRESS" --device "$DEVICE" "${PASS[@]}" +ST=$? +set -e + +echo +info "frontend exited (status $ST); unloading module" +exit "$ST"