From 5080e9f9a962f003e492297a453b3ad85812fbf8 Mon Sep 17 00:00:00 2001 From: Michal Skorupka <4363852+crustovsky@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:14:44 +0200 Subject: [PATCH 1/7] File drop: mount and autostart .d64 images, mount the other image types A dropped .d64 goes out as one RUN_IMG frame on the firmware's DMA socket (port 64, 24-bit length): the firmware saves it as /temp/tcpimage.d64, mounts it on drive A, resets the machine and types LOAD"*",8,1 and RUN itself. Verified on firmware 1.1.0 by reading screen RAM back. Cartridge parking and the readiness gate apply as for a program run. .g64/.d71/.g71/ .d81 have no firmware autostart, so they are mounted over POST /v1/drives/a:mount and the machine is left alone. The port-64 code moves into a small DMA-socket helper shared with the keyboard channel. It now authenticates when a network password is set (AUTHENTICATE 0xFF1F, one-byte reply), which the keyboard path never did; C64U_DMA_PORT redirects the port for tests. fakeultimate.py grows a DMA socket listener that logs every frame, and the integration suite checks the .d64 frame with cartridge parking and gate, the password handshake order, and the REST mount for a .d81. Help overlay (render-checked), --help, README and CLAUDE.md document the new drop types and the firmware facts. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GCACXGV72EjRZUUKapybmQ --- CLAUDE.md | 15 ++++ README.md | 17 ++-- src/keys.c | 2 +- src/main.c | 177 +++++++++++++++++++++++++++++++++++------- tests/fakeultimate.py | 63 ++++++++++++++- tests/integration.sh | 39 +++++++++- 6 files changed, 277 insertions(+), 36 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ab6397e..6ee9049 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,6 +146,21 @@ both on every push and PR. programs that never return to the prompt). `run_crt` runs the posted cart on purpose, so no parking there. `$CC` reads 0x00 at the READY prompt, verified. +- **Disk images (verified on 1.1.0)**: the DMA socket's `RUN_IMG` (0xFF0B, + header `0B FF ` then the image) saves the + payload as `/temp/tcpimage.d64`, mounts it on drive A and runs + `C64_DRIVE_LOAD` with `RUNCODE_MOUNT_LOAD_RUN`: the firmware resets, + types `LOAD"*",8,1` and `RUN` itself (screen RAM confirms). Payload cap + is `SOCKET_BUFFER_SIZE` = 200000 bytes (every .d64 variant fits; the + firmware silently truncates beyond). Always saved as .d64, so only that + type autostarts; `POST /v1/drives/a:mount?type=` + with the image as the body mounts the others (lands as `/Temp/temp0000`). + `MOUNT_IMG` (0xFF0A) is the socket twin of that mount. With a network + password the socket needs `AUTHENTICATE` (0xFF1F, password as payload, + one-byte reply 1/0, 1 s throttle on failure) before any other command, + or the firmware drops the connection; `dma_connect` does it for both + the keyboard channel and image runs. `C64U_DMA_PORT` redirects port 64 + for tests (fakeultimate.py logs `DMA cmd=FFxx len=N`). ## Dev workflow (no hardware needed) diff --git a/README.md b/README.md index 6b19076..412b66c 100644 --- a/README.md +++ b/README.md @@ -89,11 +89,18 @@ refuses to start a stream toward an address missing from its ARP table. | Ctrl+M | press the Ultimate's menu button (the on-screen menu then answers the physical keyboard only; use F9 to drive a menu from the viewer) | | Ctrl+Q | quit | -**Drop a `.prg`, `.crt`, or `.sid` file onto the window** and the Ultimate -runs it (DMA program run, cartridge run, or SID player). If a cartridge is -configured on the machine, it is parked for the run and restored afterwards, -so a freezer cart won't hijack the program's reset; the cart is back on your -next manual reset. The same works headless: `c64uv --run game.prg`. +**Drop a `.prg`, `.crt`, `.sid`, or `.d64` file onto the window** and the +Ultimate runs it (DMA program run, cartridge run, SID player, or for a +`.d64`: mount on drive A, reset, `LOAD"*",8,1` and `RUN`, all done by the +firmware over the DMA socket). If a cartridge is configured on the machine, +it is parked for the run and restored afterwards, so a freezer cart won't +hijack the program's reset; the cart is back on your next manual reset. The +same works headless: `c64uv --run game.prg`. + +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. The same machine controls work headless: `c64uv --do reset` (also `reboot`, `pause`, `resume`, `menu`, `poweroff`) issues one REST call and exits. If diff --git a/src/keys.c b/src/keys.c index b1d295b..d342488 100644 --- a/src/keys.c +++ b/src/keys.c @@ -133,7 +133,7 @@ const struct viewer_binding viewer_bindings[] = { // ASCII only: the overlay renders through the 8x8 basic font {.label = "Tab, PgUp, Ins, End", .desc = "CTRL, RESTORE, pound, + (matrix)", .action = VA_INFO, .gap = true}, - {.label = "file drop", .desc = "run a .prg/.crt/.sid on the machine", + {.label = "file drop", .desc = "run .prg/.crt/.sid/.d64 on the C64", .action = VA_INFO}, {.key = SDLK_F9, .label = "F9", .desc = "toggle the Ultimate menu view", .action = VA_MENU_VIEW}, diff --git a/src/main.c b/src/main.c index 985e95d..e550d4f 100644 --- a/src/main.c +++ b/src/main.c @@ -25,7 +25,7 @@ struct config { const char *host; // Ultimate hostname/IP for REST 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, then exit + const char *run_path; // one-shot: run this .prg/.crt/.sid/.d64, 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; @@ -339,13 +339,80 @@ static int discover_thread(void *arg) return 0; } +// ------------------------------------------------------ DMA socket (port 64) +// +// Firmware "socket DMA" service: little-endian command word, u16 payload +// length (the image and cartridge commands carry a third length byte), +// payload. With a network password set the first frame must be +// AUTHENTICATE, answered with one byte (1 = accepted); anything else on an +// unauthenticated connection makes the firmware drop it. + +#define DMA_CMD_KEYB 0xFF03 +#define DMA_CMD_DMAWRITE 0xFF06 +#define DMA_CMD_RUN_IMG 0xFF0B // mount a .d64 on drive A, reset, LOAD"*",8,1, RUN +#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) +{ + char ip[64]; + snprintf(ip, sizeof ip, "%s", host); + char *colon = strchr(ip, ':'); + if (colon) + *colon = '\0'; + const char *penv = getenv("C64U_DMA_PORT"); + return compat_tcp_connect(ip, penv ? (uint16_t)atoi(penv) : 64, + timeout_s); +} + +static bool send_all(compat_sock s, const void *data, size_t len) +{ + const uint8_t *p = data; + while (len > 0) { + int n = compat_send(s, p, len); + if (n <= 0) + return false; + p += n; + len -= (size_t)n; + } + return true; +} + +// One frame; len24 adds the third length byte (RUN_IMG, MOUNT_IMG, RUN_CRT). +static bool dma_send(compat_sock s, uint16_t cmd, const void *data, + size_t len, bool len24) +{ + uint8_t hdr[5] = {cmd & 0xFF, cmd >> 8, len & 0xFF, (len >> 8) & 0xFF, + (len >> 16) & 0xFF}; + return send_all(s, hdr, len24 ? 5 : 4) && send_all(s, data, len); +} + +// Connects and, when a password is set, authenticates. COMPAT_BAD_SOCK on +// failure (logged when the password was refused). +static compat_sock dma_connect(const char *host, int timeout_s) +{ + compat_sock s = dma_connect_raw(host, timeout_s); + if (s == COMPAT_BAD_SOCK || !g_password) + return s; + uint8_t ok = 0; + if (dma_send(s, DMA_CMD_AUTHENTICATE, g_password, strlen(g_password), + false) && + compat_wait_readable(&s, 1, 3000) > 0 && + compat_recv_nowait(s, &ok, 1) == 1 && ok == 1) + return s; + SDL_Log("DMA socket: the Ultimate refused the network password"); + compat_close(s); + return COMPAT_BAD_SOCK; +} + // ------------------------------------------------------- keyboard passthrough // -// TCP port 64, firmware "socket DMA" protocol: little-endian command word, -// u16 payload length, payload. KEYB (0xFF03) drops chars into the KERNAL -// keyboard buffer ($0277/$C6) - works for BASIC and anything else that reads -// input the normal way; games polling the matrix won't see it (needs the -// machine:input firmware feature, not shipped yet). +// KEYB (0xFF03) drops chars into the KERNAL keyboard buffer ($0277/$C6) - +// works for BASIC and anything else that reads input the normal way; games +// polling the matrix won't see it (needs the machine:input firmware +// feature, not shipped yet). struct keyb { compat_sock fd; // COMPAT_BAD_SOCK when disconnected @@ -361,7 +428,7 @@ static void keyb_try_connect(struct keyb *k) (k->last_try != 0 && SDL_GetTicks() - k->last_try < 3000)) return; k->last_try = SDL_GetTicks(); - k->fd = compat_tcp_connect(k->host, 64, 1); + k->fd = dma_connect(k->host, 1); if (k->fd != COMPAT_BAD_SOCK) SDL_Log("keyboard channel connected (port 64)"); } @@ -371,13 +438,7 @@ static void keyb_raw(struct keyb *k, Uint16 cmd, const Uint8 *data, int n) keyb_try_connect(k); if (k->fd == COMPAT_BAD_SOCK) return; - Uint8 frame[4 + 16]; - frame[0] = cmd & 0xFF; - frame[1] = cmd >> 8; - frame[2] = (Uint8)n; - frame[3] = 0; - memcpy(frame + 4, data, (size_t)n); - if (compat_send(k->fd, frame, 4 + (size_t)n) < 0) { + if (!dma_send(k->fd, cmd, data, (size_t)n, false)) { compat_close(k->fd); k->fd = COMPAT_BAD_SOCK; // reconnect on next keypress } @@ -450,6 +511,23 @@ static const char *runner_for(const char *path) return NULL; } +// Disk images. A .d64 goes through the DMA socket's RUN_IMG, which mounts +// it on drive A and has the firmware reset the machine, type LOAD"*",8,1 +// and RUN (verified on 1.1.0; the image lands as /temp/tcpimage.d64, so +// writes never reach the dropped file). The other types the mount API +// accepts have no firmware autostart and are only mounted. +static const char *image_type_for(const char *path) +{ + static const char *const types[] = {"d64", "g64", "d71", "g71", "d81"}; + const char *dot = strrchr(path, '.'); + if (!dot) + return NULL; + for (size_t i = 0; i < sizeof types / sizeof types[0]; i++) + if (!SDL_strcasecmp(dot + 1, types[i])) + return types[i]; + return NULL; +} + struct binbuf { uint8_t data[16]; int len; @@ -501,10 +579,15 @@ static void wait_kernal_ready(CURL *curl, const char *host, int max_ms) static bool run_file(const char *host, const char *path) { const char *ep = runner_for(path); - if (!ep) { - SDL_Log("%s: only .prg, .crt and .sid files can be run", path); + const char *img = ep ? NULL : image_type_for(path); + if (!ep && !img) { + SDL_Log("%s: only .prg, .crt, .sid and disk images (.d64, .g64, " + ".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)); @@ -532,7 +615,7 @@ static bool run_file(const char *host, const char *path) // cartridge parking (not for .crt: that one runs a cart on purpose) char cart[128]; bool parked = false; - if (strcmp(ep, "run_crt") != 0) { + if (resets && (!ep || strcmp(ep, "run_crt") != 0)) { snprintf(url, sizeof url, "http://%s/v1/%s", host, CART_CFG_PATH); if (rest_req(curl, "GET", url, NULL, 0, NULL, 3000, resp) == 200 && json_find_str(resp, "current", cart, sizeof cart) && cart[0]) { @@ -544,17 +627,54 @@ static bool run_file(const char *host, const char *path) } } - snprintf(url, sizeof url, "http://%s/v1/runners:%s", host, ep); - // generous timeout: the firmware saves the file before answering - long code = rest_req(curl, "POST", url, data, len, "application/octet-stream", - 15000, resp); + long code; + if (ep) { + snprintf(url, sizeof url, "http://%s/v1/runners:%s", host, ep); + // generous timeout: the firmware saves the file before answering + code = rest_req(curl, "POST", url, data, len, + "application/octet-stream", 15000, resp); + if (code == 200) + SDL_Log("runners:%s %s OK (%ld bytes)", ep, path, len); + else if (code == -1) + SDL_Log("runners:%s: no response from Ultimate", ep); + else + SDL_Log("runners:%s HTTP %ld: %s", ep, code, resp); + } else if (resets) { + // .d64: the firmware mounts and autostarts it (DMA socket RUN_IMG) + code = -1; + if (len > DMA_MAX_PAYLOAD) { + SDL_Log("%s: %ld bytes exceeds the DMA socket's %d byte limit", + path, len, DMA_MAX_PAYLOAD); + } else { + compat_sock s = dma_connect(host, 3); + if (s == COMPAT_BAD_SOCK) + SDL_Log("%s: DMA socket (port 64) unreachable; is the " + "Ultimate DMA Service enabled?", path); + else if (dma_send(s, DMA_CMD_RUN_IMG, data, (size_t)len, true)) + code = 200; + else + SDL_Log("%s: DMA socket send failed: %s", path, + compat_neterr()); + compat_close(s); + } + if (code == 200) + SDL_Log("%s mounted on drive A and started (%ld bytes)", path, + len); + } else { + // other image types: mount only, the machine keeps running + snprintf(url, sizeof url, "http://%s/v1/drives/a:mount?type=%s", host, + img); + code = rest_req(curl, "POST", url, data, len, + "application/octet-stream", 15000, resp); + if (code == 200) + SDL_Log("%s mounted on drive A (%ld bytes); no autostart for " + ".%s, type LOAD\"*\",8,1 yourself", path, len, img); + else if (code == -1) + SDL_Log("drives/a:mount: no response from Ultimate"); + else + SDL_Log("drives/a:mount HTTP %ld: %s", code, resp); + } free(data); - if (code == 200) - SDL_Log("runners:%s %s OK (%ld bytes)", ep, path, len); - else if (code == -1) - SDL_Log("runners:%s: no response from Ultimate", ep); - else - SDL_Log("runners:%s HTTP %ld: %s", ep, code, resp); if (parked) { wait_kernal_ready(curl, host, 10000); @@ -762,7 +882,8 @@ static void usage(const char *argv0) " --password network password (or set C64U_PASSWORD)\n" " --do one machine action, then exit: reset reboot pause\n" " resume menu poweroff\n" - " --run run a .prg/.crt/.sid on the machine, then exit\n" + " --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" " --no-start don't issue REST start/stop (e.g. mock stream test)\n" " --dump write first complete frame as PPM, then exit\n" diff --git a/tests/fakeultimate.py b/tests/fakeultimate.py index 21cb6a6..b50fd2b 100644 --- a/tests/fakeultimate.py +++ b/tests/fakeultimate.py @@ -6,11 +6,68 @@ METHOD PATH?QUERY [pw=] [body=] -Usage: fakeultimate.py +With a fourth argument it also serves the firmware's DMA socket protocol on +that TCP port (c64uv reads C64U_DMA_PORT), logging every frame as + + DMA cmd=FFxx len= (AUTHENTICATE logs pw=) + +Usage: fakeultimate.py [dma-port] """ import http.server import json +import socket import sys +import threading + +LEN24_CMDS = (0xFF0A, 0xFF0B, 0xFF0D) # MOUNT_IMG, RUN_IMG, RUN_CRT + + +def _log_line(line): + with open(sys.argv[3], "a") as f: + f.write(line + "\n") + + +def _recv_exact(conn, n): + buf = b"" + while len(buf) < n: + chunk = conn.recv(n - len(buf)) + if not chunk: + return None + buf += chunk + return buf + + +def _dma_client(conn): + with conn: + while True: + hdr = _recv_exact(conn, 4) + if not hdr: + return + cmd = hdr[0] | hdr[1] << 8 + n = hdr[2] | hdr[3] << 8 + if cmd in LEN24_CMDS: + extra = _recv_exact(conn, 1) + if extra is None: + return + n |= extra[0] << 16 + payload = _recv_exact(conn, n) if n else b"" + if payload is None: + return + if cmd == 0xFF1F: # AUTHENTICATE: the firmware answers one byte + _log_line(f"DMA cmd=FF1F pw={payload.decode(errors='replace')}") + conn.sendall(b"\x01") + else: + _log_line(f"DMA cmd={cmd:04X} len={n}") + + +def _dma_server(ip, port): + srv = socket.socket() + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind((ip, port)) + srv.listen() + while True: + conn, _ = srv.accept() + threading.Thread(target=_dma_client, args=(conn,), daemon=True).start() class Handler(http.server.BaseHTTPRequestHandler): @@ -67,5 +124,9 @@ 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() http.server.HTTPServer((sys.argv[1], int(sys.argv[2])), Handler).serve_forever() diff --git a/tests/integration.sh b/tests/integration.sh index d696f48..f81c5e8 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" & +python3 tests/fakeultimate.py 127.0.0.42 8064 "$out/disc.log" 8065 & pids+=($!) python3 -m http.server 8064 --bind 127.0.0.99 >/dev/null 2>&1 & pids+=($!) @@ -143,6 +143,43 @@ touch "$out/note.txt" ./c64uv --host 127.0.0.42:8064 --run "$out/note.txt" 2>/dev/null && exit 1 echo "file run test passed" +# ------------------------------------------------------------- disk images +# A .d64 goes out as one RUN_IMG frame on the DMA socket (24-bit length), +# with the same cartridge parking and readiness gate as a program run; with +# a password set the connection authenticates first. Other image types are +# mounted over REST without touching the machine. + +head -c 174848 /dev/urandom > "$out/disk.d64" +: > "$out/disc.log" +C64U_DMA_PORT=8065 timeout 30 ./c64uv --host 127.0.0.42:8064 --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=", + "DMA cmd=FF0B len=174848", + "GET /v1/machine:readmem?address=00CC&length=1", + "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("drives" in l for l in log), log +EOF +: > "$out/disc.log" +C64U_DMA_PORT=8065 C64U_PASSWORD=envpw timeout 30 ./c64uv --host 127.0.0.42:8064 --run "$out/disk.d64" +python3 - "$out/disc.log" <<'EOF' +import sys +log = open(sys.argv[1]).read().splitlines() +assert log.index("DMA cmd=FF1F pw=envpw") < log.index("DMA cmd=FF0B len=174848"), log +EOF +head -c 819200 /dev/urandom > "$out/disk.d81" +: > "$out/disc.log" +timeout 10 ./c64uv --host 127.0.0.42:8064 --run "$out/disk.d81" +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" + # ---------------------------------------------------------------------- help # --help must print the shared binding table (same rows the F10 overlay # renders), so a missing row here means the overlay lost it too. From 0a29a606c74b9d10d2ffc498fbf0089444f38ca4 Mon Sep 17 00:00:00 2001 From: Michal Skorupka <4363852+crustovsky@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:15:47 +0200 Subject: [PATCH 2/7] README: say which Ultimate service each viewer feature needs Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GCACXGV72EjRZUUKapybmQ --- README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 412b66c..ec34c5e 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,14 @@ cable. | Service | Needed for | |---|---| -| Web Remote Control Service (REST API) | starting/stopping the streams | -| Ultimate DMA Service | keyboard input (port 64) | -| Telnet Remote Menu Service | the F9 menu view (port 23) | +| 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` | +| 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 | + +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 +picture and sound; add the DMA service to type, and telnet to browse the +Ultimate's menu from the viewer. ### Wired Ethernet is required for the streams From a667013ed36a303f4bdaac5ac076d041d4c99611 Mon Sep 17 00:00:00 2001 From: Michal Skorupka <4363852+crustovsky@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:22:27 +0200 Subject: [PATCH 3/7] DMA socket: keep sending a disk image while the Ultimate drains it slowly The connect timeout (1-3 s) doubles as the socket's send timeout, and the Ultimate's TCP stack takes in a 175 KB image slowly enough that a single send() can hit it: "DMA socket send failed: Resource temporarily unavailable" on a real .d64 drop. send_all now treats a would-block as "wait for room" (compat_wait_writable) and only gives up after 30 s without progress. compat gains compat_neterr_transient and compat_wait_writable, with unit coverage. Three images re-run on firmware 1.1.0, all mounted and started. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GCACXGV72EjRZUUKapybmQ --- src/compat.h | 7 +++++++ src/compat_posix.c | 11 +++++++++++ src/main.c | 19 ++++++++++++++++--- tests/tests.c | 2 ++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/compat.h b/src/compat.h index a07c2c1..9e36e29 100644 --- a/src/compat.h +++ b/src/compat.h @@ -75,8 +75,15 @@ int compat_wait_readable(const compat_sock *socks, int n, int timeout_ms); void compat_close(compat_sock s); +// Waits until `s` can take more data or timeout_ms elapsed. 1 when +// writable, 0 on timeout, -1 on error. +int compat_wait_writable(compat_sock s, int timeout_ms); + // Text for the last socket error (errno / WSAGetLastError). const char *compat_neterr(void); +// True when the last socket error was a would-block / timeout (EAGAIN, +// EWOULDBLOCK, EINTR; WSAEWOULDBLOCK, WSAETIMEDOUT), i.e. worth retrying. +bool compat_neterr_transient(void); // Source address the OS would use to reach `ip` (UDP connect + // getsockname). Dotted quad in out (>= COMPAT_IP_STRLEN bytes). diff --git a/src/compat_posix.c b/src/compat_posix.c index 5c130ee..0eae0a0 100644 --- a/src/compat_posix.c +++ b/src/compat_posix.c @@ -134,6 +134,12 @@ int compat_wait_readable(const compat_sock *socks, int n, int timeout_ms) return poll(pfd, (nfds_t)n, timeout_ms); // negative fds are ignored } +int compat_wait_writable(compat_sock s, int timeout_ms) +{ + struct pollfd pfd = {.fd = s, .events = POLLOUT}; + return poll(&pfd, 1, timeout_ms); +} + void compat_close(compat_sock s) { if (s >= 0) @@ -145,6 +151,11 @@ const char *compat_neterr(void) return strerror(errno); } +bool compat_neterr_transient(void) +{ + return errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR; +} + bool compat_route_source_ip(const char *ip, char *out, size_t cap) { int s = socket(AF_INET, SOCK_DGRAM, 0); diff --git a/src/main.c b/src/main.c index e550d4f..4bce83a 100644 --- a/src/main.c +++ b/src/main.c @@ -367,15 +367,28 @@ static compat_sock dma_connect_raw(const char *host, int timeout_s) timeout_s); } +// The socket's send timeout (the connect timeout, 1-3 s) caps a single +// send() call, and the Ultimate's TCP stack drains a disk image slowly, so +// a would-block only means "wait for room": keep going until the data is +// out or nothing moved for DMA_STALL_MS. +#define DMA_STALL_MS 30000 + static bool send_all(compat_sock s, const void *data, size_t len) { const uint8_t *p = data; + Uint64 last_progress = SDL_GetTicks(); while (len > 0) { int n = compat_send(s, p, len); - if (n <= 0) + if (n > 0) { + p += n; + len -= (size_t)n; + last_progress = SDL_GetTicks(); + } else if (n < 0 && compat_neterr_transient() && + SDL_GetTicks() - last_progress < DMA_STALL_MS) { + compat_wait_writable(s, 1000); + } else { return false; - p += n; - len -= (size_t)n; + } } return true; } diff --git a/tests/tests.c b/tests/tests.c index a42ad89..69f5e28 100644 --- a/tests/tests.c +++ b/tests/tests.c @@ -288,7 +288,9 @@ static void test_compat(void) !strcmp(local, "127.0.0.1")); char buf[16]; CHECK(compat_recv_nowait(rx, buf, sizeof buf) < 0); // nothing yet + CHECK(compat_neterr_transient()); // ...which is a would-block, not an error CHECK(compat_wait_readable(&rx, 1, 0) == 0); + CHECK(compat_wait_writable(tx, 0) == 1); // an idle socket has room // the bound port is the one detail the layer has no getter for, so the // loopback test uses a fixed high port for the receiver instead compat_close(rx); From 2886b986df18dc1ade2ff82017df3ff3a641aac9 Mon Sep 17 00:00:00 2001 From: Michal Skorupka <4363852+crustovsky@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:30:44 +0200 Subject: [PATCH 4/7] Drop: hand port 64 to the transfer and show its progress in the title The Ultimate's DMA task serves one client at a time: it accepts a connection and reads from it until it closes before accepting the next. The windowed viewer keeps its keyboard channel connected, so a dropped .d64's RUN_IMG connection queued behind it and stalled until the keyboard socket happened to drop (30 s stall, then "Resource temporarily unavailable"). Headless --run never showed it because nothing else holds the port. Reproduced by holding an idle port-64 connection during a --run: the transfer waited, and finished 0.1 s after the idle client closed. run_file_async now closes the keyboard socket before the run thread starts and keyb_try_connect stays off port 64 while g_run_busy; the channel reconnects on the next keypress afterwards. The transfer publishes its progress (g_run_pct) and the event loop shows it in the window title ("sending game.d64 42%"), restoring the normal title when done. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GCACXGV72EjRZUUKapybmQ --- CLAUDE.md | 8 +++++++- README.md | 5 +++++ src/main.c | 55 +++++++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6ee9049..d773496 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,7 +160,13 @@ both on every push and PR. one-byte reply 1/0, 1 s throttle on failure) before any other command, or the firmware drops the connection; `dma_connect` does it for both the keyboard channel and image runs. `C64U_DMA_PORT` redirects port 64 - for tests (fakeultimate.py logs `DMA cmd=FFxx len=N`). + for tests (fakeultimate.py logs `DMA cmd=FFxx len=N`). **One DMA client + at a time**: `dmaThread` accepts a connection and serves it until it + closes before accepting the next, so the viewer's open keyboard + connection stalls an image transfer behind it (seen as a 30 s stall then + EAGAIN; a bare socket takes 0.1 s for a .d64 whether or not the stream + runs or the C64 is loading). Hence `run_file_async` closes the keyboard + socket and `keyb_try_connect` stays off port 64 while `g_run_busy`. ## Dev workflow (no hardware needed) diff --git a/README.md b/README.md index ec34c5e..4702c74 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,11 @@ it is parked for the run and restored afterwards, so a freezer cart won't hijack the program's reset; the cart is back on your next manual reset. The same works headless: `c64uv --run game.prg`. +The window title shows the transfer progress ("sending game.d64 42%"). +The Ultimate serves one DMA-socket client at a time, so the viewer drops its +keyboard connection for the duration and reconnects it on your next keypress; +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 diff --git a/src/main.c b/src/main.c index 4bce83a..c016fd3 100644 --- a/src/main.c +++ b/src/main.c @@ -40,6 +40,14 @@ struct config { }; static atomic_bool g_quit; +// Drag-and-drop transfer state shared between the run thread and the event +// loop. g_run_busy blocks a second drop and keeps the keyboard channel off +// port 64 while a transfer runs: the firmware serves one DMA client at a +// time, so an open keyboard connection would stall the image behind it. +// g_run_pct (-1 idle, else 0-100) drives the window title. +static atomic_bool g_run_busy; +static atomic_int g_run_pct = -1; +static char g_run_name[64]; // written before the run thread starts // machine:input capability (probed once per session): -1 unknown, 0 no, 1 yes static atomic_int g_minput = -1; // Network password (firmware 3.12+), sent as X-Password on every REST call. @@ -373,7 +381,9 @@ static compat_sock dma_connect_raw(const char *host, int timeout_s) // out or nothing moved for DMA_STALL_MS. #define DMA_STALL_MS 30000 -static bool send_all(compat_sock s, const void *data, size_t len) +// A non-zero `total` publishes progress in g_run_pct (image transfers). +static bool send_all(compat_sock s, const void *data, size_t len, + size_t total) { const uint8_t *p = data; Uint64 last_progress = SDL_GetTicks(); @@ -383,6 +393,8 @@ static bool send_all(compat_sock s, const void *data, size_t len) p += n; len -= (size_t)n; last_progress = SDL_GetTicks(); + if (total) + atomic_store(&g_run_pct, (int)((total - len) * 100 / total)); } else if (n < 0 && compat_neterr_transient() && SDL_GetTicks() - last_progress < DMA_STALL_MS) { compat_wait_writable(s, 1000); @@ -399,7 +411,8 @@ static bool dma_send(compat_sock s, uint16_t cmd, const void *data, { uint8_t hdr[5] = {cmd & 0xFF, cmd >> 8, len & 0xFF, (len >> 8) & 0xFF, (len >> 16) & 0xFF}; - return send_all(s, hdr, len24 ? 5 : 4) && send_all(s, data, len); + return send_all(s, hdr, len24 ? 5 : 4, 0) && + send_all(s, data, len, len24 ? len : 0); } // Connects and, when a password is set, authenticates. COMPAT_BAD_SOCK on @@ -438,6 +451,7 @@ struct keyb { static void keyb_try_connect(struct keyb *k) { if (!k->enabled || k->fd != COMPAT_BAD_SOCK || + atomic_load(&g_run_busy) || // a transfer owns port 64 right now (k->last_try != 0 && SDL_GetTicks() - k->last_try < 3000)) return; k->last_try = SDL_GetTicks(); @@ -670,6 +684,7 @@ static bool run_file(const char *host, const char *path) compat_neterr()); compat_close(s); } + atomic_store(&g_run_pct, -1); // the title stops saying "sending" if (code == 200) SDL_Log("%s mounted on drive A and started (%ld bytes)", path, len); @@ -707,7 +722,6 @@ static bool run_file(const char *host, const char *path) // Drag-and-drop runs on a worker thread: the whole sequence can take // seconds and must not freeze the viewer. -static atomic_bool g_run_busy; struct runjob { char host[64]; char path[1024]; @@ -718,16 +732,28 @@ static int run_thread(void *arg) struct runjob *j = arg; run_file(j->host, j->path); free(j); + atomic_store(&g_run_pct, -1); atomic_store(&g_run_busy, false); return 0; } -static void run_file_async(const char *host, const char *path) +// Called from the event loop; hands port 64 to the transfer by dropping +// the keyboard connection (it reconnects on the next keypress after the +// run). NULL kb for the headless --run path. +static void run_file_async(const char *host, const char *path, + struct keyb *kb) { if (atomic_exchange(&g_run_busy, true)) { SDL_Log("still busy with the previous file"); return; } + if (kb && kb->fd != COMPAT_BAD_SOCK) { + compat_close(kb->fd); + kb->fd = COMPAT_BAD_SOCK; + } + const char *base = strrchr(path, '/'); + snprintf(g_run_name, sizeof g_run_name, "%s", base ? base + 1 : path); + atomic_store(&g_run_pct, 0); struct runjob *j = malloc(sizeof *j); snprintf(j->host, sizeof j->host, "%s", host); snprintf(j->path, sizeof j->path, "%s", path); @@ -1179,6 +1205,7 @@ int main(int argc, char **argv) dthr = SDL_CreateThread(discover_thread, "discover", &da); } compat_sock tfd = COMPAT_BAD_SOCK; + int shown_pct = -1; // drop progress currently in the window title Uint64 tfd_last_try = 0; SDL_Texture *term_tex = NULL; Uint32 *term_px = calloc(TERM_PX_W * TERM_PX_H, sizeof(Uint32)); @@ -1322,7 +1349,7 @@ int main(int argc, char **argv) term_present = true; } else if (ev.type == SDL_EVENT_DROP_FILE) { if (!cfg.no_start && cfg.host && ev.drop.data) - run_file_async(cfg.host, ev.drop.data); + run_file_async(cfg.host, ev.drop.data, &kb); } else if (ev.type == SDL_EVENT_TEXT_INPUT && !help_active) { for (const char *p = ev.text.text; *p; p++) { if (term_active) { @@ -1344,6 +1371,24 @@ int main(int argc, char **argv) } } + if (windowed) { // a running drop shows its progress in the title + int pct = atomic_load(&g_run_pct); + if (pct != shown_pct) { + shown_pct = pct; + char title[128]; + if (pct >= 0) + snprintf(title, sizeof title, "c64uv - sending %s %d%%", + g_run_name, pct); + else + snprintf(title, sizeof title, "%s", + !got_any ? "c64uv - waiting for stream…" + : kb.enabled + ? "c64uv - Esc = RUN/STOP, Ctrl+Q = quit" + : "c64uv"); + SDL_SetWindowTitle(win, title); + } + } + compat_sock waitset[3] = {sock, asock, tfd}; // unset ones are skipped compat_wait_readable(waitset, 3, 5); From 44b1aa350aeb00e3a6fff4a2b825017b581ac837 Mon Sep 17 00:00:00 2001 From: Michal Skorupka <4363852+crustovsky@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:37:58 +0200 Subject: [PATCH 5/7] Drop: reconnect the keyboard channel as soon as the transfer releases port 64 Reconnecting only on the next keypress could lose that keystroke. The drop marks the channel for reclaim and the event loop reconnects it the moment the run thread clears g_run_busy. Only then: a blind retry every loop would block the render loop for the connect timeout whenever the Ultimate is unreachable. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GCACXGV72EjRZUUKapybmQ --- src/main.c | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main.c b/src/main.c index c016fd3..5814cb6 100644 --- a/src/main.c +++ b/src/main.c @@ -443,6 +443,7 @@ static compat_sock dma_connect(const char *host, int timeout_s) struct keyb { compat_sock fd; // COMPAT_BAD_SOCK when disconnected bool enabled; + bool reclaim; // reconnect as soon as the running drop releases port 64 const char *host; Uint64 last_try; }; @@ -747,9 +748,10 @@ static void run_file_async(const char *host, const char *path, SDL_Log("still busy with the previous file"); return; } - if (kb && kb->fd != COMPAT_BAD_SOCK) { + if (kb) { compat_close(kb->fd); 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); @@ -1371,6 +1373,15 @@ int main(int argc, char **argv) } } + // a drop hands port 64 to the transfer; take the keyboard channel + // back as soon as it ends so the next keystroke is not lost (only + // then: a blind retry every loop would block the render loop for + // the connect timeout whenever the Ultimate is unreachable) + if (kb.reclaim && !atomic_load(&g_run_busy)) { + kb.reclaim = false; + keyb_try_connect(&kb); + } + if (windowed) { // a running drop shows its progress in the title int pct = atomic_load(&g_run_pct); if (pct != shown_pct) { From 0c5963685302d448cc733a4ed6d874d93f0222d4 Mon Sep 17 00:00:00 2001 From: Michal Skorupka <4363852+crustovsky@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:48:16 +0200 Subject: [PATCH 6/7] Roadmap: persistent drop storage via FTP upload and mount-by-path Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GCACXGV72EjRZUUKapybmQ --- CLAUDE.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index d773496..adc55ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,6 +207,25 @@ 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 + 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 + 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). + Dormant follow-up: when official firmware ships `machine:input`, re-verify the matrix-keyboard mapping against real hardware and activate the gamepad path alongside it. From f0efbae3d77748f0b1779e20a92465e372ce03e3 Mon Sep 17 00:00:00 2001 From: Michal Skorupka <4363852+crustovsky@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:49:24 +0200 Subject: [PATCH 7/7] Version 0.2.7 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GCACXGV72EjRZUUKapybmQ --- src/main.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.c b/src/main.c index 5814cb6..11cc5d7 100644 --- a/src/main.c +++ b/src/main.c @@ -6,7 +6,7 @@ #include #include -#define C64UV_VERSION "0.2.6" +#define C64UV_VERSION "0.2.7" #include "compat.h" #include "discover.h"