diff --git a/CLAUDE.md b/CLAUDE.md index ab6397e..adc55ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,6 +146,27 @@ 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`). **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) @@ -186,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. diff --git a/README.md b/README.md index 6b19076..4702c74 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 @@ -89,11 +94,23 @@ 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`. + +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 +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/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/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..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" @@ -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; @@ -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. @@ -339,17 +347,103 @@ 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); +} + +// 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 + +// 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(); + while (len > 0) { + int n = compat_send(s, p, len); + if (n > 0) { + 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); + } else { + return false; + } + } + 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, 0) && + send_all(s, data, len, len24 ? len : 0); +} + +// 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 bool enabled; + bool reclaim; // reconnect as soon as the running drop releases port 64 const char *host; Uint64 last_try; }; @@ -358,10 +452,11 @@ 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(); - 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 +466,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 +539,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 +607,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 +643,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 +655,55 @@ 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); + } + 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); + } 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); @@ -574,7 +723,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]; @@ -585,16 +733,29 @@ 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) { + 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); + 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); @@ -762,7 +923,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" @@ -1045,6 +1207,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)); @@ -1188,7 +1351,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) { @@ -1210,6 +1373,33 @@ 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) { + 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); 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. 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);