Compare commits

..

No commits in common. "master" and "v0.0.9" have entirely different histories.

6 changed files with 30 additions and 371 deletions

View File

@ -51,33 +51,23 @@ 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 "$img" .
-t iec-kbuild .
# 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 "$img" infinity)
cid=$(docker create --platform linux/arm64 --entrypoint sleep iec-kbuild 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_<kernel_version>.ko)
working-directory: kernel
@ -99,19 +89,12 @@ jobs:
path: kernel/out
if-no-files-found: error
# 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:
# 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:
needs: build
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- name: Checkout
@ -121,67 +104,21 @@ jobs:
uses: https://github.com/christopherHX/gitea-download-artifact@v4
with:
path: dist
# Merge every per-kernel artifact back into the shared modules/ folder.
# Merge every artifact back into the shared modules/ folder.
merge-multiple: true
- name: Determine version label
id: ver
run: |
# Tags ship under the tag name; PR builds get a traceable pr<n>-<sha>.
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
- name: Assemble release package
run: |
repo="${{ github.event.repository.name }}"
root="package/$repo"
mkdir -p "$root"
cp -r dist/modules "$root"/ # modules/iec_listener_<kernel_version>.ko
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/<repo>/ -> <repo>/...
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/<repo>/...
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")
cp kernel/selftest.sh "$root"/ # selftest.sh only at the package root
(cd package && 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: |-
${{ github.event.repository.name }}-${{ github.ref_name }}.zip
*.zip

View File

@ -71,39 +71,6 @@ 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_<kernel_version>.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.

View File

@ -20,45 +20,14 @@ 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 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.
autoload): it checks vermagic, loads the module, runs the self-test, and always
unloads it again.
```bash
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
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
```
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.

View File

@ -108,26 +108,16 @@ static void emit_record(u8 kind, u8 value, u8 flags)
/* --- busy-poll helper ---------------------------------------------------- */
/*
* Spin until CLK reaches `want_asserted`, or timeout/ATN-change/RESET. Caller
* holds local IRQs disabled. Uses a microsecond budget rather than a fixed
* udelay so a slightly-fast C64 is handled correctly (research §1.3).
*
* The ATN abort is phase-relative, and IEC_RX_ATN means "ATN changed, abort":
* - data_phase: ATN is released; abort if it ASSERTS (C64 interrupts data
* transfer with a new command).
* - command phase: ATN is asserted for the whole command byte; abort if it
* RELEASES (the command sequence is over -> hand back to the state machine).
* Treating any asserted ATN as an abort (the previous behaviour) made command
* reception impossible: ATN is legitimately low the entire command phase, so
* the very first poll aborted before a single bit was clocked.
* Spin until CLK reaches `want_asserted`, or timeout/ATN/RESET. Caller holds
* local IRQs disabled. Uses a microsecond budget rather than a fixed udelay so
* a slightly-fast C64 is handled correctly (research §1.3).
*/
static enum iec_rx wait_clk(bool want_asserted, unsigned int timeout_us,
bool data_phase)
static enum iec_rx wait_clk(bool want_asserted, unsigned int timeout_us)
{
unsigned int waited = 0;
while (iec_clk_asserted() != want_asserted) {
if (iec_atn_asserted() == data_phase)
if (iec_atn_asserted())
return IEC_RX_ATN;
if (iec_reset_asserted())
return IEC_RX_RESET;
@ -153,7 +143,7 @@ static enum iec_rx receive_byte(u8 *out, bool *eoi, bool data_phase)
*eoi = false;
/* 1. READY-FOR-DATA: wait for talker to release CLK, then release DATA */
rc = wait_clk(false /* released */, IEC_CLK_TIMEOUT_US, data_phase);
rc = wait_clk(false /* released */, IEC_CLK_TIMEOUT_US);
if (rc != IEC_RX_OK)
return rc;
iec_data_release();
@ -181,12 +171,10 @@ static enum iec_rx receive_byte(u8 *out, bool *eoi, bool data_phase)
/* 3. RECEIVE 8 BITS, LSB first */
for (i = 0; i < 8; i++) {
rc = wait_clk(true /* asserted: data invalid/setup */,
IEC_CLK_TIMEOUT_US, data_phase);
rc = wait_clk(true /* asserted: data invalid/setup */, IEC_CLK_TIMEOUT_US);
if (rc != IEC_RX_OK)
goto out;
rc = wait_clk(false /* released: data valid -> sample */,
IEC_CLK_TIMEOUT_US, data_phase);
rc = wait_clk(false /* released: data valid -> sample */, IEC_CLK_TIMEOUT_US);
if (rc != IEC_RX_OK)
goto out;
/* released(high) = bit 1, asserted(low) = bit 0 */

View File

@ -62,67 +62,14 @@ 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"
if [ -z "$KO" ]; then
for cand in "./${MODULE}.ko" "$HOME/${MODULE}.ko" "$(dirname "$0")/${MODULE}.ko"; do
[ -f "$cand" ] && { KO="$cand"; break; }
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)"
# ---- 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_<kernel_version>.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
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
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"

149
launch.sh
View File

@ -1,149 +0,0 @@
#!/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] [-- <iecpoc args>]
#
# 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"