From a56fb7dc796a9c093e27b762fbfddef40e46fbc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:28:06 +0000 Subject: [PATCH 01/16] Persistent image drops: --store FOLDER uploads over FTP and mounts by path Dropped disk images normally land in the firmware's temp RAM disk. With --store (or C64U_STORE) the image is FTP-uploaded into the given folder on the Ultimate, mounted from there read-write, and autostarted by the viewer: machine:reset, readiness gate, LOAD"*",8,1 and RUN typed over the DMA keyboard channel in 10-byte batches. An existing file is never overwritten (files/:info check). Static release curl keeps FTP now. tests/fakeultimate.py grows a passive-mode FTP stub (fifth argument, C64U_FTP_PORT) and a files:info route; integration.sh covers the store sequence and the name-clash refusal. --verbose logs the Shift state at drop time to settle whether Wayland reports modifiers during a drag. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MD4jNoeKcKQc8pkZyJTepc --- .github/workflows/release.yml | 4 +- CLAUDE.md | 34 +++--- README.md | 14 ++- src/main.c | 223 ++++++++++++++++++++++++++++++++-- tests/fakeultimate.py | 83 +++++++++++-- tests/integration.sh | 41 ++++++- 6 files changed, 362 insertions(+), 37 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 741f7ac..851747f 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 diff --git a/CLAUDE.md b/CLAUDE.md index eab130d..3752933 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -226,24 +226,30 @@ 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 2026-09-27, awaiting a real + hardware run): `--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 + `files/:info` (200 = refuse to overwrite), libcurl FTP upload + (anonymous), `PUT drives/a:mount?image=&mode=readwrite`, then + `machine:reset` + readiness gate + `LOAD"*",8,1` / `RUN` typed over the + keyboard channel in <= 10-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. Still open: whether SDL reports a modifier held during a drag + on Wayland (`--verbose` logs the Shift state at drop time to find out; + a modifier-selected store would build on that), and hardware + verification of the `:info` reply for a missing file (assumed non-200) + and of `image=` taking a literal `/`-separated path. 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`; a name containing + `exists` answers `:info` with 200). 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/README.md b/README.md index be301e2..bfcfac1 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,7 +122,16 @@ 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. A file with the same name is +never overwritten; move or rename it from the Ultimate's menu first. +`.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 diff --git a/src/main.c b/src/main.c index 9bfbdc4..4b9db01 100644 --- a/src/main.c +++ b/src/main.c @@ -53,6 +53,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 @@ -397,15 +400,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); @@ -640,6 +649,181 @@ static void wait_kernal_ready(CURL *curl, const char *host, int max_ms) } } +// ------------------------------------------------ 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. +// An existing file is never overwritten. + +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, in the +// 10-byte batches the buffer holds (the firmware does not chunk). +static bool dma_type(compat_sock s, const char *text) +{ + size_t len = strlen(text); + for (size_t i = 0; i < len; i += 10) { + size_t n = len - i < 10 ? len - i : 10; + 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 *base = strrchr(path, '/'); + const char *name = base ? base + 1 : 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]; + snprintf(url, sizeof url, "http://%s/v1/files/%s:info", host, rpath); + long code = rest_req(curl, "GET", url, NULL, 0, NULL, 3000, resp, NULL); + if (code == -1) { + SDL_Log("files:info: no response from Ultimate"); + return -1; + } + if (code == 200 && strstr(resp, "\"size\"")) { // present: don't clobber + SDL_Log("%s already exists on the Ultimate, not overwriting", shown); + return -1; + } + 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)", shown, len); + + snprintf(url, sizeof url, + "http://%s/v1/drives/a:mount?image=/%s&mode=readwrite", host, + rpath); + 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; + 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), so the gate + // waits for READY.; a KERNAL load runs at about 400 bytes/s + SDL_Delay(1000); + 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,9 +833,10 @@ 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"); + 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"); FILE *f = fopen(path, "rb"); if (!f) { SDL_Log("%s: %s", path, strerror(errno)); @@ -704,6 +889,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; @@ -960,8 +1147,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" + " [--dump FILE.ppm] [--term-test] [--discover] [--verbose]\n" + " [--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 +1163,9 @@ 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" " --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 +1191,8 @@ 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], "--dest") && i + 1 < argc) cfg.dest = argv[++i]; else if (!strcmp(argv[i], "--port") && i + 1 < argc) @@ -1036,6 +1229,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); @@ -1424,6 +1621,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..d218426 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): @@ -112,6 +171,13 @@ def do_GET(self): "joysticks": [{"port": 1, "inputs": []}, {"port": 2, "inputs": []}], "errors": []}) + elif self.path.startswith("/v1/files/") and self.path.endswith(":info"): + # a name containing "exists" stands for a file already there + if "exists" in self.path: + self._json({"path": self.path[10:-5], "size": 174848, + "errors": []}) + else: + self._json({"errors": ["File not found"]}, 404) else: self._json({"errors": ["Unknown API Call"]}, 404) @@ -124,9 +190,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..c1b3b60 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,45 @@ 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 checked for a name clash, FTP-uploaded into the +# folder, mounted by path read-write, and autostarted by the viewer: reset +# (with cartridge parking), readiness gate, LOAD"*",8,1 and RUN typed in +# <= 10-byte KEYB batches. An existing name refuses without uploading. + +: > "$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=", + "GET /v1/files/Usb0/my%20games/disk.d64:info", + "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=10", + "DMA cmd=FF03 len=2", + "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 +cp "$out/disk.d64" "$out/exists.d64" +: > "$out/disc.log" +C64U_FTP_PORT=8067 timeout 30 ./c64uv --host 127.0.0.42:8064 --store /Temp \ + --run "$out/exists.d64" 2> "$out/store.err" && exit 1 +grep -q "GET /v1/files/Temp/exists.d64:info" "$out/disc.log" +! grep -q "FTP STOR\|drives\|machine:reset" "$out/disc.log" +grep -q "already exists" "$out/store.err" +echo "stored image 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 From fe8f901b811f942f783d615be6aae6e7e9d1c2d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:32:53 +0000 Subject: [PATCH 02/16] Headless --type and --screen; drop the last POSIX-only calls in main.c --type TEXT puts text into the KERNAL buffer over the DMA keyboard channel (\n = RETURN, 10-byte batches), --screen prints the 40x25 text screen from machine:readmem $0400. Together they close the loop for scripts and agents driving the machine without a window; the readmem request is shared with the readiness gate. Files now load through SDL_LoadFile and dropped paths split on both separators, so main.c has no POSIX call left: a Windows port is compat_win32.c plus a build recipe. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MD4jNoeKcKQc8pkZyJTepc --- CLAUDE.md | 16 +++- README.md | 9 ++- src/main.c | 182 ++++++++++++++++++++++++++++++++---------- tests/fakeultimate.py | 5 ++ tests/integration.sh | 13 +++ 5 files changed, 179 insertions(+), 46 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3752933..4df0573 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,8 +49,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. @@ -205,7 +212,14 @@ control + password, drag-and-drop run, help overlay) shipped in v0.2.0. `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: + 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+). 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 diff --git a/README.md b/README.md index bfcfac1..89dd253 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,14 @@ never overwritten; move or rename it from the Ultimate's menu first. `.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). 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. diff --git a/src/main.c b/src/main.c index 4b9db01..fab0274 100644 --- a/src/main.c +++ b/src/main.c @@ -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; @@ -602,7 +603,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; }; @@ -615,33 +616,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; @@ -649,6 +661,16 @@ static void wait_kernal_ready(CURL *curl, const char *host, int max_ms) } } +// 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 @@ -765,8 +787,7 @@ static long store_image(CURL *curl, const char *host, const char *path, SDL_Log("--store needs libcurl with FTP support"); return -1; } - const char *base = strrchr(path, '/'); - const char *name = base ? base + 1 : path; + 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); @@ -837,25 +858,16 @@ static bool run_file(const char *host, const char *path) // 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"); - FILE *f = fopen(path, "rb"); - if (!f) { - SDL_Log("%s: %s", path, strerror(errno)); + 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; } @@ -927,7 +939,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); @@ -977,8 +989,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); @@ -986,6 +997,76 @@ 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 { + c = ascii_to_petscii((unsigned char)*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), @@ -1148,8 +1229,8 @@ static void usage(const char *argv0) "usage: %s --host IP [--dest IP[:PORT]] [--port N] [--scale N]\n" " [--multicast] [--no-start] [--no-audio] [--no-keyb]\n" " [--password PW] [--do ACTION] [--run FILE] [--store DIR]\n" - " [--dump FILE.ppm] [--term-test] [--discover] [--verbose]\n" - " [--version]\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" @@ -1166,6 +1247,8 @@ static void usage(const char *argv0) " --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" @@ -1193,6 +1276,10 @@ int main(int argc, char **argv) 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) @@ -1275,7 +1362,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"); @@ -1301,6 +1388,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); diff --git a/tests/fakeultimate.py b/tests/fakeultimate.py index d218426..5ec098a 100644 --- a/tests/fakeultimate.py +++ b/tests/fakeultimate.py @@ -164,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": diff --git a/tests/integration.sh b/tests/integration.sh index c1b3b60..7471b8f 100644 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -219,6 +219,19 @@ grep -q "GET /v1/files/Temp/exists.d64:info" "$out/disc.log" grep -q "already exists" "$out/store.err" echo "stored image test passed" +# ------------------------------------------------------------ type + screen +# --type goes out as KEYB frames in <= 10-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=10" "$out/disc.log" | grep -q '^1$' +grep -q "DMA cmd=FF03 len=1$" "$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 From 1761d0fddf3211af07c2c65ffe1f5ab85e4f1b6c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:15:19 +0000 Subject: [PATCH 03/16] Windows port: compat_win32.c, make TARGET=win32, cross-built release zip Winsock implementation of compat.h: WSAPoll for the waits, non-blocking connect + select for the connect timeout (SO_SNDTIMEO does not bound connect() on Winsock), GetAdaptersAddresses for the interface list with OnLinkPrefixLength as the mask, GetIpNetTable for the neighbor MAC, one datagram as the ARP prime. compat_sock is uintptr_t on Windows. make TARGET=win32 cross-builds c64uv.exe with MinGW as a console app so the CLI flags keep working; release.yml adds a windows job that ships the exe with SDL3.dll from the official MinGW package and a static libcurl. Compiles warning-free; awaiting a run on real hardware. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MD4jNoeKcKQc8pkZyJTepc --- .github/workflows/release.yml | 66 ++++++++ .gitignore | 2 + CLAUDE.md | 20 ++- Makefile | 12 +- README.md | 7 + src/compat.h | 7 +- src/compat_win32.c | 283 ++++++++++++++++++++++++++++++++++ src/discover.c | 2 +- 8 files changed, 388 insertions(+), 11 deletions(-) create mode 100644 src/compat_win32.c diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 851747f..1f41556 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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:-dev}-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..c29c4b2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ c64uv *.o .claude/settings.local.json* tests/run +c64uv.exe +*.zip diff --git a/CLAUDE.md b/CLAUDE.md index 4df0573..630c64b 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 @@ -219,12 +220,17 @@ control + password, drag-and-drop run, help overlay) shipped in v0.2.0. `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+). 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 + + 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 = one datagram), `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). Compiles warning-free; awaiting Michal's run on a Windows + box. 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, diff --git a/Makefile b/Makefile index ed4f82d..7bb62ce 100644 --- a/Makefile +++ b/Makefile @@ -12,14 +12,22 @@ 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 +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) +c64uv$(EXE): $(SRC) $(HDR) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(SRC) $(LDLIBS) tests/run: tests/tests.c $(LIB) $(HDR) diff --git a/README.md b/README.md index 89dd253..307b072 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,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** (experimental, 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 the stream's ARP prime is a plain packet rather than `ping -I`, +so a VPN that claims the LAN route may need to be off. Tested only through +the cross-compiler so far; reports welcome. + 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/src/compat.h b/src/compat.h index 9e36e29..7bd5d41 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 diff --git a/src/compat_win32.c b/src/compat_win32.c new file mode 100644 index 0000000..363dc00 --- /dev/null +++ b/src/compat_win32.c @@ -0,0 +1,283 @@ +// 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 + +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; +} + +// One datagram is enough here: the Linux `ping -I` exists for policy +// routing that detours LAN traffic, which Windows VPN clients do not do +// in the same way (unverified; revisit if a Windows tester hits "Network +// Host Resolve Error" with a VPN up). +void compat_arp_prime(compat_sock s, const char *ip, const char *ifname) +{ + (void)ifname; + compat_sendto(s, "", 1, ip, 11000); +} 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); From 23032567a9e08701a549524e41e62f2325f6e254 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:23:29 +0000 Subject: [PATCH 04/16] Windows exe: embedded icon and version block genicon.py also wraps the PNG renditions into assets/c64uv.ico; the TARGET=win32 build compiles assets/c64uv.rc (icon + VERSIONINFO, version taken from src/main.c) with windres and links it in. SDL picks the exe's first icon up as the window icon on Windows. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MD4jNoeKcKQc8pkZyJTepc --- .gitignore | 1 + CLAUDE.md | 6 +++++- Makefile | 9 +++++++-- assets/c64uv.ico | Bin 0 -> 7014 bytes assets/c64uv.rc | 27 +++++++++++++++++++++++++++ tools/genicon.py | 22 +++++++++++++++++++++- 6 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 assets/c64uv.ico create mode 100644 assets/c64uv.rc diff --git a/.gitignore b/.gitignore index c29c4b2..d36a10b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ c64uv tests/run c64uv.exe *.zip +c64uv.res.o diff --git a/CLAUDE.md b/CLAUDE.md index 630c64b..ac2ac84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,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 diff --git a/Makefile b/Makefile index 7bb62ce..03036ee 100644 --- a/Makefile +++ b/Makefile @@ -21,14 +21,19 @@ 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$(EXE): $(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) +c64uv.res.o: assets/c64uv.rc assets/c64uv.ico + x86_64-w64-mingw32-windres -DC64UV_VERSION='\"$(VERSION)\"' -O coff -o $@ $< tests/run: tests/tests.c $(LIB) $(HDR) $(CC) $(CFLAGS) -o $@ tests/tests.c $(LIB) $(LDLIBS) diff --git a/assets/c64uv.ico b/assets/c64uv.ico new file mode 100644 index 0000000000000000000000000000000000000000..3d989647b8f0e3d76f2cebcc6710b64bd8341b8b GIT binary patch literal 7014 zcmb_g1yt1Qw*SvCw6xNag3?Hf#DE|nT?0r7f^y_xyXUf+Mmx4#{~wbus#AOHg(Bm_{D4j2LffEfS)bacP3*T4Y4 zk9xSde_u<8vvw;0U%gKNd^yx z5+w=X$;nEop(tucfDj7<^-sHh{R~AgjGxFz0oOmj87+A+C<6g!X2wTD?9g5Qma*Q6%mK@^As+UE7v zX;MA;Va?vWo{@wm&=F4Q@I-B_`w>W4PEyq(+oAoOiK{8_iZRjdX}{?cKO)`pzO$+^ z=LoDWqIZe|<9WD!?}5b=w{|)qrhekwgjGBRG<%n$vQ+_mPh7s~m9g2{wr!75!8_z+ zRaay!VoX`&S+$DDVaL5ykS;z@;*c#Re2X|IR+gngldQj5rPjYlR5(7H@m9-%x>moZ z-tn{_k)_|Jke;FMRM8omd)l@j)*HsorM2{d^Gbg6>o{LDoT2@?o`W{hv{f__U4G;D zHP=d3ZHEukLwgCNeEmvgT%Nbibw3(Zk~awfL8Gf>VE{ zQ~UMTcnPIhDEVt~8G)Kt;dnyCkBN*jWuJ906i>Hf*F$GXG&Tz7RmVz&Fv-@iXYj1F zok*~2N(-Z6g`?^tMuxNNaW~$QY)a8Ri#p5x^8DMi=#Klh%QRU}&n1oX_ooq2d2mI6 z{3ijJ-CoaNM?)=jbS-^BLY>Az2Q%WwpI*xj@i3FsOL}mU%pMWYB3?)~x747)A-D|+ zX#r-?9_bR|B^}=uKE@(3)W*bjsR=yQWxB1EyeKkoY1z^vFRH22K z?EQ~5_b*zTS72qK7P^VKui4oix#wEkx8sN!vO4h4*Q*O*XZutssT9qOBRdb$=ap(} z)+64}q6r!l@mcs%rP$!$3L;v@wX@qQSDjYiOQ_xE<35y< zP8V2%^v0o?D6BPVdbDsBCX-=6%)PGTb%`q92Q|w^}J z0oEENGL6)Wyh%+IdClwIp`3so5WQ-b8NH!bC!bUKV3$pS>iAAJ4UdTty17FkZ zdpIALS=)z~8*PI~v1z24T%;ov?t2?;tcLp{7qPY~GkOMb;+^^Z^k{C?n+LRAC*V~cOLKVt+mE&tcUl0f%q_MCZwu~y> zzpHZ;5CYu4)j14&`sUB-9CG%v&dvR{bQINRACvoN=}udH?B*eV^=?t(=#B$jsuGVV zU+TihdZhWzobc#VPixyGgOghq!Gj0!a`>r!kHj~VpdWym%vvjo0FGz9vuL#p(^$`O zD%MvX$d$PuylGo#&r78MBhwuAeuXm-wIP}|gwB{}37BGTr_4V!c6QEG)Y9sE$u93p zPnRA(+xVs3ki=3nt3}YaD-09Zi+#B1nMo!fNJ5QJN$GCJ)i2BZk{{lafg2^ClX=gg zOg4vHcIt5!hE3Cf0UmB-Cml$YJb?xFngG&`C%gfXmP7U;xdK-{E(^1sKg@J`{;kUG zYKCMH}hZ0MsdMp4(1axXwYZ0G3WVmCQU7BRrq(Y+S-* zC|}=?)6ufIb9qzDk0WQYp-j2CLk9-5DPh_8P+4$D^y2_|A)e&Llr9JBYTd_c(ozyo zV@t~WrxOJcwq#0^esoxb0j`=UCQf7l42#}YcrAh9;R%c!4AP0pG6>H?U3jCm{Z~j< z6WvXaep%CGDhY1V>E>{vSyhDAxtnZS(vV~}gHua{?WCV+k~vM3P?I> zcYbJKo`O=Hy)<2}{izM5$3TK$ti0HKie3yRW!i|@>}~SLqb)BKFid!IqeqSIQ^fV* zD^%SQf@$KmHY}`GRZ(QCS!{0GcGNzE$T!VVQj)jgcam&oSGyf5$n`8qu+ewjMp~}(e1PvCr1BhzyV-$+PktS0 zGAyEk5ks@7ItoVv-bJ^{UfeZ6xM2rBF3%T(-%}Ggij|Teba>9^U|;^Gt1FO8{1rDZ z?;Go*)`xYGdbZdpuDh$WZJojur4jJb$@avsfS!5(so8m)%OeJ2sy;lfAi}G6l51L> zl?u%smyV13s>??^meVlrL0vX>Ns`6p??s%~=n*De4xF}Eor~S+m|}$Ff?!9Z#RGy- z>khuBk6r@YVa(P|$l6;AmMgdI9^NLGQG{)ePq(%5fKh?@fRw8%RcDnLl^1hIti5!n ziZ_+i#5Gdr_A%|5Z*N9Y#8k|()fMVOYjisn7UFk2G$4U0HM$r%#D(KLsNeGL9rDoA z6>qhk9vN-jvzvG0u@%{t2Io^|UwZaTZk&HwO(0UfBT)d!th4eglabEAJi~j$S$9MA zrbBrfs@NH)XU`lPOcN(6zPqU2^jq)?AOy>WoE+E#CGj8$Lb{NfA4h~~=B^=it7IA9 z`8PLh4ML#y#E$s0+eTL3UDrJD>Xy}w6pnhS4q0z9;dMWsa>0CSezQP>vUXmg4l#Dv z?eF+`zz5Q0-pMn|9g!>~bb?zu-|Tdq&8 z2rcieoZL9(quKmMZSp+@BYa51uxDZt1VWXC`1mJ?x}|Kr6IVtJ3*G5W7=F5UtR0?`iO^-@6wI&T@S^d4z7>#eY_i8BO)sEoVmFV$V%j#hK zfV~WP8rREWYM$Psj&kfp3K}v5{Vuv*TG&_3ECa(fbksyFBXhaBm*+*W$W-v5GHR_H zy_QOPIr{a|=`x91x@uf+kE~O-^+~d0G4*@P7)&4#Mtwy!*p8oHGrap9j^-x8;g5q+ ztAkcv%xYcpj;TM1>*1_tVSj(KHT^XeCol%icK~IOLKO!d-{qwy1%{i}-cNp~Jfwn9 zVND2_#m`O^=wT!Z9EgArWB|OQoB$vXx8@GATeP^PX>P{2B_>|h0rSvB9%!9kU##({+63*veTDlxaZ4W$V=#od zc*pSJJHdLB+v~ZQ&}VLWd9-{z+&vs1s?>Z1Zh;b`t%bS0WK)B#(C@dVee^Nb1U^|k zcK?3Jxi|knc?rZ?HTE$Co3tQ7&{?xz(loxYo+)FNPM~t7H+;ikDk%d!tga(?_qj|< zkVM@5KnEbO>wvKoua7)y?@2p4To>T~EeHi(08d?qPG0kgRH6%xg4k8}VJ>ZO)xv$H zx_9G|EJ+km)cNDKxj4c7qs$LPm)4I8h4(+45z9822`Y7SJ`mGJ`d?suae|NXzS% zU$HddYAE;QMs;>=lB7@oQp7-maY#72z3_l_4t8Yo8X-&0UIkdwuq? z2Z9(J*0C#?IwY}Au}Mt4to6|LvLLvk^tJD0^}y|R$qX{2*i=qq|E7cBC_enR4(bwn z*8gW61kwCQ2hFwFk+_m${8(-2pLi~>U&A0tC;Y>%`l$rN`Ue$(3G%x)!lVxf=#*@x zqFBH1TVhMhFAf=nMqx=TPeu)6pBn@&_KA9}a30McZ?2xQ2koa`KK8jdYuT9=@RBGx z+^%-FqoC1;T-<;&0fye|tpBVlS^A4D>nwrNvbG0bX)j>prQo3c zLJiipiPP~iw6@i=2+=2q6CGee3sKyDAqGEXEsW`-ZbbH()m4Z#->1A9I@>5B{alxE z*O!zdaj8sef4pWX{Ms3=Tz0{cElJON*P&;~U&#mReEcs~q;5=whO4fsIIA-Wx#6SXLyAO_jaKKWqVbF~e zF(-QBK#D#NODdEQLw{fPxwxe2_)0Un^kQ@~Iq z5M3S+r4Ib#wPPkO`MvtUOYzK7Wd)M~i;Z8xdQtS;J3llMB(TZt7K?bDf7vKKptq05 zY*pn~o-NPE|7G%vA%0pny>s*!=zlhCn7QHS{-#*<(QLj=49yI4OD9Zvz&#)Bmj^Wz z;78c}>=UM@s<(eR$mWgp`MjaQ;v#oTh~J7y#ZuzG{YKZpF79WYsyAEOQNjGDv;4&o zae#&jE=;mZJ-svH4LNaGf-{SNw{MSrK;*pFCK-t_)yzTd~R8%1z6`IqGQWF zQW>HAWlQJSbq@JrT$BxUpz*8zOr6_|-sjMT!I;TnS1>9bInMCn;}r)V-28}2VW|lp z=PZ0^X*GTnzkY(ZSN@>tfpsn?N>%e{YcWRYi;nuG!|LJsrE=j0%xOF_4<);21#*up z+h`HlL8c!hOU_T?p~R4u+mZ9Tk^f=IacX<9EK)KDVL7#e6JJ!TUAgO2{Q6|D0p?eXcyYUDcj$O?Gd`Bq_(^_v554$H9P?_HKxk(c`MA%pkIuZ)q{hy!4067BSh|B57#c{8S^U zcNCwjmt}6st2K`4E0aa0P}VPO51P~v%(Y!>*>Rkk!|oZx9lUnI1ZSAG!LqBW4)Io* znQIlw%rH4Hjm*u@V)WnQv*hf^dc5RU#-W4pMh{AHL_~xi>RWyFQ^YhHN*imbcwuQ6 za^m+b?+BQPC%7f7rc8VB<3sS4>pOR-I*6Pm%|qaulflU93?&VGndy?{iO1LQ;45*# z89uA(jvszhTs1|~T-O<~-wOBK$ro0hi7)&5u zzF^%*yFQmDYSG&_#{@b#9$4QA&bn-Gyg9P3Y+L`Jd9&3$D@#!P?RH*Kv_QNV4`AAm z#p!)CRdMAj8)^m=ly)t55fr<=)0oV}6qHOBugH7YbnaSFOKZ=~`|$Wf-p?4nh}P&A zn_&{WfQN?9>mRpra(JO@3+0f(Lt|Sx-P5JGR z{nGNIJUTSFulFz)1+C+=mEgCkFvx|375BkfvQ%{fF}uZU5FWYOb8bFazNC z!2mhqUy*llaQ8A16Y1$J&6-YvqOKbN_hSGsf&2xT*kuFhEWsiQ7hY(Ql7>By~z{~Ot`IC%d`OLWO}oXdc1-2`l=YAD{^xOs7`O_d1x z>jVE?F#c~m)@$d*+x`!6=Y6vbGda#q6RZwl4piY~^M=y{=Z6WGXCXem6Ayo7x1ui+ z#C{zSf9%@%wJt$iY-iPuFYSGuowL_g>y;M5u9)KwVT_|iTXQO*F)=?l`MM|e?X6%0 zwv)ggY(+f87~W`$S-niH+H}O6Xwr(LuWl4C4*QkDVq^rUy3wU&G##;(_~^^3nGwrB z>zC_|TYY(|Uxe5`JqtfFtUbKC?cLHQ_6H%9!^MX?cssx8L0=d;?4wWBg5f z{guTcxyHK%dHHvz?$=l4>!XkUMYHEehE3Zj_0WBEO%SMT+WH+Sa9%>?C;^NCrFavD%Qk1wBAQ@-&wzwZKUhkbj#kNG7&_ zElx&f>7el$3_sj=1=&mTu$ zXANvo^p@1ma<`tQ_BMVU0FwznQR7~&4CanHl~)d`s;o1VQwJW>Br*A zk*tGQ_Ub+b6&&0`b$HPCbAKwEsWEAZNLpIA9b}6)my|qXv~8WJ2q1l;k~vv-bmU!- zM9?<2a>J~I<#a}*H@O`Q.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(" Date: Sun, 27 Sep 2026 17:23:59 +0000 Subject: [PATCH 05/16] Release: file names survive a slash in the branch name on manual runs Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MD4jNoeKcKQc8pkZyJTepc --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f41556..2aeabe6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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/') @@ -143,7 +143,7 @@ jobs: 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:-dev}-windows-x86_64.zip" ./*) + (cd dist && zip -q "../c64uv-${GITHUB_REF_NAME//\//-}-windows-x86_64.zip" ./*) - name: Upload to release if: startsWith(github.ref, 'refs/tags/') From 20fea8d4795b4ff28f0d9060d65ce3ef5c81509d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:39:27 +0000 Subject: [PATCH 06/16] ARP prime must be a ping: the firmware only learns MACs from packets it answers First Windows run on real hardware: stream start returned 404 "Network Host Resolve Error" with the datagram prime. The Ultimate fills its ARP table when it replies to us, which a UDP packet to the stream port never makes it do. Windows now sends an ICMP echo via IcmpSendEcho (no process spawn); the Linux no-interface fallback pings too instead of sending a datagram. Protocol fact recorded in CLAUDE.md. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MD4jNoeKcKQc8pkZyJTepc --- CLAUDE.md | 18 +++++++++++------- README.md | 6 +++--- src/compat.h | 11 ++++++----- src/compat_posix.c | 22 ++++++++++++---------- src/compat_win32.c | 23 +++++++++++++++++------ 5 files changed, 49 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ac2ac84..119d7f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,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, @@ -216,7 +220,7 @@ 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 + 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 @@ -228,12 +232,12 @@ control + password, drag-and-drop run, help overlay) shipped in v0.2.0. `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 = one datagram), `compat_sock` + `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). Compiles warning-free; awaiting Michal's run on a Windows - box. Unit/integration tests stay Linux-only (bash + loopback). A macOS + flags work). Michal's first Windows run (2026-09-27): discovery, REST, + DMA keyboard all worked; the stream needed the ICMP prime (above). 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. diff --git a/README.md b/README.md index 307b072..0f1c5a8 100644 --- a/README.md +++ b/README.md @@ -179,9 +179,9 @@ terminal. **Windows** (experimental, 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 the stream's ARP prime is a plain packet rather than `ping -I`, -so a VPN that claims the LAN route may need to be off. Tested only through -the cross-compiler so far; reports welcome. +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 diff --git a/src/compat.h b/src/compat.h index 7bd5d41..3af4a22 100644 --- a/src/compat.h +++ b/src/compat.h @@ -114,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 index 363dc00..c073ae7 100644 --- a/src/compat_win32.c +++ b/src/compat_win32.c @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -272,12 +273,22 @@ bool compat_neighbor_mac(const char *ip, char *out, size_t cap) return found; } -// One datagram is enough here: the Linux `ping -I` exists for policy -// routing that detours LAN traffic, which Windows VPN clients do not do -// in the same way (unverified; revisit if a Windows tester hits "Network -// Host Resolve Error" with a VPN up). +// 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)ifname; - compat_sendto(s, "", 1, ip, 11000); + (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); } From f0c9ad6b73fadd3bec30c19f6fb15fb8e3478d0c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:41:04 +0000 Subject: [PATCH 07/16] Windows port verified on hardware; sharper hint for a dead Ethernet link Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MD4jNoeKcKQc8pkZyJTepc --- CLAUDE.md | 5 +++-- README.md | 2 +- src/main.c | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 119d7f3..18398ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -236,8 +236,9 @@ control + password, drag-and-drop run, help overlay) shipped in v0.2.0. 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). Michal's first Windows run (2026-09-27): discovery, REST, - DMA keyboard all worked; the stream needed the ICMP prime (above). Unit/integration tests stay Linux-only (bash + loopback). A macOS + 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. diff --git a/README.md b/README.md index 0f1c5a8..206cf43 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ 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** (experimental, x86_64): unzip `c64uv--windows-x86_64.zip` +**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 diff --git a/src/main.c b/src/main.c index fab0274..17adf62 100644 --- a/src/main.c +++ b/src/main.c @@ -194,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; } From 02af4ea061976a882d55f49a58e65da882b98d7f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:42:52 +0000 Subject: [PATCH 08/16] tools/mkd64.py: a minimal autostarting .d64 for drop tests Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MD4jNoeKcKQc8pkZyJTepc --- .gitignore | 1 + tools/mkd64.py | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 tools/mkd64.py diff --git a/.gitignore b/.gitignore index d36a10b..abeda88 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ tests/run c64uv.exe *.zip c64uv.res.o +*.d64 diff --git a/tools/mkd64.py b/tools/mkd64.py new file mode 100644 index 0000000..4f79d41 --- /dev/null +++ b/tools/mkd64.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Write a minimal .d64 holding one autostarting BASIC program. + + python3 tools/mkd64.py [out.d64] + +The program (first file on the disk, so LOAD"*",8,1 / RUN gets it) clears +the screen, prints a banner and cycles the border colour: an unmistakable +sign that a dropped image was mounted, loaded and started. Standard 35 +tracks, 174848 bytes, no error bytes; BAM and directory as the 1541 DOS +writes them, so the Ultimate's drive emulation and real drives read it. +""" +import struct +import sys + +SECTORS = [21] * 17 + [19] * 7 + [18] * 6 + [17] * 5 # per track 1..35 + + +def offset(track, sector): + return (sum(SECTORS[: track - 1]) + sector) * 256 + + +def basic(lines): + """Tokenise (line number, tokenised bytes) pairs into a $0801 PRG.""" + out = bytearray(b"\x01\x08") # load address + addr = 0x0801 + for num, body in lines: + nxt = addr + 5 + len(body) + out += 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() From 7711bb7aba00630210d3a7c3de3057575e6ee216 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:46:54 +0000 Subject: [PATCH 09/16] store: log the files:info reply when refusing to overwrite Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MD4jNoeKcKQc8pkZyJTepc --- src/main.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main.c b/src/main.c index 17adf62..0ec7ee6 100644 --- a/src/main.c +++ b/src/main.c @@ -805,7 +805,8 @@ static long store_image(CURL *curl, const char *host, const char *path, return -1; } if (code == 200 && strstr(resp, "\"size\"")) { // present: don't clobber - SDL_Log("%s already exists on the Ultimate, not overwriting", shown); + SDL_Log("%s already exists on the Ultimate, not overwriting " + "(files:info said: %s)", shown, resp); return -1; } if (!ftp_upload(curl, host, rpath, data, (size_t)len)) From afd9b78ef60d21c9df661053742390d655ee85a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:51:20 +0000 Subject: [PATCH 10/16] CLAUDE.md: --store verified on hardware Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MD4jNoeKcKQc8pkZyJTepc --- CLAUDE.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 18398ac..e1a728d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -255,8 +255,9 @@ 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** (implemented 2026-09-27, awaiting a real - hardware run): `--store ` / `C64U_STORE` switches image drops +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 @@ -271,9 +272,9 @@ control + password, drag-and-drop run, help overlay) shipped in v0.2.0. preference: upload to `/Temp` and move the file from the Ultimate menu himself. Still open: whether SDL reports a modifier held during a drag on Wayland (`--verbose` logs the Shift state at drop time to find out; - a modifier-selected store would build on that), and hardware - verification of the `:info` reply for a missing file (assumed non-200) - and of `image=` taking a literal `/`-separated path. Follow-up on top + a modifier-selected store would build on that). Verified: `:info` + answers non-200 for a missing file and `image=` takes a literal + `/`-separated path. 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 From e0aead2f5e14a1909b7630190c6dd9a52b6bd916 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:53:27 +0000 Subject: [PATCH 11/16] store autostart: let the C64 boot before gating, wait for the LOAD to start Reset zeroes the zero page, so the $CC readiness gate could pass mid-boot and the typed LOAD/RUN landed in a keyboard buffer the KERNAL init then wiped (seen on hardware: reset, READY, nothing typed). Now a 2.5 s head start precedes the gate, and after typing LOAD the code waits for $CC to go non-zero (load in progress) before waiting for READY. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MD4jNoeKcKQc8pkZyJTepc --- src/main.c | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/main.c b/src/main.c index 0ec7ee6..5435c4f 100644 --- a/src/main.c +++ b/src/main.c @@ -662,6 +662,19 @@ 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) { @@ -824,6 +837,11 @@ static long store_image(CURL *curl, const char *host, const char *path, } 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) { @@ -833,9 +851,10 @@ static long store_image(CURL *curl, const char *host, const char *path, } bool typed = dma_type(s, "LOAD\"*\",8,1\r"); if (typed) { - // the cursor is off while the drive works ($CC != 0), so the gate - // waits for READY.; a KERNAL load runs at about 400 bytes/s - SDL_Delay(1000); + // 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"); } From 4e26f4608cffc07adca8f2d17eacb52fed96e11b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:01:23 +0000 Subject: [PATCH 12/16] store: a re-drop replaces the stored file instead of refusing The files:info existence check is gone; FTP STOR overwrites, which is the wanted behaviour for dropping a fixed image again. Verified on hardware: upload, path mount, reset with boot head start, typed LOAD/RUN, program running. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MD4jNoeKcKQc8pkZyJTepc --- CLAUDE.md | 16 +++++++++------- README.md | 6 +++--- src/main.c | 17 +++-------------- tests/fakeultimate.py | 7 ------- tests/integration.sh | 16 ++++------------ 5 files changed, 19 insertions(+), 43 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e1a728d..f0f5c45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -263,8 +263,8 @@ control + password, drag-and-drop run, help overlay) shipped in v0.2.0. 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: - `files/:info` (200 = refuse to overwrite), libcurl FTP upload - (anonymous), `PUT drives/a:mount?image=&mode=readwrite`, then + 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 <= 10-byte batches (no firmware autostart for a path mount; the KERNAL load runs at ~400 bytes/s, so the second gate @@ -272,14 +272,16 @@ control + password, drag-and-drop run, help overlay) shipped in v0.2.0. preference: upload to `/Temp` and move the file from the Ultimate menu himself. Still open: whether SDL reports a modifier held during a drag on Wayland (`--verbose` logs the Shift state at drop time to find out; - a modifier-selected store would build on that). Verified: `:info` - answers non-200 for a missing file and `image=` takes a literal - `/`-separated path. Follow-up on top + a modifier-selected store would build on that). 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`; a name containing - `exists` answers `:info` with 200). + 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/README.md b/README.md index 206cf43..2c5b6de 100644 --- a/README.md +++ b/README.md @@ -129,9 +129,9 @@ To keep dropped images, start the viewer with `--store FOLDER` (or set 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. A file with the same name is -never overwritten; move or rename it from the Ultimate's menu first. -`.prg`/`.crt`/`.sid` drops are unaffected by `--store`. +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. diff --git a/src/main.c b/src/main.c index 5435c4f..b2669f2 100644 --- a/src/main.c +++ b/src/main.c @@ -694,7 +694,7 @@ static const char *path_base(const char *path) // 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. -// An existing file is never overwritten. +// A file of the same name is replaced (FTP STOR overwrites). static bool curl_has_ftp(void) { @@ -811,26 +811,15 @@ static long store_image(CURL *curl, const char *host, const char *path, curl_free(esc); char url[1024]; - snprintf(url, sizeof url, "http://%s/v1/files/%s:info", host, rpath); - long code = rest_req(curl, "GET", url, NULL, 0, NULL, 3000, resp, NULL); - if (code == -1) { - SDL_Log("files:info: no response from Ultimate"); - return -1; - } - if (code == 200 && strstr(resp, "\"size\"")) { // present: don't clobber - SDL_Log("%s already exists on the Ultimate, not overwriting " - "(files:info said: %s)", shown, resp); - return -1; - } 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)", shown, len); + 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); - code = rest_put(curl, url, resp); + long code = rest_put(curl, url, resp); if (code != 200) { SDL_Log("drives/a:mount HTTP %ld: %s", code, resp); return -1; diff --git a/tests/fakeultimate.py b/tests/fakeultimate.py index 5ec098a..fa8a5f0 100644 --- a/tests/fakeultimate.py +++ b/tests/fakeultimate.py @@ -176,13 +176,6 @@ def do_GET(self): "joysticks": [{"port": 1, "inputs": []}, {"port": 2, "inputs": []}], "errors": []}) - elif self.path.startswith("/v1/files/") and self.path.endswith(":info"): - # a name containing "exists" stands for a file already there - if "exists" in self.path: - self._json({"path": self.path[10:-5], "size": 174848, - "errors": []}) - else: - self._json({"errors": ["File not found"]}, 404) else: self._json({"errors": ["Unknown API Call"]}, 404) diff --git a/tests/integration.sh b/tests/integration.sh index 7471b8f..8668341 100644 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -181,10 +181,10 @@ 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 checked for a name clash, FTP-uploaded into the -# folder, mounted by path read-write, and autostarted by the viewer: reset -# (with cartridge parking), readiness gate, LOAD"*",8,1 and RUN typed in -# <= 10-byte KEYB batches. An existing name refuses without uploading. +# 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 <= 10-byte KEYB batches. : > "$out/disc.log" C64U_DMA_PORT=8065 C64U_FTP_PORT=8067 timeout 60 \ @@ -193,7 +193,6 @@ python3 - "$out/disc.log" <<'EOF' import sys log = open(sys.argv[1]).read().splitlines() want = ["PUT /v1/configs/C64%20and%20Cartridge%20Settings/Cartridge?value=", - "GET /v1/files/Usb0/my%20games/disk.d64:info", "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", @@ -210,13 +209,6 @@ for line in log: 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 -cp "$out/disk.d64" "$out/exists.d64" -: > "$out/disc.log" -C64U_FTP_PORT=8067 timeout 30 ./c64uv --host 127.0.0.42:8064 --store /Temp \ - --run "$out/exists.d64" 2> "$out/store.err" && exit 1 -grep -q "GET /v1/files/Temp/exists.d64:info" "$out/disc.log" -! grep -q "FTP STOR\|drives\|machine:reset" "$out/disc.log" -grep -q "already exists" "$out/store.err" echo "stored image test passed" # ------------------------------------------------------------ type + screen From d382ecc6ffadab33dc358cd03d247573bb11dfed Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:24:01 +0000 Subject: [PATCH 13/16] Docs: Shift-during-drop verified on Wayland; boot pause after --do reset Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MD4jNoeKcKQc8pkZyJTepc --- CLAUDE.md | 6 +++--- README.md | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f0f5c45..81c4e1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -270,9 +270,9 @@ control + password, drag-and-drop run, help overlay) shipped in v0.2.0. 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. Still open: whether SDL reports a modifier held during a drag - on Wayland (`--verbose` logs the Shift state at drop time to find out; - a modifier-selected store would build on that). Verified: `image=` + 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 diff --git a/README.md b/README.md index 2c5b6de..1d8ed5d 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,8 @@ 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). Exit status is 0 on success, 1 +`--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 From 825f5e8f3bf0b4a4381a2c0476dd4108889601f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:27:09 +0000 Subject: [PATCH 14/16] --type: letters of either case type as plain PETSCII letters Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MD4jNoeKcKQc8pkZyJTepc --- src/main.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main.c b/src/main.c index b2669f2..bb81cd7 100644 --- a/src/main.c +++ b/src/main.c @@ -1026,7 +1026,9 @@ static bool type_text(const char *host, const char *text) } else if (*p == '\n') { c = 0x0D; } else { - c = ascii_to_petscii((unsigned char)*p); + // 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; From 61482b7bb85f2f764af6c4639e23aaa938c1eb46 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:28:39 +0000 Subject: [PATCH 15/16] KEYB batches of 8; version 0.2.9 Typed text in frames of exactly 10 bytes went missing on hardware twice (the short tail batch arrived, the 10-byte one did not), so dma_type sends 8 per frame. Bump to 0.2.9. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MD4jNoeKcKQc8pkZyJTepc --- CLAUDE.md | 7 +++++-- src/main.c | 13 ++++++++----- tests/integration.sh | 12 ++++++------ 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 81c4e1d..97f3f6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,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 @@ -266,7 +269,7 @@ control + password, drag-and-drop run, help overlay) shipped in v0.2.0. 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 <= 10-byte batches (no firmware autostart for a + 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 diff --git a/src/main.c b/src/main.c index bb81cd7..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" @@ -778,13 +778,16 @@ static bool ftp_upload(CURL *curl, const char *host, const char *rpath, return res == CURLE_OK; } -// Types text into the KERNAL keyboard buffer over a DMA connection, in the -// 10-byte batches the buffer holds (the firmware does not chunk). +// 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 += 10) { - size_t n = len - i < 10 ? len - i : 10; + 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 diff --git a/tests/integration.sh b/tests/integration.sh index 8668341..8d5e529 100644 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -184,7 +184,7 @@ echo "disk image test passed" # 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 <= 10-byte KEYB batches. +# RUN typed in <= 8-byte KEYB batches. : > "$out/disc.log" C64U_DMA_PORT=8065 C64U_FTP_PORT=8067 timeout 60 \ @@ -197,8 +197,8 @@ want = ["PUT /v1/configs/C64%20and%20Cartridge%20Settings/Cartridge?value=", "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=10", - "DMA cmd=FF03 len=2", + "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"] @@ -212,13 +212,13 @@ EOF echo "stored image test passed" # ------------------------------------------------------------ type + screen -# --type goes out as KEYB frames in <= 10-byte batches with \n as RETURN; +# --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=10" "$out/disc.log" | grep -q '^1$' -grep -q "DMA cmd=FF03 len=1$" "$out/disc.log" +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\. *$' From 2d63b9c62346bb43c6c9cd66df45948aeafd2188 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:31:38 +0000 Subject: [PATCH 16/16] Windows exe: numeric FILEVERSION derived from the version string Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MD4jNoeKcKQc8pkZyJTepc --- Makefile | 5 ++++- assets/c64uv.rc | 7 ++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 03036ee..c7d9a59 100644 --- a/Makefile +++ b/Makefile @@ -32,8 +32,11 @@ 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)\"' -O coff -o $@ $< + 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/assets/c64uv.rc b/assets/c64uv.rc index c69ea9e..e1a39b6 100644 --- a/assets/c64uv.rc +++ b/assets/c64uv.rc @@ -1,12 +1,13 @@ // 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 comes from the Makefile (the string in src/main.c). +// 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 0,0,0,0 -PRODUCTVERSION 0,0,0,0 +FILEVERSION C64UV_VERSION_COMMAS +PRODUCTVERSION C64UV_VERSION_COMMAS BEGIN BLOCK "StringFileInfo" BEGIN