diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 741f7ac..2aeabe6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 @@ -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/') @@ -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 diff --git a/.gitignore b/.gitignore index 57b144b..abeda88 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ c64uv *.o .claude/settings.local.json* tests/run +c64uv.exe +*.zip +c64uv.res.o +*.d64 diff --git a/CLAUDE.md b/CLAUDE.md index eab130d..97f3f6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 @@ -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. @@ -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 ` 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 ` 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, @@ -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 `) 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 @@ -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; `` 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--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, @@ -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 ` 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 ` / `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:///Temp/` works, `files/:info` then sees the file, the FTP service is on by default on 1.1.0). Sequence: - check `files/:info` (refuse to overwrite), `curl -T` the file, - `PUT drives/a:mount?image=&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=&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/: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 len=N`). Dormant follow-up: when official firmware ships `machine:input`, re-verify the matrix-keyboard mapping against real hardware and activate the gamepad diff --git a/Makefile b/Makefile index ed4f82d..c7d9a59 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/README.md b/README.md index be301e2..1d8ed5d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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--windows-x86_64.zip` +from the release and run `c64uv.exe` from a terminal (`c64uv.exe --host +`); `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--1-x86_64.pkg.tar.zst` from the [latest release](https://github.com/crustovsky/C64UV/releases/latest) and install it with pacman: diff --git a/assets/c64uv.ico b/assets/c64uv.ico new file mode 100644 index 0000000..3d98964 Binary files /dev/null and b/assets/c64uv.ico differ diff --git a/assets/c64uv.rc b/assets/c64uv.rc new file mode 100644 index 0000000..e1a39b6 --- /dev/null +++ b/assets/c64uv.rc @@ -0,0 +1,28 @@ +// Windows resources for c64uv.exe (make TARGET=win32 compiles this with +// windres): the Explorer/taskbar icon, which SDL also picks up as the +// window icon (it extracts the exe's first icon), and the version block. +// C64UV_VERSION ("0.2.9") and C64UV_VERSION_COMMAS (0,2,9,0) come from the +// Makefile, derived from the string in src/main.c. +1 ICON "c64uv.ico" + +1 VERSIONINFO +FILEVERSION C64UV_VERSION_COMMAS +PRODUCTVERSION C64UV_VERSION_COMMAS +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904B0" + BEGIN + VALUE "FileDescription", "Commodore 64 Ultimate Viewer" + VALUE "FileVersion", C64UV_VERSION + VALUE "ProductName", "c64uv" + VALUE "ProductVersion", C64UV_VERSION + VALUE "OriginalFilename", "c64uv.exe" + VALUE "LegalCopyright", "MIT License" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END diff --git a/src/compat.h b/src/compat.h index 9e36e29..3af4a22 100644 --- a/src/compat.h +++ b/src/compat.h @@ -17,8 +17,13 @@ #include #include -typedef int compat_sock; // SOCKET on Winsock +#ifdef _WIN32 +typedef uintptr_t compat_sock; // Winsock SOCKET +#define COMPAT_BAD_SOCK ((compat_sock)~0) // INVALID_SOCKET +#else +typedef int compat_sock; #define COMPAT_BAD_SOCK (-1) +#endif #define COMPAT_IP_STRLEN 16 // "255.255.255.255" + NUL #define COMPAT_MAC_STRLEN 18 // "aa:bb:cc:dd:ee:ff" + NUL @@ -109,11 +114,12 @@ int compat_ifaces(struct compat_iface *out, int max); // Linux reads /proc/net/arp; C64U_ARP_TABLE overrides the path for tests. bool compat_neighbor_mac(const char *ip, char *out, size_t cap); -// Makes the Ultimate hear from us so `ip` lands in its ARP table (the -// firmware never ARPs on demand). With a non-empty `ifname` the traffic is -// forced out of that interface, which on Linux needs `ping -I` because -// policy routing (VPN accept-routes) can otherwise detour LAN traffic; an -// empty name sends one datagram from `s` and lets the OS route it. +// Makes the Ultimate answer us so `ip` lands in its ARP table (the +// firmware never ARPs on demand, and only fills the table for packets it +// replies to, so this is an ICMP echo, never a bare datagram). With a +// non-empty `ifname` the traffic is forced out of that interface, which on +// Linux needs `ping -I` because policy routing (VPN accept-routes) can +// otherwise detour LAN traffic. `s` is unused today. void compat_arp_prime(compat_sock s, const char *ip, const char *ifname); #endif diff --git a/src/compat_posix.c b/src/compat_posix.c index 54e10ad..39742e9 100644 --- a/src/compat_posix.c +++ b/src/compat_posix.c @@ -225,19 +225,21 @@ bool compat_neighbor_mac(const char *ip, char *out, size_t cap) return found; } +// The firmware fills its ARP table only when it has to answer us, so the +// prime is a ping, not a bare datagram (a datagram to the stream port gets +// no reply and leaves the table empty: "Network Host Resolve Error"). +// ping may force the egress device (-I) without privileges. void compat_arp_prime(compat_sock s, const char *ip, const char *ifname) { + (void)s; uint32_t a; if (!compat_ipv4_parse(ip, &a)) return; // hostnames never reach a shell - if (ifname && ifname[0]) { - // ping may force the egress device without privileges - char cmd[160]; - snprintf(cmd, sizeof cmd, - "ping -n -q -c 1 -W 1 -I '%s' '%s' >/dev/null 2>&1", ifname, - ip); - (void)!system(cmd); - } else { - compat_sendto(s, "", 1, ip, 11000); - } + char dev[48] = ""; + if (ifname && ifname[0]) + snprintf(dev, sizeof dev, "-I '%s'", ifname); + char cmd[160]; + snprintf(cmd, sizeof cmd, "ping -n -q -c 1 -W 1 %s '%s' >/dev/null 2>&1", + dev, ip); + (void)!system(cmd); } diff --git a/src/compat_win32.c b/src/compat_win32.c new file mode 100644 index 0000000..c073ae7 --- /dev/null +++ b/src/compat_win32.c @@ -0,0 +1,294 @@ +// Windows (Winsock) implementation of compat.h. Built with MinGW +// (make TARGET=win32); libraries: ws2_32, iphlpapi. +#define _WIN32_WINNT 0x0600 // Vista+: inet_pton, WSAPoll, OnLinkPrefixLength + +#include "compat.h" + +#include +#include +#include +#include +#include + +#include +#include + +bool compat_net_init(void) +{ + WSADATA wsa; + return WSAStartup(MAKEWORD(2, 2), &wsa) == 0; +} + +void compat_net_quit(void) +{ + WSACleanup(); +} + +bool compat_ipv4_parse(const char *s, uint32_t *addr) +{ + struct in_addr a; + if (!s || inet_pton(AF_INET, s, &a) != 1) + return false; + *addr = ntohl(a.s_addr); + return true; +} + +const char *compat_ipv4_format(uint32_t addr, char *out, size_t cap) +{ + struct in_addr a = {.s_addr = htonl(addr)}; + if (!inet_ntop(AF_INET, &a, out, cap) && cap) + out[0] = '\0'; + return out; +} + +static bool sockaddr_from(const char *ip, uint16_t port, + struct sockaddr_in *sa) +{ + memset(sa, 0, sizeof *sa); + sa->sin_family = AF_INET; + sa->sin_port = htons(port); + return ip && inet_pton(AF_INET, ip, &sa->sin_addr) == 1; +} + +compat_sock compat_udp_bind(uint16_t port, int rcvbuf, bool reuse) +{ + SOCKET s = socket(AF_INET, SOCK_DGRAM, 0); + if (s == INVALID_SOCKET) + return COMPAT_BAD_SOCK; + BOOL one = TRUE; + setsockopt(s, SOL_SOCKET, SO_RCVBUF, (const char *)&rcvbuf, sizeof rcvbuf); + if (reuse) + setsockopt(s, SOL_SOCKET, SO_REUSEADDR, (const char *)&one, sizeof one); + struct sockaddr_in sa = {.sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_ANY), + .sin_port = htons(port)}; + if (bind(s, (struct sockaddr *)&sa, sizeof sa) != 0) { + int saved = WSAGetLastError(); + closesocket(s); + WSASetLastError(saved); + return COMPAT_BAD_SOCK; + } + return (compat_sock)s; +} + +bool compat_mcast_join(compat_sock s, const char *group, const char *ifip) +{ + struct ip_mreq m = {0}; + if (inet_pton(AF_INET, group, &m.imr_multiaddr) != 1) + return false; + if (ifip) + inet_pton(AF_INET, ifip, &m.imr_interface); + return setsockopt((SOCKET)s, IPPROTO_IP, IP_ADD_MEMBERSHIP, + (const char *)&m, sizeof m) == 0; +} + +// Winsock's SO_SNDTIMEO does not bound connect(), so the connect runs +// non-blocking against a select() timeout; the socket then goes back to +// blocking with a send timeout for the later sends. +compat_sock compat_tcp_connect(const char *ip, uint16_t port, int timeout_s) +{ + SOCKET fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd == INVALID_SOCKET) + return COMPAT_BAD_SOCK; + struct sockaddr_in sa; + u_long nb = 1; + bool ok = sockaddr_from(ip, port, &sa) && ioctlsocket(fd, FIONBIO, &nb) == 0; + if (ok) { + connect(fd, (struct sockaddr *)&sa, sizeof sa); // WSAEWOULDBLOCK + fd_set w, e; + FD_ZERO(&w); + FD_ZERO(&e); + FD_SET(fd, &w); + FD_SET(fd, &e); + struct timeval tv = {.tv_sec = timeout_s}; + ok = select(0, NULL, &w, &e, &tv) == 1 && FD_ISSET(fd, &w); + if (!ok) + WSASetLastError(WSAETIMEDOUT); + } + if (ok) { + nb = 0; + ioctlsocket(fd, FIONBIO, &nb); + DWORD ms = (DWORD)timeout_s * 1000; + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, (const char *)&ms, sizeof ms); + return (compat_sock)fd; + } + int saved = WSAGetLastError(); + closesocket(fd); + WSASetLastError(saved); + return COMPAT_BAD_SOCK; +} + +int compat_send(compat_sock s, const void *buf, size_t len) +{ + int n = send((SOCKET)s, buf, (int)len, 0); // no SIGPIPE on Windows + return n == SOCKET_ERROR ? -1 : n; +} + +int compat_sendto(compat_sock s, const void *buf, size_t len, const char *ip, + uint16_t port) +{ + struct sockaddr_in sa; + if (!sockaddr_from(ip, port, &sa)) + return -1; + int n = sendto((SOCKET)s, buf, (int)len, 0, (struct sockaddr *)&sa, + sizeof sa); + return n == SOCKET_ERROR ? -1 : n; +} + +int compat_recv_nowait(compat_sock s, void *buf, size_t cap) +{ + // no MSG_DONTWAIT: a zero-timeout readiness check stands in for it + if (compat_wait_readable(&s, 1, 0) != 1) { + WSASetLastError(WSAEWOULDBLOCK); + return -1; + } + int n = recv((SOCKET)s, buf, (int)cap, 0); + return n == SOCKET_ERROR ? -1 : n; +} + +int compat_wait_readable(const compat_sock *socks, int n, int timeout_ms) +{ + WSAPOLLFD pfd[8]; + if (n > 8) + n = 8; + for (int i = 0; i < n; i++) + pfd[i] = (WSAPOLLFD){.fd = (SOCKET)socks[i], .events = POLLRDNORM}; + // INVALID_SOCKET entries read as negative and are ignored, like poll() + return WSAPoll(pfd, (ULONG)n, timeout_ms); +} + +int compat_wait_writable(compat_sock s, int timeout_ms) +{ + WSAPOLLFD pfd = {.fd = (SOCKET)s, .events = POLLWRNORM}; + return WSAPoll(&pfd, 1, timeout_ms); +} + +void compat_close(compat_sock s) +{ + if (s != COMPAT_BAD_SOCK) + closesocket((SOCKET)s); +} + +const char *compat_neterr(void) +{ + static char msg[256]; + int err = WSAGetLastError(); + DWORD n = FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | + FORMAT_MESSAGE_IGNORE_INSERTS, + NULL, (DWORD)err, 0, msg, sizeof msg, NULL); + if (n == 0) + snprintf(msg, sizeof msg, "Winsock error %d", err); + else + while (n > 0 && (msg[n - 1] == '\r' || msg[n - 1] == '\n')) + msg[--n] = '\0'; + return msg; +} + +bool compat_neterr_transient(void) +{ + int err = WSAGetLastError(); + return err == WSAEWOULDBLOCK || err == WSAETIMEDOUT || err == WSAEINTR; +} + +bool compat_route_source_ip(const char *ip, char *out, size_t cap) +{ + SOCKET s = socket(AF_INET, SOCK_DGRAM, 0); + if (s == INVALID_SOCKET) + return false; + struct sockaddr_in sa; + bool ok = sockaddr_from(ip, 80, &sa) && + connect(s, (struct sockaddr *)&sa, sizeof sa) == 0; + if (ok) { + struct sockaddr_in local; + int len = sizeof local; + ok = getsockname(s, (struct sockaddr *)&local, &len) == 0 && + inet_ntop(AF_INET, &local.sin_addr, out, cap) != NULL; + } + closesocket(s); + return ok; +} + +int compat_ifaces(struct compat_iface *out, int max) +{ + ULONG size = 16384; + IP_ADAPTER_ADDRESSES *list = malloc(size); + ULONG flags = GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | + GAA_FLAG_SKIP_DNS_SERVER; + if (list && GetAdaptersAddresses(AF_INET, flags, NULL, list, &size) == + ERROR_BUFFER_OVERFLOW) { + free(list); + list = malloc(size); + } + if (!list || GetAdaptersAddresses(AF_INET, flags, NULL, list, &size) != + NO_ERROR) { + free(list); + return 0; + } + int n = 0; + for (IP_ADAPTER_ADDRESSES *a = list; a && n < max; a = a->Next) { + for (IP_ADAPTER_UNICAST_ADDRESS *u = a->FirstUnicastAddress; + u && n < max; u = u->Next) { + if (u->Address.lpSockaddr->sa_family != AF_INET) + continue; + struct compat_iface *o = &out[n++]; + memset(o, 0, sizeof *o); + WideCharToMultiByte(CP_UTF8, 0, a->FriendlyName, -1, o->name, + sizeof o->name - 1, NULL, NULL); + o->addr = ntohl(((struct sockaddr_in *)u->Address.lpSockaddr) + ->sin_addr.s_addr); + int pl = u->OnLinkPrefixLength; + o->mask = pl > 0 && pl <= 32 ? ~0u << (32 - pl) : 0; + o->up = a->OperStatus == IfOperStatusUp; + o->loopback = a->IfType == IF_TYPE_SOFTWARE_LOOPBACK; + o->wireless = a->IfType == IF_TYPE_IEEE80211; + } + } + free(list); + return n; +} + +bool compat_neighbor_mac(const char *ip, char *out, size_t cap) +{ + uint32_t addr; + if (!compat_ipv4_parse(ip, &addr)) + return false; + ULONG size = 0; + if (GetIpNetTable(NULL, &size, FALSE) != ERROR_INSUFFICIENT_BUFFER) + return false; + MIB_IPNETTABLE *t = malloc(size); + bool found = false; + if (t && GetIpNetTable(t, &size, FALSE) == NO_ERROR) { + for (DWORD i = 0; i < t->dwNumEntries && !found; i++) { + MIB_IPNETROW *r = &t->table[i]; + if (ntohl(r->dwAddr) != addr || r->dwPhysAddrLen != 6 || + r->dwType == MIB_IPNET_TYPE_INVALID) + continue; + snprintf(out, cap, "%02x:%02x:%02x:%02x:%02x:%02x", + r->bPhysAddr[0], r->bPhysAddr[1], r->bPhysAddr[2], + r->bPhysAddr[3], r->bPhysAddr[4], r->bPhysAddr[5]); + found = true; + } + } + free(t); + return found; +} + +// The firmware fills its ARP table only when it has to answer us, so the +// prime must be something it replies to: an ICMP echo (a bare datagram to +// the stream port leaves the table empty, seen as "Network Host Resolve +// Error" on Windows, 2026-09-27). IcmpSendEcho needs no process and no +// privileges. The interface hint is unused: Windows has no equivalent of +// the policy-routing detour that made Linux force the egress device. +void compat_arp_prime(compat_sock s, const char *ip, const char *ifname) +{ + (void)s; (void)ifname; + uint32_t addr; + if (!compat_ipv4_parse(ip, &addr)) + return; + HANDLE h = IcmpCreateFile(); + if (h == INVALID_HANDLE_VALUE) + return; + char reply[sizeof(ICMP_ECHO_REPLY) + 8]; + IcmpSendEcho(h, htonl(addr), "", 0, NULL, reply, sizeof reply, 1000); + IcmpCloseHandle(h); +} diff --git a/src/discover.c b/src/discover.c index 7e74494..519310d 100644 --- a/src/discover.c +++ b/src/discover.c @@ -173,7 +173,7 @@ int discover_scan(struct discovered *out, int max, bool verbose) curl_easy_setopt(h, CURLOPT_CONNECTTIMEOUT_MS, 1500L); curl_easy_setopt(h, CURLOPT_TIMEOUT_MS, 4750L); curl_easy_setopt(h, CURLOPT_WRITEFUNCTION, probe_sink); - curl_easy_setopt(h, CURLOPT_WRITEDATA, pr->resp); + curl_easy_setopt(h, CURLOPT_WRITEDATA, (void *)pr->resp); curl_easy_setopt(h, CURLOPT_PRIVATE, pr); if (pwhdr) curl_easy_setopt(h, CURLOPT_HTTPHEADER, pwhdr); diff --git a/src/main.c b/src/main.c index 9bfbdc4..555fbf6 100644 --- a/src/main.c +++ b/src/main.c @@ -6,7 +6,7 @@ #include #include -#define C64UV_VERSION "0.2.8" +#define C64UV_VERSION "0.2.9" #include "compat.h" #include "discover.h" @@ -14,7 +14,6 @@ #include "term.h" #include "video.h" -#include #include #include #include @@ -26,6 +25,8 @@ struct config { const char *password; // network password -> X-Password header const char *do_action; // one-shot machine control, then exit const char *run_path; // one-shot: run this .prg/.crt/.sid/.d64, then exit + const char *type_text; // one-shot: type this into the C64, then exit + bool screen; // one-shot: print the C64 text screen, then exit const char *dest; // ip[:port] the stream should be sent to (auto if NULL) int listen_port; // video; audio uses listen_port + 1 int scale; @@ -53,6 +54,9 @@ static atomic_int g_minput = -1; // Network password (firmware 3.12+), sent as X-Password on every REST call. // Set once at startup, before any thread starts. static const char *g_password; +// --store folder on the Ultimate: dropped disk images are uploaded there +// (FTP) and mounted by path instead of going to the firmware's temp area. +static const char *g_store; // ---------------------------------------------------------------- REST control @@ -190,8 +194,9 @@ static int keepalive_thread(void *arg) else SDL_Log("stream start HTTP %ld: %s%s", code, resp, strstr(resp, "No Operational Network Interface") - ? " -> plug the Ultimate into wired Ethernet; " - "streams don't work over its WiFi" + ? " -> the Ultimate's wired port has no link: " + "check the cable and its link LED; streams " + "don't work over its WiFi" : ""); last_code[i] = code; } @@ -397,15 +402,21 @@ static int discover_thread(void *arg) #define DMA_CMD_AUTHENTICATE 0xFF1F #define DMA_MAX_PAYLOAD 200000 // firmware SOCKET_BUFFER_SIZE; longer is truncated -// The REST host may carry a :port (discovery test hook); the DMA socket -// wants the bare address. C64U_DMA_PORT overrides port 64 for tests. -static compat_sock dma_connect_raw(const char *host, int timeout_s) +// The REST host may carry a :port (discovery test hook); the DMA socket and +// FTP want the bare address. +static void host_ip(const char *host, char *ip, size_t size) { - char ip[64]; - snprintf(ip, sizeof ip, "%s", host); + snprintf(ip, size, "%s", host); char *colon = strchr(ip, ':'); if (colon) *colon = '\0'; +} + +// C64U_DMA_PORT overrides port 64 for tests. +static compat_sock dma_connect_raw(const char *host, int timeout_s) +{ + char ip[64]; + host_ip(host, ip, sizeof ip); const char *penv = getenv("C64U_DMA_PORT"); return compat_tcp_connect(ip, penv ? (uint16_t)atoi(penv) : 64, timeout_s); @@ -593,7 +604,7 @@ static const char *image_type_for(const char *path) } struct binbuf { - uint8_t data[16]; + uint8_t data[1000]; // a full 40x25 text screen int len; }; @@ -606,33 +617,44 @@ static size_t bin_sink(char *d, size_t size, size_t nmemb, void *userp) return n; } +// GET machine:readmem: `len` bytes from hex address `addr` into b (capped +// at the buffer). False on a transport error. +static bool readmem(CURL *curl, const char *host, const char *addr, int len, + struct binbuf *b) +{ + char url[256]; + snprintf(url, sizeof url, + "http://%s/v1/machine:readmem?address=%s&length=%d", host, addr, + len); + b->len = 0; + curl_easy_reset(curl); + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, 3000L); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, bin_sink); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, b); + struct curl_slist *hdrs = NULL; + if (g_password) { + char pwhdr[160]; + snprintf(pwhdr, sizeof pwhdr, "X-Password: %s", g_password); + hdrs = curl_slist_append(NULL, pwhdr); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs); + } + bool ok = curl_easy_perform(curl) == CURLE_OK; + curl_slist_free_all(hdrs); + return ok; +} + // Readiness gate: the KERNAL zeroes $CC when it sits at a prompt with the // cursor flashing. Two consecutive ready reads guard against sampling a // transient zero mid-boot; the timeout covers programs that never return // to the prompt (games) - by then the internal reset is long done. static void wait_kernal_ready(CURL *curl, const char *host, int max_ms) { - char url[256]; - snprintf(url, sizeof url, - "http://%s/v1/machine:readmem?address=00CC&length=1", host); int ready = 0; for (int t = 0; t < max_ms && ready < 2; t += 500) { - struct binbuf b = {.len = 0}; - curl_easy_reset(curl); - curl_easy_setopt(curl, CURLOPT_URL, url); - curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, 1000L); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, bin_sink); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &b); - struct curl_slist *hdrs = NULL; - if (g_password) { - char pwhdr[160]; - snprintf(pwhdr, sizeof pwhdr, "X-Password: %s", g_password); - hdrs = curl_slist_append(NULL, pwhdr); - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs); - } - bool ok = curl_easy_perform(curl) == CURLE_OK; - curl_slist_free_all(hdrs); - if (ok && b.len >= 1 && b.data[0] == 0) + struct binbuf b; + if (readmem(curl, host, "00CC", 1, &b) && b.len >= 1 && + b.data[0] == 0) ready++; else ready = 0; @@ -640,6 +662,202 @@ static void wait_kernal_ready(CURL *curl, const char *host, int max_ms) } } +// The opposite wait: until the KERNAL is busy ($CC != 0), i.e. a typed +// LOAD has started; a load that fails at once never gets there, hence the +// short timeout. +static void wait_kernal_busy(CURL *curl, const char *host, int max_ms) +{ + for (int t = 0; t < max_ms; t += 250) { + struct binbuf b; + if (readmem(curl, host, "00CC", 1, &b) && b.len >= 1 && b.data[0]) + return; + SDL_Delay(250); + } +} + +// File name part of a dropped path; SDL hands over native separators. +static const char *path_base(const char *path) +{ + const char *b = path; + for (const char *p = path; *p; p++) + if (*p == '/' || *p == '\\') + b = p + 1; + return b; +} + +// ------------------------------------------------ persistent images (--store) +// +// The firmware's temp area is a RAM disk, gone at power-off, and RUN_IMG +// always lands there. With --store the image is uploaded into a folder on +// the Ultimate over FTP instead (the only upload route: the REST files API +// cannot write on any firmware; the FTP service is on by default), mounted +// from there read-write, and autostarted by the viewer itself: a path mount +// has no firmware autostart, so it resets the machine and types +// LOAD"*",8,1 / RUN over the keyboard channel behind the readiness gate. +// A file of the same name is replaced (FTP STOR overwrites). + +static bool curl_has_ftp(void) +{ + curl_version_info_data *v = curl_version_info(CURLVERSION_NOW); + for (const char *const *p = v->protocols; *p; p++) + if (!strcmp(*p, "ftp")) + return true; + return false; +} + +// Percent-encodes an Ultimate path for a URL one segment at a time, so the +// slashes survive; leading slashes are dropped (callers add the root). +static void url_path(CURL *curl, char *out, size_t size, const char *path) +{ + size_t n = 0; + out[0] = '\0'; + while (*path) { + while (*path == '/') + path++; + const char *end = strchr(path, '/'); + size_t seg = end ? (size_t)(end - path) : strlen(path); + if (!seg) + break; + char *esc = curl_easy_escape(curl, path, (int)seg); + n += (size_t)snprintf(out + n, n < size ? size - n : 0, "%s%s", + n ? "/" : "", esc ? esc : ""); + curl_free(esc); + path += seg; + } +} + +struct memsrc { + const uint8_t *p; + size_t left; +}; + +static size_t mem_read(char *buf, size_t size, size_t nmemb, void *userp) +{ + struct memsrc *m = userp; + size_t n = size * nmemb < m->left ? size * nmemb : m->left; + memcpy(buf, m->p, n); + m->p += n; + m->left -= n; + return n; +} + +// Upload progress feeds the window title; a quit aborts the transfer. +static int ftp_progress_cb(void *unused, curl_off_t dlt, curl_off_t dln, + curl_off_t ult, curl_off_t uln) +{ + (void)unused; (void)dlt; (void)dln; + if (ult > 0) + atomic_store(&g_run_pct, (int)(uln * 100 / ult)); + return atomic_load(&g_quit) ? 1 : 0; +} + +// C64U_FTP_PORT overrides port 21 for tests. Anonymous login, as the +// firmware's FTP service expects. +static bool ftp_upload(CURL *curl, const char *host, const char *rpath, + const uint8_t *data, size_t len) +{ + char ip[64], url[1024]; + host_ip(host, ip, sizeof ip); + const char *penv = getenv("C64U_FTP_PORT"); + snprintf(url, sizeof url, "ftp://%s:%s/%s", ip, penv ? penv : "21", + rpath); + struct memsrc src = {data, len}; + curl_easy_reset(curl); + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_UPLOAD, 1L); + curl_easy_setopt(curl, CURLOPT_READFUNCTION, mem_read); + curl_easy_setopt(curl, CURLOPT_READDATA, &src); + curl_easy_setopt(curl, CURLOPT_INFILESIZE_LARGE, (curl_off_t)len); + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, ftp_progress_cb); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 3000L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, 120000L); + CURLcode res = curl_easy_perform(curl); + if (res != CURLE_OK) + SDL_Log("FTP upload to %s failed: %s", url, curl_easy_strerror(res)); + return res == CURLE_OK; +} + +// Types text into the KERNAL keyboard buffer over a DMA connection. The +// buffer holds 10 bytes and the firmware does not chunk; batches of +// exactly 10 went missing twice on hardware (tail batch delivered, first +// one gone), so 8 leaves room for a key already sitting in the buffer. +#define KEYB_BATCH 8 +static bool dma_type(compat_sock s, const char *text) +{ + size_t len = strlen(text); + for (size_t i = 0; i < len; i += KEYB_BATCH) { + size_t n = len - i < KEYB_BATCH ? len - i : KEYB_BATCH; + if (!dma_send(s, DMA_CMD_KEYB, text + i, n, false)) + return false; + SDL_Delay(100); // let BASIC drain the buffer + } + return true; +} + +// Returns 200 on success, -1 otherwise (already logged). The machine has +// been reset by then, so the caller's cartridge parking applies. +static long store_image(CURL *curl, const char *host, const char *path, + const uint8_t *data, long len, char *resp) +{ + if (!curl_has_ftp()) { + SDL_Log("--store needs libcurl with FTP support"); + return -1; + } + const char *name = path_base(path); + char shown[512], rpath[512], folder[256]; // shown: for the log lines + snprintf(shown, sizeof shown, "%s/%s", g_store, name); + url_path(curl, folder, sizeof folder, g_store); + char *esc = curl_easy_escape(curl, name, 0); + snprintf(rpath, sizeof rpath, "%s%s%s", folder, folder[0] ? "/" : "", + esc ? esc : ""); + curl_free(esc); + + char url[1024]; + if (!ftp_upload(curl, host, rpath, data, (size_t)len)) + return -1; + atomic_store(&g_run_pct, -1); + SDL_Log("stored as %s (%ld bytes, replacing any old file)", shown, len); + + snprintf(url, sizeof url, + "http://%s/v1/drives/a:mount?image=/%s&mode=readwrite", host, + rpath); + long code = rest_put(curl, url, resp); + if (code != 200) { + SDL_Log("drives/a:mount HTTP %ld: %s", code, resp); + return -1; + } + if (!machine_ctl(host, "reset")) + return -1; + // reset zeroes the zero page, so $CC reads 0 mid-boot and the gate + // alone can pass before the KERNAL has set up (and wiped) the keyboard + // buffer: typed text vanished that way (verified 2026-09-27). The C64 + // boots in about 1.5 s; let it. + SDL_Delay(2500); + wait_kernal_ready(curl, host, 10000); + compat_sock s = dma_connect(host, 3); + if (s == COMPAT_BAD_SOCK) { + SDL_Log("mounted on drive A, but the DMA socket (port 64) is " + "unreachable: type LOAD\"*\",8,1 yourself"); + return -1; + } + bool typed = dma_type(s, "LOAD\"*\",8,1\r"); + if (typed) { + // the cursor is off while the drive works ($CC != 0): wait for + // the load to start, then for READY.; a KERNAL load runs at about + // 400 bytes/s + wait_kernal_busy(curl, host, 3000); + wait_kernal_ready(curl, host, 120000); + typed = dma_type(s, "RUN\r"); + } + compat_close(s); + if (!typed) + SDL_Log("keyboard channel dropped mid-autostart: %s", compat_neterr()); + else + SDL_Log("%s mounted read-write on drive A and started", shown); + return typed ? 200 : -1; +} + static bool run_file(const char *host, const char *path) { const char *ep = runner_for(path); @@ -649,28 +867,20 @@ static bool run_file(const char *host, const char *path) ".d71, .g71, .d81) can be run", path); return false; } - // the machine resets for a runner or a .d64 autostart; a plain mount - // leaves it alone - bool resets = ep || !strcmp(img, "d64"); - FILE *f = fopen(path, "rb"); - if (!f) { - SDL_Log("%s: %s", path, strerror(errno)); + bool store = img && g_store; + // the machine resets for a runner, a stored image or a .d64 autostart; + // a plain mount leaves it alone + bool resets = ep || store || !strcmp(img, "d64"); + size_t size; + uint8_t *data = SDL_LoadFile(path, &size); // UTF-8 path on every OS + if (!data) { + SDL_Log("%s: %s", path, SDL_GetError()); return false; } - fseek(f, 0, SEEK_END); - long len = ftell(f); - fseek(f, 0, SEEK_SET); + long len = (long)size; if (len <= 0 || len > 2 << 20) { // largest sensible .crt is ~1 MB SDL_Log("%s: unreasonable file size (%ld)", path, len); - fclose(f); - return false; - } - uint8_t *data = malloc((size_t)len); - bool readok = data && fread(data, 1, (size_t)len, f) == (size_t)len; - fclose(f); - if (!readok) { - SDL_Log("%s: short read", path); - free(data); + SDL_free(data); return false; } @@ -704,6 +914,8 @@ static bool run_file(const char *host, const char *path) SDL_Log("runners:%s: no response from Ultimate", ep); else SDL_Log("runners:%s HTTP %ld: %s", ep, code, resp); + } else if (store) { + code = store_image(curl, host, path, data, len, resp); } else if (resets) { // .d64: the firmware mounts and autostarts it (DMA socket RUN_IMG) code = -1; @@ -740,7 +952,7 @@ static bool run_file(const char *host, const char *path) else SDL_Log("drives/a:mount HTTP %ld: %s", code, resp); } - free(data); + SDL_free(data); if (parked) { wait_kernal_ready(curl, host, 10000); @@ -790,8 +1002,7 @@ static void run_file_async(const char *host, const char *path, kb->fd = COMPAT_BAD_SOCK; kb->reclaim = kb->enabled; } - const char *base = strrchr(path, '/'); - snprintf(g_run_name, sizeof g_run_name, "%s", base ? base + 1 : path); + snprintf(g_run_name, sizeof g_run_name, "%s", path_base(path)); atomic_store(&g_run_pct, 0); struct runjob *j = malloc(sizeof *j); snprintf(j->host, sizeof j->host, "%s", host); @@ -799,6 +1010,78 @@ static void run_file_async(const char *host, const char *path, SDL_DetachThread(SDL_CreateThread(run_thread, "runfile", j)); } +// ------------------------------------------------------ headless type/screen +// +// The scripting pair: --type puts text into the KERNAL buffer (the same +// channel the window uses), --screen reads screen RAM back. Together they +// let a script or an agent drive BASIC and check the result without a +// window. + +static bool type_text(const char *host, const char *text) +{ + char buf[1024]; + size_t n = 0; + for (const char *p = text; *p && n < sizeof buf - 1; p++) { + int c; + if (*p == '\\' && p[1] == 'n') { // "\n" in the argument = RETURN + c = 0x0D; + p++; + } else if (*p == '\n') { + c = 0x0D; + } else { + // plain letters either way: BASIC wants unshifted PETSCII, and + // the window's Shift-means-graphics convention has no use here + c = ascii_to_petscii((unsigned char)SDL_tolower(*p)); + } + if (c > 0) + buf[n++] = (char)c; + } + buf[n] = '\0'; + compat_sock s = dma_connect(host, 3); + if (s == COMPAT_BAD_SOCK) { + SDL_Log("DMA socket (port 64) unreachable; is the Ultimate DMA " + "Service enabled?"); + return false; + } + bool ok = dma_type(s, buf); + if (!ok) + SDL_Log("typing failed: %s", compat_neterr()); + compat_close(s); + return ok; +} + +// Screen codes to text, upper-case/graphics set assumed (the default); +// graphics symbols print as '#', reverse video is dropped. +static char screen_char(uint8_t sc) +{ + sc &= 0x7F; + if (sc < 32) + return (char)(sc + 64); // @ A-Z [ pound ] arrows + if (sc < 64) + return (char)sc; // space, punctuation, digits + return '#'; +} + +static bool print_screen(const char *host) +{ + CURL *curl = curl_easy_init(); + struct binbuf b; + bool ok = readmem(curl, host, "0400", 1000, &b) && b.len == 1000; + curl_easy_cleanup(curl); + if (!ok) { + SDL_Log("machine:readmem failed (%d bytes)", b.len); + return false; + } + for (int row = 0; row < 25; row++) { + char line[41]; + for (int col = 0; col < 40; col++) + line[col] = screen_char(b.data[row * 40 + col]); + line[40] = '\0'; + puts(line); + } + return true; +} + // ----------------------------------------------- matrix keyboard (REST) // // When the firmware supports machine:input (probed by the keepalive thread), @@ -960,8 +1243,9 @@ static void usage(const char *argv0) fprintf(stderr, "usage: %s --host IP [--dest IP[:PORT]] [--port N] [--scale N]\n" " [--multicast] [--no-start] [--no-audio] [--no-keyb]\n" - " [--password PW] [--do ACTION] [--dump FILE.ppm]\n" - " [--term-test] [--discover] [--verbose] [--version]\n" + " [--password PW] [--do ACTION] [--run FILE] [--store DIR]\n" + " [--type TEXT] [--screen] [--dump FILE.ppm] [--term-test]\n" + " [--discover] [--verbose] [--version]\n" " --host C64 Ultimate address (or set C64U_HOST; omit to " "auto-discover)\n" " --dest where the Ultimate should send the streams (default: auto;\n" @@ -975,6 +1259,11 @@ static void usage(const char *argv0) " --run run a .prg/.crt/.sid/.d64 on the machine, then exit\n" " (.g64/.d71/.g71/.d81 are mounted without autostart)\n" " (in the window: drop the file onto it instead)\n" + " --store keep dropped disk images: upload them into this folder on\n" + " the Ultimate (FTP, e.g. /Temp or /Usb0/games), mount from\n" + " there read-write and autostart (or set C64U_STORE)\n" + " --type type TEXT into the C64 (\\n = RETURN), then exit\n" + " --screen print the C64 text screen (40x25), then exit\n" " --no-start don't issue REST start/stop (e.g. mock stream test)\n" " --dump write first complete frame as PPM, then exit\n" " --term-test print the telnet menu screen as text, then exit\n" @@ -1000,6 +1289,12 @@ int main(int argc, char **argv) cfg.do_action = argv[++i]; else if (!strcmp(argv[i], "--run") && i + 1 < argc) cfg.run_path = argv[++i]; + else if (!strcmp(argv[i], "--store") && i + 1 < argc) + g_store = argv[++i]; + else if (!strcmp(argv[i], "--type") && i + 1 < argc) + cfg.type_text = argv[++i]; + else if (!strcmp(argv[i], "--screen")) + cfg.screen = true; else if (!strcmp(argv[i], "--dest") && i + 1 < argc) cfg.dest = argv[++i]; else if (!strcmp(argv[i], "--port") && i + 1 < argc) @@ -1036,6 +1331,10 @@ int main(int argc, char **argv) if (!cfg.password) cfg.password = getenv("C64U_PASSWORD"); g_password = cfg.password; + if (!g_store) + g_store = getenv("C64U_STORE"); + if (g_store && !*g_store) + g_store = NULL; if (g_password) // discovery reads the env; SDL wraps the C runtime setenv portably SDL_setenv_unsafe("C64U_PASSWORD", g_password, 1); @@ -1078,7 +1377,7 @@ int main(int argc, char **argv) // immediately and discovers in the background instead. static char auto_host[46]; bool headless = cfg.dump_path || cfg.term_test || cfg.do_action || - cfg.run_path; + cfg.run_path || cfg.type_text || cfg.screen; if (!cfg.host && !cfg.no_start && headless) { struct discovered found[DISCOVER_MAX]; fprintf(stderr, "no --host given, discovering...\n"); @@ -1104,6 +1403,13 @@ int main(int argc, char **argv) if (cfg.run_path) return run_file(cfg.host, cfg.run_path) ? 0 : 1; + if (cfg.type_text && !type_text(cfg.host, cfg.type_text)) + return 1; + if (cfg.screen) + return print_screen(cfg.host) ? 0 : 1; + if (cfg.type_text) + return 0; + if (cfg.term_test) return run_term_test(cfg.host); @@ -1424,6 +1730,12 @@ int main(int argc, char **argv) // a stale frame would be shown scaled and off-center term_present = true; } else if (ev.type == SDL_EVENT_DROP_FILE) { + // open question for a modifier-selected store: does + // Wayland report the modifier state during a drag? + if (cfg.verbose) + SDL_Log("drop: shift %s", (SDL_GetModState() & + SDL_KMOD_SHIFT) + ? "held" : "not held"); if (!cfg.no_start && cfg.host && ev.drop.data) run_file_async(cfg.host, ev.drop.data, &kb); } else if (ev.type == SDL_EVENT_TEXT_INPUT && !help_active) { diff --git a/tests/fakeultimate.py b/tests/fakeultimate.py index b50fd2b..fa8a5f0 100644 --- a/tests/fakeultimate.py +++ b/tests/fakeultimate.py @@ -11,7 +11,12 @@ DMA cmd=FFxx len= (AUTHENTICATE logs pw=) -Usage: fakeultimate.py [dma-port] +With a fifth argument it also serves a minimal passive-mode FTP server on +that port (c64uv reads C64U_FTP_PORT), logging every upload as + + FTP STOR len= + +Usage: fakeultimate.py [dma-port [ftp-port]] """ import http.server import json @@ -37,7 +42,7 @@ def _recv_exact(conn, n): return buf -def _dma_client(conn): +def _dma_client(conn, _ip): with conn: while True: hdr = _recv_exact(conn, 4) @@ -60,14 +65,68 @@ def _dma_client(conn): _log_line(f"DMA cmd={cmd:04X} len={n}") -def _dma_server(ip, port): +def _listen(ip, port): srv = socket.socket() srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((ip, port)) srv.listen() + return srv + + +def _serve(ip, port, handler): + srv = _listen(ip, port) while True: conn, _ = srv.accept() - threading.Thread(target=_dma_client, args=(conn,), daemon=True).start() + threading.Thread(target=handler, args=(conn, ip), daemon=True).start() + + +def _ftp_client(conn, ip): + """Just enough FTP for a libcurl anonymous upload: login, CWD, passive + data connection (EPSV or PASV), STOR.""" + cwd = "/" + data_srv = None + with conn, conn.makefile("rwb", buffering=0) as f: + f.write(b"220 fakeultimate\r\n") + while True: + line = f.readline() + if not line: + return + cmd, _, arg = line.decode(errors="replace").strip().partition(" ") + cmd = cmd.upper() + if cmd in ("USER", ): + f.write(b"331 ok\r\n") + elif cmd in ("PASS", ): + f.write(b"230 ok\r\n") + elif cmd == "PWD": + f.write(f'257 "{cwd}"\r\n'.encode()) + elif cmd == "CWD": + cwd = arg if arg.startswith("/") else cwd.rstrip("/") + "/" + arg + f.write(b"250 ok\r\n") + elif cmd in ("EPSV", "PASV"): + data_srv = _listen(ip, 0) + port = data_srv.getsockname()[1] + if cmd == "EPSV": + f.write(f"229 ok (|||{port}|)\r\n".encode()) + else: + f.write(f"227 ok ({ip.replace('.', ',')},{port >> 8}," + f"{port & 255})\r\n".encode()) + elif cmd == "STOR": + f.write(b"150 ok\r\n") + data, _ = data_srv.accept() + data_srv.close() + n = 0 + chunk = data.recv(65536) + while chunk: + n += len(chunk) + chunk = data.recv(65536) + data.close() + _log_line(f"FTP STOR {cwd.rstrip('/')}/{arg} len={n}") + f.write(b"226 ok\r\n") + elif cmd == "QUIT": + f.write(b"221 bye\r\n") + return + else: + f.write(b"200 ok\r\n") # TYPE and friends class Handler(http.server.BaseHTTPRequestHandler): @@ -105,6 +164,11 @@ def do_GET(self): "Cartridge": {"current": "Retro Replay", "presets": ["", "Retro Replay"], "default": ""}}, "errors": []}) + elif self.path.startswith("/v1/machine:readmem?address=0400"): + # a text screen: READY. on row 0 (screen codes), blanks elsewhere + screen = bytearray(b"\x20" * 1000) + screen[0:6] = bytes(ord(c) - 64 for c in "READY") + b"\x2e" + self._reply(200, bytes(screen), "application/octet-stream") elif self.path.startswith("/v1/machine:readmem"): self._reply(200, b"\x00", "application/octet-stream") elif self.path == "/v1/machine:input": @@ -124,9 +188,10 @@ def do_PUT(self): if __name__ == "__main__": - if len(sys.argv) > 4: - threading.Thread(target=_dma_server, - args=(sys.argv[1], int(sys.argv[4])), - daemon=True).start() + for i, handler in ((4, _dma_client), (5, _ftp_client)): + if len(sys.argv) > i: + threading.Thread(target=_serve, + args=(sys.argv[1], int(sys.argv[i]), handler), + daemon=True).start() http.server.HTTPServer((sys.argv[1], int(sys.argv[2])), Handler).serve_forever() diff --git a/tests/integration.sh b/tests/integration.sh index 4f4d744..8d5e529 100644 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -58,7 +58,7 @@ echo "mock stream test passed" # A fake Ultimate on one loopback address must be found; a plain web server # on another must be rejected (real subnets are full of port-80 responders). -python3 tests/fakeultimate.py 127.0.0.42 8064 "$out/disc.log" 8065 & +python3 tests/fakeultimate.py 127.0.0.42 8064 "$out/disc.log" 8065 8067 & pids+=($!) python3 -m http.server 8064 --bind 127.0.0.99 >/dev/null 2>&1 & pids+=($!) @@ -180,6 +180,50 @@ grep -q "POST /v1/drives/a:mount?type=d81 body=819200" "$out/disc.log" grep -q "Cartridge" "$out/disc.log" && exit 1 # a plain mount parks nothing echo "disk image test passed" +# ------------------------------------------------------------ stored images +# With --store the image is FTP-uploaded into the folder (replacing a +# same-named file), mounted by path read-write, and autostarted by the +# viewer: reset (with cartridge parking), readiness gate, LOAD"*",8,1 and +# RUN typed in <= 8-byte KEYB batches. + +: > "$out/disc.log" +C64U_DMA_PORT=8065 C64U_FTP_PORT=8067 timeout 60 \ + ./c64uv --host 127.0.0.42:8064 --store "/Usb0/my games" --run "$out/disk.d64" +python3 - "$out/disc.log" <<'EOF' +import sys +log = open(sys.argv[1]).read().splitlines() +want = ["PUT /v1/configs/C64%20and%20Cartridge%20Settings/Cartridge?value=", + "FTP STOR /Usb0/my games/disk.d64 len=174848", + "PUT /v1/drives/a:mount?image=/Usb0/my%20games/disk.d64&mode=readwrite", + "PUT /v1/machine:reset", + "GET /v1/machine:readmem?address=00CC&length=1", + "DMA cmd=FF03 len=8", + "DMA cmd=FF03 len=4", + "GET /v1/machine:readmem?address=00CC&length=1", + "DMA cmd=FF03 len=4", + "PUT /v1/configs/C64%20and%20Cartridge%20Settings/Cartridge?value=Retro%20Replay"] +i = 0 +for line in log: + if i < len(want) and line == want[i]: + i += 1 +assert i == len(want), f"missing/mis-ordered step {i}: {want[i]}\nlog: {log}" +assert not any("FF0B" in l for l in log), log +EOF +echo "stored image test passed" + +# ------------------------------------------------------------ type + screen +# --type goes out as KEYB frames in <= 8-byte batches with \n as RETURN; +# --screen prints the 40x25 text screen read from $0400. + +: > "$out/disc.log" +C64U_DMA_PORT=8065 timeout 10 ./c64uv --host 127.0.0.42:8064 --type 'PRINT "HI"\n' +grep -c "DMA cmd=FF03 len=8" "$out/disc.log" | grep -q '^1$' +grep -q "DMA cmd=FF03 len=3$" "$out/disc.log" +timeout 10 ./c64uv --host 127.0.0.42:8064 --screen > "$out/screen.txt" +test "$(wc -l < "$out/screen.txt")" = 25 +head -1 "$out/screen.txt" | grep -q '^READY\. *$' +echo "type/screen test passed" + # ------------------------------------------------------- exit with host gone # A powered-off Ultimate answers nothing: the tarpit accepts connections and # never replies, which leaves the keepalive thread inside a REST call. A diff --git a/tools/genicon.py b/tools/genicon.py index ca5dac0..37fd749 100644 --- a/tools/genicon.py +++ b/tools/genicon.py @@ -9,10 +9,13 @@ python3 tools/genicon.py writes assets/c64uv.svg and assets/c64uv-.png (via rsvg-convert; -the PNGs exist because some launchers only pick up bitmap icon themes). +the PNGs exist because some launchers only pick up bitmap icon themes) and +assets/c64uv.ico (the PNGs wrapped in an ICO container for the Windows +exe; Vista+ reads PNG entries). """ import pathlib import re +import struct import subprocess PNG_SIZES = [16, 32, 48, 64, 128, 256] @@ -52,6 +55,22 @@ def text_rects(font, s, x0, y0, scale): return rects +def write_ico(assets): + """ICONDIR + one ICONDIRENTRY per size, each pointing at the PNG bytes.""" + pngs = [(size, (assets / f"c64uv-{size}.png").read_bytes()) for size in PNG_SIZES] + header = struct.pack(" 1 else "c64uv-hello.d64" + img = bytearray(sum(SECTORS) * 256) + + # file data: track 17 from sector 0, chained 254 bytes per sector + chunks = [PROGRAM[i:i + 254] for i in range(0, len(PROGRAM), 254)] + used = {(18, 0), (18, 1)} + for i, chunk in enumerate(chunks): + pos = offset(17, i) + last = i == len(chunks) - 1 + img[pos:pos + 2] = bytes([0, len(chunk) + 1]) if last else bytes([17, i + 1]) + img[pos + 2:pos + 2 + len(chunk)] = chunk + used.add((17, i)) + + # BAM at 18/0: dir pointer, format, per-track free count + 3 bitmap bytes + bam = offset(18, 0) + img[bam:bam + 4] = bytes([18, 1, 0x41, 0]) + for t in range(1, 36): + free = [s for s in range(SECTORS[t - 1]) if (t, s) not in used] + bits = sum(1 << s for s in free) + img[bam + t * 4:bam + t * 4 + 4] = bytes([len(free)]) + bits.to_bytes(3, "little") + name = b"C64UV HELLO".ljust(16, b"\xa0") + img[bam + 0x90:bam + 0xAB] = name + b"\xa0\xa0" + b"UV" + b"\xa0" + b"2A" + b"\xa0" * 4 + + # directory at 18/1: one PRG entry pointing at 17/0 + d = offset(18, 1) + img[d:d + 2] = bytes([0, 0xFF]) + img[d + 2:d + 5] = bytes([0x82, 17, 0]) + img[d + 5:d + 21] = b"HELLO".ljust(16, b"\xa0") + img[d + 30:d + 32] = struct.pack(" 1 else ''})") + + +if __name__ == "__main__": + main()