Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 69 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:
cmake --build sdl3-build -j"$(nproc)"
cmake --install sdl3-build

- name: Build static libcurl (HTTP only)
- name: Build static libcurl (HTTP + FTP, for --store uploads)
run: |
curl -sLO https://curl.se/download/curl-8.11.1.tar.gz
tar xzf curl-8.11.1.tar.gz
Expand All @@ -53,7 +53,7 @@ jobs:
--without-nghttp2 --without-libidn2 --disable-ldap --disable-ldaps \
--disable-rtsp --disable-dict --disable-telnet --disable-tftp \
--disable-pop3 --disable-imap --disable-smb --disable-smtp \
--disable-gopher --disable-mqtt --disable-ftp --disable-file \
--disable-gopher --disable-mqtt --disable-file \
--disable-docs --disable-manual --prefix="$GITHUB_WORKSPACE/staticlibs"
make -j"$(nproc)"
make install
Expand All @@ -77,7 +77,7 @@ jobs:
mkdir -p dist
cp c64uv dist/
strip dist/c64uv
tar -C dist -czf "c64uv-${GITHUB_REF_NAME:-dev}-linux-x86_64.tar.gz" c64uv
tar -C dist -czf "c64uv-${GITHUB_REF_NAME//\//-}-linux-x86_64.tar.gz" c64uv

- name: Upload to release
if: startsWith(github.ref, 'refs/tags/')
Expand All @@ -95,6 +95,72 @@ jobs:
name: c64uv-linux-x86_64
path: c64uv-*-linux-x86_64.tar.gz

# Cross-built with MinGW on Linux: the same static libcurl recipe as
# above, SDL3 from its official MinGW development package (shipped as a
# DLL next to the exe). Unverified on real hardware until a Windows
# tester reports back; see CLAUDE.md roadmap 1.
windows:
if: ${{ !inputs.tag }}
runs-on: ubuntu-latest
env:
SDL_VER: 3.2.20
CURL_VER: 8.11.1
steps:
- uses: actions/checkout@v4

- name: Install MinGW toolchain
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends gcc-mingw-w64-x86-64 zip

- name: Fetch SDL3 MinGW development package
run: |
curl -sLO "https://github.com/libsdl-org/SDL/releases/download/release-$SDL_VER/SDL3-devel-$SDL_VER-mingw.tar.gz"
tar xzf "SDL3-devel-$SDL_VER-mingw.tar.gz"

- name: Build static libcurl (HTTP + FTP)
run: |
curl -sLO "https://github.com/curl/curl/releases/download/curl-${CURL_VER//./_}/curl-$CURL_VER.tar.gz"
tar xzf "curl-$CURL_VER.tar.gz"
cd "curl-$CURL_VER"
./configure --host=x86_64-w64-mingw32 --disable-shared --enable-static \
--without-ssl --without-libpsl --without-zlib --without-brotli \
--without-zstd --without-nghttp2 --without-libidn2 --disable-ldap \
--disable-ldaps --disable-rtsp --disable-dict --disable-telnet \
--disable-tftp --disable-pop3 --disable-imap --disable-smb \
--disable-smtp --disable-gopher --disable-mqtt --disable-file \
--disable-docs --disable-manual --prefix="$GITHUB_WORKSPACE/wincurl"
make -j"$(nproc)"
make install

- name: Build c64uv.exe
run: |
PKG_CONFIG_PATH="$PWD/SDL3-$SDL_VER/x86_64-w64-mingw32/lib/pkgconfig:$PWD/wincurl/lib/pkgconfig" \
make TARGET=win32
x86_64-w64-mingw32-strip c64uv.exe

- name: Package
run: |
mkdir -p dist
cp c64uv.exe "SDL3-$SDL_VER/x86_64-w64-mingw32/bin/SDL3.dll" README.md LICENSE dist/
(cd dist && zip -q "../c64uv-${GITHUB_REF_NAME//\//-}-windows-x86_64.zip" ./*)

- name: Upload to release
if: startsWith(github.ref, 'refs/tags/')
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" --generate-notes || true
gh release upload "$GITHUB_REF_NAME" \
"c64uv-$GITHUB_REF_NAME-windows-x86_64.zip" --clobber

- name: Upload artifact (non-tag runs)
if: "!startsWith(github.ref, 'refs/tags/')"
uses: actions/upload-artifact@v4
with:
name: c64uv-windows-x86_64
path: c64uv-*-windows-x86_64.zip

arch-package:
runs-on: ubuntu-latest
container: archlinux:latest
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ c64uv
*.o
.claude/settings.local.json*
tests/run
c64uv.exe
*.zip
c64uv.res.o
*.d64
95 changes: 68 additions & 27 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ src/term.c minimal VT100 emulator matched to the firmware's remote screen
src/font8x8.h public-domain 8x8 bitmap font (rendering for term.c)
src/compat.h platform layer: sockets, interface list, neighbor (ARP)
table, ARP prime; compat_posix.c is the Linux reference
implementation (a port swaps the file in the Makefile)
implementation, compat_win32.c the Winsock port
(`make TARGET=win32`, MinGW cross build in release.yml)
```

Nothing outside compat_posix.c includes a socket or network header: main.c
Expand All @@ -30,7 +31,11 @@ calls (`SDL_strcasecmp`, `SDL_setenv_unsafe`).

Packaging: `make install` (DESTDIR/PREFIX) installs the binary plus
`assets/c64uv.desktop` and `assets/c64uv.svg` (icon; regenerate with
`tools/genicon.py`, which rasterises font8x8.h - never hand-edit the SVG).
`tools/genicon.py`, which rasterises font8x8.h and also wraps the PNGs
into `assets/c64uv.ico` - never hand-edit the SVG). The Windows exe gets
the icon and a version block from `assets/c64uv.rc` (windres, in the
`TARGET=win32` Makefile branch; SDL uses the exe's first icon as the
window icon on Windows).
`packaging/aur/PKGBUILD` builds from the GitHub tag tarball, so it can only
reference tags that already contain the packaging files; bump `pkgver` and
`sha256sums` on release (the release workflow builds the .pkg.tar.zst from
Expand All @@ -49,8 +54,15 @@ keepalive thread -> ARP prime (ping -I) + PUT streams/{video,audio}:start / 5 s
+ one-time GET machine:input capability probe
no host -> discover_scan() /v1/info sweep | file drop/--run -> runners:*
Ctrl hotkeys / --do -> PUT machine:{reset,reboot,pause,resume,menu_button}
--type -> KEYB batches over TCP :64 | --screen -> GET machine:readmem $0400
```

Every in-window action has a headless one-shot flag (`--discover`, `--do`,
`--run`/`--store`, `--type`, `--screen`, `--dump`, `--term-test`); keep it
that way so scripts and agents can drive the machine. `--type` + `--screen`
is the closed loop for checking typed input. Results go to stdout, logs to
stderr; exit 0/1/2 = ok / refused or unreachable / usage.

The hardware-independent pieces (video.c, term.c, keys.c, discover.c) are
split out so tests can link them; main.c keeps everything socket- and
SDL-bound.
Expand Down Expand Up @@ -90,9 +102,13 @@ finish within 3 s while the keepalive thread is stuck in a REST call. CI
5 s.
- **The firmware never ARPs on demand**: `streams/*:start` returns HTTP 404
"Network Host Resolve Error" unless the destination is already in its ARP
table. Hence the `ping -I <iface>` prime before every keepalive start - a
plain UDP send is not enough when policy routing (e.g. a VPN with
accept-routes covering the local subnet) sends LAN traffic through a
table, and the table only fills for packets the firmware *answers*: a
bare UDP datagram to the stream port leaves it empty (verified on
Windows 2026-09-27, 404 until the prime became an ICMP echo), a ping
works because the reply makes the firmware ARP for us. Hence the ping
prime before every keepalive start (`ping -I <iface>` on Linux,
`IcmpSendEcho` on Windows). `-I` matters when policy routing (e.g. a VPN
with accept-routes covering the local subnet) sends LAN traffic through a
tunnel, making packets arrive from the wrong MAC. Interface selection is
by subnet match (getifaddrs), preferring wired over `wl*`.
- **Audio queue needs a servo, not a buffer**: input and output rates match,
Expand All @@ -101,7 +117,10 @@ finish within 3 s while the keepalive thread is stuck in a REST call. CI
queue at the 60 ms target.
- **Keyboard**: TCP :64 `KEYB` (0xFF03, frame `03 FF <len16 LE> <chars>`)
DMA-writes into the KERNAL buffer `$0277` + count `$C6`. The firmware does
NOT chunk - keep batches <= 10 chars (buffer size). RUN/STOP is not a buffer
NOT chunk, and the buffer is 10 bytes; batches of exactly 10 were lost
twice on hardware (2026-09-27, Windows and Linux: the following short
batch arrived, the 10-byte one never showed), so `dma_type` sends 8 per
frame (`KEYB_BATCH`). RUN/STOP is not a buffer
char: poke `$91 = $7F` via `DMAWRITE` (0xFF06), repeated to win the race
against the KERNAL restoring it (the vendor web UI does the same). The
vendor web UI itself types via `writemem $0277`, so this is the sanctioned
Expand Down Expand Up @@ -204,13 +223,26 @@ control + password, drag-and-drop run, help overlay) shipped in v0.2.0.
1. **Platform compat layer** (done 2026-09): `src/compat.h` +
`compat_posix.c` hold sockets, interface enumeration, neighbor/ARP
lookup, and the ARP prime (`ping -I` on Linux for policy routing; a
plain datagram likely suffices elsewhere). Linux stays the reference
implementation and sole CI target. Gated follow-ups, not commitments:
a Windows port (`compat_win32.c`: Winsock, `GetAdaptersAddresses`,
`GetIpNetTable`; CMake or dual build, CI job, zip-with-DLLs release)
only when there is a test machine or a motivated tester with real
hardware - the community is Windows-heavy, but an unverifiable port
rots; a macOS port (compat_posix.c mostly builds as-is: BSD sockets +
plain datagram does NOT suffice anywhere, see protocol facts). Linux stays the reference
implementation and sole CI target. Audit 2026-09-27: main.c and
discover.c are free of POSIX calls (file loading via `SDL_LoadFile`,
dropped paths split on both separators, no errno/unistd), so a port is
compat_win32.c (the ~240 lines of compat_posix.c: Winsock,
`GetAdaptersAddresses`, `GetIpNetTable`, prime = `ping -S` or a
datagram) plus `make COMPAT=src/compat_win32.c` under MSYS2 (SDL3 and
curl come from its pacman; `<stdatomic.h>` needs MinGW or VS 2022
17.5+). Windows port written 2026-09-27 on that basis:
`compat_win32.c` (Winsock, `WSAPoll`, non-blocking connect + select for
the connect timeout since `SO_SNDTIMEO` does not bound `connect()` on
Winsock, `GetAdaptersAddresses` with `OnLinkPrefixLength` for the mask,
`GetIpNetTable` for the neighbor MAC, prime = `IcmpSendEcho`), `compat_sock`
is `uintptr_t` there, `make TARGET=win32` cross-builds with MinGW and
release.yml ships `c64uv-<tag>-windows-x86_64.zip` (exe + SDL3.dll from
the official MinGW package + static curl, console subsystem so the CLI
flags work). Verified on Michal's Windows box 2026-09-27: discovery,
REST, DMA keyboard, video + audio streams (once the prime became an
ICMP echo, see protocol facts). Unit/integration tests stay Linux-only (bash + loopback). A macOS
port (compat_posix.c mostly builds as-is: BSD sockets +
`getifaddrs`, but `/proc/net/arp` and `ping -I` need `arp -n` /
`ping -b` equivalents) only on request.
2. **Gamepad -> machine:input joysticks**: SDL_Gamepad (SDL_INIT_GAMEPAD,
Expand All @@ -226,24 +258,33 @@ control + password, drag-and-drop run, help overlay) shipped in v0.2.0.
comes from SDL3's HIDAPI drivers + mapping db (worst case Steam udev
rules or SDL_GAMECONTROLLERCONFIG); code against generic SDL_Gamepad.

3. **Persistent drop storage** (agreed 2026-09-02, not started): the drop
path keeps the firmware's temp area (RAM disk, gone at power-off) as
the fast default; a `--store <folder>` flag and/or a modifier held
during the drop switch to FTP-upload-then-mount-by-path. FTP is the
3. **Persistent drop storage** (implemented and verified on hardware
2026-09-27, Windows client against firmware 1.1.0: upload, path mount,
reset and typed autostart all went through): `--store <folder>` / `C64U_STORE` switches image drops
from the firmware's temp area (RAM disk, gone at power-off) to
FTP-upload-then-mount-by-path, see `store_image` in main.c. FTP is the
only upload route: the REST files API has no upload on any firmware
(verified: `curl -T` to `ftp://<ult>/Temp/` works, `files/<path>:info`
then sees the file, the FTP service is on by default on 1.1.0). Sequence:
check `files/<path>:info` (refuse to overwrite), `curl -T` the file,
`PUT drives/a:mount?image=<path>&mode=readwrite`, then for autostart
`machine:reset` + readiness gate + `LOAD"*",8,1` / `RUN` over the
keyboard channel (no firmware autostart for a path mount). Michal's
libcurl FTP upload (anonymous; STOR replaces a same-named file, which
is what Michal wants for re-drops), `PUT drives/a:mount?image=<path>&mode=readwrite`, then
`machine:reset` + readiness gate + `LOAD"*",8,1` / `RUN` typed over the
keyboard channel in 8-byte batches (no firmware autostart for a
path mount; the KERNAL load runs at ~400 bytes/s, so the second gate
allows 120 s). The static release build now keeps FTP in curl. Michal's
preference: upload to `/Temp` and move the file from the Ultimate menu
himself. Open questions: whether SDL reports a modifier held during a
drag on Wayland (`SDL_GetKeyboardState` at drop time; if not, flag
only), and the static release build needs curl rebuilt with FTP
(`--disable-ftp` today in release.yml). Follow-up on top of it: in the
F9 view, upload into the folder the menu currently shows (path line
parse; truncated long paths need a fallback).
himself. SDL does report a modifier held during a drag on Wayland
(Hyprland, verified 2026-09-27 via the `--verbose` drop log), so a
modifier-selected store is possible. Verified: `image=`
takes a literal `/`-separated path; `files/<path>:info` answers
non-200 for a missing file (no longer used). The typed autostart needs
a boot head start: reset zeroes the zero page, so the `$CC` gate can
pass mid-boot and the KERNAL init then wipes the typed buffer (seen as
"1", "RUN", READY with the first batch gone). Follow-up on top
of it: in the F9 view, upload into the folder the menu currently shows
(path line parse; truncated long paths need a fallback). Test hooks:
`C64U_FTP_PORT` (fakeultimate.py serves a passive-mode FTP stub as its
fifth argument and logs `FTP STOR <path> len=N`).

Dormant follow-up: when official firmware ships `machine:input`, re-verify
the matrix-keyboard mapping against real hardware and activate the gamepad
Expand Down
22 changes: 19 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,31 @@ LDLIBS += $(shell pkg-config --libs sdl3 libcurl)
endif

# compat_posix.c is the Linux reference implementation of compat.h; a port
# swaps in its own file here.
# swaps in its own file here. `make TARGET=win32` cross-builds c64uv.exe
# with MinGW against SDL3/libcurl found on PKG_CONFIG_PATH (release.yml).
COMPAT = src/compat_posix.c
ifeq ($(TARGET),win32)
CC = x86_64-w64-mingw32-gcc
COMPAT = src/compat_win32.c
LDLIBS += -lws2_32 -liphlpapi -mconsole # sdl3.pc says -mwindows; the CLI wants a console
LDFLAGS += -static-libgcc
EXE = .exe
RES = c64uv.res.o # icon + version block, baked into the exe (rule below)
endif
SRC = src/main.c src/video.c src/term.c src/keys.c src/discover.c $(COMPAT)
LIB = src/video.c src/term.c src/keys.c src/discover.c $(COMPAT)
HDR = src/video.h src/term.h src/keys.h src/discover.h src/compat.h \
src/font8x8.h

c64uv: $(SRC) $(HDR)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(SRC) $(LDLIBS)
c64uv$(EXE): $(SRC) $(HDR) $(RES)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(SRC) $(RES) $(LDLIBS)

VERSION = $(shell sed -n 's/^\#define C64UV_VERSION "\(.*\)"/\1/p' src/main.c)
VERSION_COMMAS = $(subst .,$(comma),$(VERSION)),0
comma = ,
c64uv.res.o: assets/c64uv.rc assets/c64uv.ico
x86_64-w64-mingw32-windres -DC64UV_VERSION='\"$(VERSION)\"' \
-DC64UV_VERSION_COMMAS=$(VERSION_COMMAS) -O coff -o $@ $<

tests/run: tests/tests.c $(LIB) $(HDR)
$(CC) $(CFLAGS) -o $@ tests/tests.c $(LIB) $(LDLIBS)
Expand Down
31 changes: 28 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,10 @@ cable.

| Service | Needed for |
|---|---|
| Web Remote Control Service (REST API, port 80) | discovery, starting/stopping the streams, the Ctrl+R/P/M machine controls and `--do`, running dropped `.prg`/`.crt`/`.sid` files, mounting `.g64`/`.d71`/`.g71`/`.d81` images, cartridge parking, and matrix-level typing on firmware that has `machine:input` |
| Web Remote Control Service (REST API, port 80) | discovery, starting/stopping the streams, the Ctrl+R/P/M machine controls and `--do`, running dropped `.prg`/`.crt`/`.sid` files, mounting `.g64`/`.d71`/`.g71`/`.d81` images, mounting stored images by path (`--store`), cartridge parking, and matrix-level typing on firmware that has `machine:input` |
| Ultimate DMA Service (port 64) | typing into the C64 (KERNAL buffer), RUN/STOP, and mount-and-run of a dropped `.d64` |
| Telnet Remote Menu Service (port 23) | the F9 menu view |
| FTP Service (port 21, on by default) | uploading dropped images with `--store` |

Everything else the viewer does needs no service: the video/audio streams
arrive on UDP 11000/11001 once started. The REST API alone gets you a
Expand Down Expand Up @@ -121,10 +122,27 @@ a second drop while one is in flight is refused.
Other disk images (`.g64`, `.d71`, `.g71`, `.d81`) are mounted on drive A
without touching the machine; type `LOAD"*",8,1` yourself. Every image is
copied to the Ultimate's temp area first, so writes never reach the file you
dropped.
dropped, and the temp area is a RAM disk that is gone at power-off.

To keep dropped images, start the viewer with `--store FOLDER` (or set
`C64U_STORE`), e.g. `--store /Temp` or `--store /Usb0/games`. A dropped
image is then uploaded into that folder over FTP (the Ultimate's FTP service
is on by default), mounted from there read-write, and autostarted by the
viewer: it resets the machine and types `LOAD"*",8,1` and `RUN` once the
READY prompt is back, for every image type. Dropping a file of the same
name replaces the stored copy. `.prg`/`.crt`/`.sid` drops are unaffected
by `--store`.

The same machine controls work headless: `c64uv --do reset` (also `reboot`,
`pause`, `resume`, `menu`, `poweroff`) issues one REST call and exits. If
`pause`, `resume`, `menu`, `poweroff`) issues one REST call and exits.
Every viewer action has a one-shot flag, so scripts and agents can drive the
machine without a window: `--discover`, `--do`, `--run` (with `--store`),
`--type 'LOAD"*",8,1\n'` (types into the C64, `\n` = RETURN),
`--screen` (prints the 40x25 text screen read from screen RAM, the way to
check what a typed command did), `--dump frame.ppm` (one video frame) and
`--term-test` (the Ultimate menu as text). After `--do reset` give the C64
about three seconds to boot before typing. Exit status is 0 on success, 1
when the Ultimate refused or did not answer, 2 for a usage error. If
your Ultimate has a network password set (firmware 3.12+), pass it with
`--password` or the `C64U_PASSWORD` environment variable; it is sent as the
`X-Password` header on every request, discovery included.
Expand Down Expand Up @@ -159,6 +177,13 @@ binary, a desktop entry, and the icon. Prebuilt static binaries are on the
in your environment if you launch it from the desktop menu rather than a
terminal.

**Windows** (x86_64): unzip `c64uv-<version>-windows-x86_64.zip`
from the release and run `c64uv.exe` from a terminal (`c64uv.exe --host
<ip>`); `SDL3.dll` must stay next to it. Everything in this README applies,
except that a VPN claiming the LAN route may need to be off (the Linux
build pins the stream's ARP prime to the LAN interface; Windows sends a
plain ping).

On Arch (x86_64), download `c64uv-<version>-1-x86_64.pkg.tar.zst` from the
[latest release](https://github.com/crustovsky/C64UV/releases/latest) and
install it with pacman:
Expand Down
Binary file added assets/c64uv.ico
Binary file not shown.
Loading
Loading