From 93814c3e95e4bb7c459215aed0857a2edc7f126c Mon Sep 17 00:00:00 2001 From: rijads Date: Sun, 4 Oct 2026 15:25:39 +0200 Subject: [PATCH] Send games from a PC or phone browser, with a QR code on screen Add games gets a first row, Send from PC or phone. Its screen runs a small HTTP server for as long as it is open (port 9666, or the next one the sandbox allows) and shows the address as text and as a QR code. The page it serves takes dropped files and whole folders, uploads them one at a time with progress, and lists what the console did with each. Game WADs are moved into place, archives go through the importer and are deleted afterwards, add-ons and other files are refused with a reason. qrcodegen 1.8.0 joins the pinned third-party fetch, renamed to fetch-third-party.sh; the page is embedded at build time by tools/embed-file.py. tools/send.py does what the page does, for console tests with test/console-send.cfg. --- DEVELOPMENT.md | 32 +- Makefile | 18 +- README.md | 17 +- THIRD_PARTY_NOTICES.md | 3 +- src/launcher/launcher.c | 282 ++++++++- src/launcher/screen.c | 42 +- src/launcher/screen.h | 3 + src/launcher/upload.c | 568 ++++++++++++++++++ src/launcher/upload.h | 35 ++ src/launcher/upload.html | 221 +++++++ test/console-import.cfg | 2 +- test/console-send.cfg | 3 + third_party/third_party.mk | 26 +- tools/embed-file.py | 12 + ...h-archive-libs.sh => fetch-third-party.sh} | 6 +- tools/send.py | 62 ++ 16 files changed, 1263 insertions(+), 69 deletions(-) create mode 100644 src/launcher/upload.c create mode 100644 src/launcher/upload.h create mode 100644 src/launcher/upload.html create mode 100644 test/console-send.cfg create mode 100644 tools/embed-file.py rename tools/{fetch-archive-libs.sh => fetch-third-party.sh} (73%) create mode 100644 tools/send.py diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 6274ad8..00ced36 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -6,7 +6,8 @@ DualSense through ScePad and playing sound through AudioOut. Title ID `PPSA99666 All PS5, platform, audio, launcher and test code here is written for this project. The external code is id's game source, the archive libraries the importer is built from (libarchive, xz's -liblzma, zlib) and the build tools listed at the end. +liblzma, zlib), the QR code library of the send screen (qrcodegen) and the build tools listed at +the end. ## Layout @@ -14,16 +15,16 @@ liblzma, zlib) and the build tools listed at the end. | --- | --- | | `src/doom/` | id's original game code with the 64-bit and portability fixes listed below | | `src/port/` | Doom's `i_*` layer (main, system, video, sound, network, controller mapping) on top of the platform API, and the button glyphs shared with the launcher | -| `src/launcher/` | The game list shown before the engine starts: IWAD discovery, settings, Doom-style drawing from the bundled WAD, HTML folder listings, and the importer (HTTP or local file, WAD or ZIP/7Z/RAR) | +| `src/launcher/` | The game list shown before the engine starts: IWAD discovery, settings, Doom-style drawing from the bundled WAD, HTML folder listings, the importer (HTTP or local file, WAD or ZIP/7Z/RAR), and the send screen's HTTP server and web page (`upload.c`, `upload.html`) | | `src/platform/platform.h` | The platform API: time, log, files and folder listing, video present, pad, rumble, audio output, HTTP, text input | | `src/audio/` | Sound engine: SFX mixer, MUS sequencer with DMX-style voice allocation, OPL FM synth | | `src/ps5/` | PS5 backend: startup (`crt0.c`), system, VideoOut, AGC compute presenter, pad, AudioOut, `sceHttp`, the IME keyboard | | `src/ps5/present.cl` | GPU kernel: palette lookup, sharp-bilinear scaling to 1080p, tiled scanout writes (gfx1010, wave64) | | `src/host/` | Headless Linux backend for testing on the PC: PNG frames, scripted pad, WAV audio, libcurl HTTP | -| `third_party/` | Build configuration for libarchive, liblzma and zlib (`config.h` files and `third_party.mk`); the sources are fetched, not copied | +| `third_party/` | Build configuration for libarchive, liblzma, zlib and qrcodegen (`config.h` files and `third_party.mk`); the sources are fetched, not copied | | `test/` | `render_music` (MUS lump to WAV), `present_preview` (runs the GPU kernel's math on the CPU) and the console test plans | | `sce_sys/` | `param.json` (raise `contentVersion` in every release) and the icon (the shareware WAD's `M_DOOM` logo, scaled 3x on black) | -| `tools/` | Tool, library and shareware WAD fetch, kernel embedding, deploy, console test runner | +| `tools/` | Tool, library and shareware WAD fetch, kernel and page embedding, deploy, console test runner, `send.py` (sends files to the send screen like its page) | ## Build @@ -35,7 +36,7 @@ docker build -t ps5-doom-build docker/ MSYS_NO_PATHCONV=1 docker run --rm -v "$(cygpath -w "$PWD"):/src" -w /src ps5-doom-build make -j16 ps5 host ``` -`make ps5` fetches the pinned tools, the archive libraries (`tools/fetch-archive-libs.sh`) and the +`make ps5` fetches the pinned tools, the libraries (`tools/fetch-third-party.sh`) and the shareware WAD on first use, then: 1. compiles the game, the launcher and the PS5 layer for `x86_64-sie-ps5` against the PS5 payload @@ -88,7 +89,14 @@ and maps the chosen game to Doom's game mode, mission and language. The launcher is read through or re-read from the start instead; - writes into the first writable WAD folder (`/app0/wads` on the console), through a `.part` file that is checked as an IWAD before it is renamed; -- shows a one-time notice with a checkbox before the first download. +- shows a one-time notice with a checkbox before the first download; +- receives files from a browser on the send screen (*Add games*, *Send from PC or phone*): + `upload.c` listens only while that screen is open, on port 9666 or the next free one, serves + `upload.html` (embedded at build time by `tools/embed-file.py`) and stores each `PUT /upload/` + as `.upload` in the WAD folder. The launcher moves game WADs into place, runs archives + through the importer and deletes them afterwards, and reports each result on the screen and to the + page, which polls `GET /status`. The address shown is the one the route to the internet leaves + from (a connected UDP socket's local address); the QR code is drawn with qrcodegen. Saves are per game: `/sav.dsg` (for example `doom2sav0.dsg`). @@ -122,7 +130,8 @@ without opening it), `reset` (delete the app's saved data in `/download0` first, state; `console-reset.cfg` does only that). Plans: `console-play.cfg` (menus, play, save, load), `console-cpu.cfg`, `console-soak.cfg` (6 minutes), `console-import.cfg` (launcher, notice, folder listing and a 7Z import from `http://192.168.0.10:8666/`), `console-autoimport.cfg`, `console-tnt.cfg`, -`console-ultimate.cfg`. +`console-ultimate.cfg`, `console-send.cfg` (opens the send screen for a minute; run +`tools/send.py
` meanwhile). ## PS5 facts learned on hardware (FW 13.00) @@ -136,6 +145,8 @@ import from `http://192.168.0.10:8666/`), `console-autoimport.cfg`, `console-tnt plus `sceKernelGetdents` (8-byte records: 32-bit inode, 16-bit length, type, name length). Saves use explicit `/download0/...` paths; output is captured by pointing `stdout`/`stderr` at a pipe (`fdopen`), and klog wants one line per `sceKernelDebugOutText`. +- A title can listen on TCP ports, but the sandbox refuses some with `EACCES` (8666 and 50000 of + those tried; 8000, 9000, 9090, 9666, 18666 and 30000 work). BSD sockets come from `libkernel`. - `sceHttp` with `sceSsl` works in the sandboxed title, plain and HTTPS, including `Range` request headers added with `sceHttpAddRequestHeader` (answers 206). `sceHttpReadData` returns only when the buffer is full or the body ends. About 12 MB/s from a PC on the same wired network. @@ -203,7 +214,8 @@ id's keyboard help (`HELP1`, `HELP`); DOOM II keeps its *Read This!* entry whene `b1315a9`: its `ps5-native-tool` (PIE to PS5 module, FSELF signing), its clean-room `libc.prx`, its intermediate PIE linker script, and the PS5 payload SDK v0.42 it fetches. Built in `.deps/native-app` by `tools/fetch-native-tools.sh`; nothing from it is copied into `src/`. -- [libarchive](https://www.libarchive.org/) 3.8.9, [xz](https://tukaani.org/xz/) 5.8.4 (liblzma) - and [zlib](https://zlib.net/) 1.3.2, fetched and checked by `tools/fetch-archive-libs.sh` into - `.deps/archive` and compiled into the game for both targets. +- [libarchive](https://www.libarchive.org/) 3.8.9, [xz](https://tukaani.org/xz/) 5.8.4 (liblzma), + [zlib](https://zlib.net/) 1.3.2 and [qrcodegen](https://www.nayuki.io/page/qr-code-generator-library) + 1.8.0, fetched and checked by `tools/fetch-third-party.sh` into `.deps/third-party` and compiled + into the game for both targets. - clang/lld/llvm 18 (x86_64-sie-ps5 and amdgcn targets). diff --git a/Makefile b/Makefile index ea59bc7..c1a9950 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,8 @@ PS5_SRC := $(wildcard src/ps5/*.c) include third_party/third_party.mk -INCLUDES := -Isrc/doom -Isrc/port -Isrc/platform -Isrc/audio -Isrc/launcher $(ARCHIVE_INCLUDES) +GEN := build/gen +INCLUDES := -Isrc/doom -Isrc/port -Isrc/platform -Isrc/audio -Isrc/launcher -I$(GEN) $(THIRD_INCLUDES) DOOM_WARNINGS := -Wall -Wno-unused-const-variable -Wno-unused-but-set-variable \ -Wno-unused-variable -Wno-logical-not-parentheses -Wno-missing-braces OWN_WARNINGS := -Wall -Wextra -Wno-unused-parameter @@ -52,11 +53,17 @@ $(HOST_DIR)/%.o: src/%.c @mkdir -p $(dir $@) $(HOST_CC) -std=gnu11 $(HOST_FLAGS) $(OWN_WARNINGS) $(INCLUDES) -c $< -o $@ -$(HOST_DIR)/third_party/%.o: $(ARCHIVE_DEPS)/%.c +$(HOST_DIR)/third_party/%.o: $(THIRD_DEPS)/%.c @mkdir -p $(dir $@) $(HOST_CC) $(THIRD_FLAGS) -g -MMD -MP $(call third_flags,$<) -c $< -o $@ -$(HOST_OWN_OBJ): | $(ARCHIVE_STAMP) +$(HOST_OWN_OBJ): | $(THIRD_STAMP) + +$(GEN)/upload_page.h: src/launcher/upload.html tools/embed-file.py + @mkdir -p $(GEN) + python3 tools/embed-file.py $< upload_page $@ + +$(HOST_DIR)/launcher/upload.o $(PS5_DIR)/launcher/upload.o: $(GEN)/upload_page.h tools: $(NATIVE_TOOL) @@ -95,11 +102,11 @@ $(PS5_DIR)/%.o: src/%.c $(SDK)/target/lib/libkernel.so @mkdir -p $(dir $@) $(PS5_CC) -std=gnu11 $(PS5_FLAGS) $(OWN_WARNINGS) $(INCLUDES) -I$(PS5_GEN) -c $< -o $@ -$(PS5_DIR)/third_party/%.o: $(ARCHIVE_DEPS)/%.c $(SDK)/target/lib/libkernel.so +$(PS5_DIR)/third_party/%.o: $(THIRD_DEPS)/%.c $(SDK)/target/lib/libkernel.so @mkdir -p $(dir $@) $(PS5_CC) $(THIRD_FLAGS) -MMD -MP $(call third_flags,$<) -c $< -o $@ -$(PS5_OWN_OBJ): | $(ARCHIVE_STAMP) +$(PS5_OWN_OBJ): | $(THIRD_STAMP) $(PS5_DIR)/llvm-pie.elf: $(PS5_OBJ) $(AGC_STUBS) src/ps5/symbols.map $(SDK)/bin/prospero-lld -T $(NATIVE)/tooling/native/ps5-pie.ld --eh-frame-hdr --gc-sections \ @@ -124,6 +131,7 @@ $(APP)/eboot.bin: $(PS5_DIR)/eboot.elf $(RUNTIME) sce_sys/param.json sce_sys/ico cp $(LA_DIR)/../COPYING $(APP)/licenses/libarchive.txt cp $(XZ_DIR)/../../COPYING.0BSD $(APP)/licenses/liblzma.txt cp $(ZL_DIR)/LICENSE $(APP)/licenses/zlib.txt + head -n 22 $(QR_DIR)/qrcodegen.c > $(APP)/licenses/qrcodegen.txt $(NATIVE_TOOL) self --inspect --file $@ $(NATIVE_TOOL) self --inspect --file $(APP)/sce_module/libc.prx diff --git a/README.md b/README.md index a9dc47b..f251e6c 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ id Software's original DOOM source (`linuxdoom-1.10`) running as a native PS5 ti home-screen icon, drawn on the GPU through Sony's AGC driver and VideoOut, DualSense input with analog sticks and rumble, and the DOS-era FM music synthesised from the game's own instrument bank. It ships with the free shareware episode of DOOM and plays The Ultimate DOOM, DOOM II and Final -DOOM from your own game files, which it can import from a folder on the console or download from -your PC, including straight out of ZIP, 7Z and RAR archives. +DOOM from your own game files, which you can send from a PC or phone browser, download from a +link or copy to the console, including straight out of ZIP, 7Z and RAR archives. ## Requirements @@ -38,18 +38,23 @@ The files from the original releases and from the 2024 *DOOM + DOOM II* re-relea ### Adding your games -Either way, the game files can also be inside ZIP, 7Z or RAR archives: DOOM extracts the game +Whichever way, the game files can also be inside ZIP, 7Z or RAR archives: DOOM extracts the game WADs it recognises and skips everything else. +- **Send them from a PC or phone.** In the game list choose *Add games...*, then *Send from PC or + phone*. Open the address the screen shows (for example `http://192.168.1.50:9666/`) in a browser + on the same network, or scan its QR code, and drop the files or whole folders on the page. Both + the page and the console show what was added; archives are deleted once their games are out. The + console only accepts files while that screen is open. - **Copy them to the console.** Put the files or archives in `/data/homebrew/PPSA99666/wads/` (FTP, ps5upload or USB). DOOM picks them up the next time it starts. - **Download them from a link.** In the game list choose *Add games...*, then *Link*, type an `http://` or `https://` address on the console keyboard and choose *Download*. The link can point at a WAD, an archive, or a folder; for a folder DOOM lists the files and you pick one or all. -The simplest way to serve your own files from a PC on the same network is to open a terminal in the -folder that holds them and run a small web server, then use the address it prints with your PC's -local IP (for example `http://192.168.1.20:8000/`): +To download from your own PC instead, open a terminal in the folder that holds the files and run a +small web server, then use the address it prints with your PC's local IP (for example +`http://192.168.1.20:8000/`): ```bash python -m http.server 8000 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index c79b201..c447f28 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -9,9 +9,10 @@ release package carries these notices and the license texts in `PPSA99666/licens | --- | --- | --- | | [DOOM source code](https://github.com/id-Software/DOOM) (`linuxdoom-1.10`), id Software | The game engine in `src/doom/`, with the changes listed in `DEVELOPMENT.md` | GNU GPL v2 (`LICENSE`) | | DOOM shareware IWAD v1.9 (`DOOM1.WAD`, episode 1), id Software | Bundled game data, `wads/doom1.wad`, unmodified; fetched by `tools/fetch-shareware.sh`. Its `M_DOOM` logo, scaled up, is the home-screen icon (`sce_sys/icon0.png`) | id Software's shareware terms: free to share unmodified and free of charge | -| [libarchive](https://www.libarchive.org/) 3.8.9 | Reading ZIP, 7Z and RAR archives in the importer (`tools/fetch-archive-libs.sh`, configured by `third_party/`) | BSD 2-Clause, with a few files public domain or CC0 (`licenses/libarchive.txt`) | +| [libarchive](https://www.libarchive.org/) 3.8.9 | Reading ZIP, 7Z and RAR archives in the importer (`tools/fetch-third-party.sh`, configured by `third_party/`) | BSD 2-Clause, with a few files public domain or CC0 (`licenses/libarchive.txt`) | | [xz](https://tukaani.org/xz/) 5.8.4, liblzma | LZMA and LZMA2 decoding for 7Z archives | BSD Zero Clause (`licenses/liblzma.txt`) | | [zlib](https://zlib.net/) 1.3.2 | Deflate decoding for ZIP archives | zlib license (`licenses/zlib.txt`) | +| [QR Code generator](https://www.nayuki.io/page/qr-code-generator-library) 1.8.0, Project Nayuki | The QR code of the send screen's address (`qrcodegen.c`) | MIT (`licenses/qrcodegen.txt`) | | `libc.prx` from [ps5-native-app-boilerplate](https://github.com/blackbearreloaded/ps5-native-app-boilerplate), BlackBearReloaded | Runtime module the PS5 loader requires next to a native title (`sce_module/libc.prx`), built from its source at commit `b1315a9` | GNU GPL v3 or later (`licenses/libc-prx-GPL-3.0.txt`) | ## Used to build diff --git a/src/launcher/launcher.c b/src/launcher/launcher.c index aa22adc..e30c558 100644 --- a/src/launcher/launcher.c +++ b/src/launcher/launcher.c @@ -13,6 +13,7 @@ #include "screen.h" #include "settings.h" #include "test_plan.h" +#include "upload.h" #define FRAME_US 16667 #define REPEAT_DELAY 18 @@ -24,6 +25,7 @@ #define BROWSE_ROWS 12 #define FOOTER_Y 188 #define MB (1024.0 * 1024.0) +#define SEND_LINES 4 typedef enum { @@ -32,6 +34,7 @@ typedef enum VIEW_NOTICE, VIEW_BROWSE, VIEW_IMPORT, + VIEW_SEND, } view_t; static game_list_t games; @@ -49,6 +52,12 @@ static int frame, typing, understood, auto_import; static uint32_t held, pressed; static int repeat_timer, input_locked = 1; static const game_t *chosen; +static int send_ready, send_added; +static char send_lines[SEND_LINES][192]; +static int send_line_ok[SEND_LINES], send_line_count; +static char send_archive[GAME_PATH + UPLOAD_NAME]; + +static void start_send(void); static void read_input(void) { @@ -231,14 +240,16 @@ static void add_view(void) { url_clean(settings.url, sizeof(settings.url), text); settings_save(&settings); - add_row = 1; + add_row = 2; } } } else { - move(&add_row, 3); + move(&add_row, 4); if ((pressed & PAD_CROSS) && add_row == 0) + start_send(); + else if ((pressed & PAD_CROSS) && add_row == 1) { if (plat_text_open("Link to a WAD, ZIP, 7Z or RAR file, or a folder", settings.url[0] ? settings.url : "http://") == 0) @@ -246,7 +257,7 @@ static void add_view(void) else set_status("The keyboard is not available", INK_RED); } - else if ((pressed & PAD_CROSS) && add_row == 1 && settings.url[0]) + else if ((pressed & PAD_CROSS) && add_row == 2 && settings.url[0]) { if (settings.accepted) { @@ -262,31 +273,30 @@ static void add_view(void) view = VIEW_NOTICE; } } - else if (((pressed & PAD_CROSS) && add_row == 2) || (pressed & PAD_CIRCLE)) + else if (((pressed & PAD_CROSS) && add_row == 3) || (pressed & PAD_CIRCLE)) view = VIEW_GAMES; } screen_background(); screen_text_center(6, "Add games", INK_GOLD, 2); y = screen_wrap(16, 28, SCREEN_WIDTH - 32, - "Copy game WADs, or ZIP, 7Z or RAR files with them inside, to this folder, " - "for example over FTP:", INK_RED); - screen_text_fit(16, y + 1, SCREEN_WIDTH - 32, plat_wad_folder(), INK_GOLD, 1); - y = screen_wrap(16, y + 12, SCREEN_WIDTH - 32, "Doom adds them the next time it starts.", INK_RED); - y = screen_wrap(16, y + 3, SCREEN_WIDTH - 32, - "DOOM.WAD DOOM2.WAD TNT.WAD PLUTONIA.WAD DOOMU.WAD FREEDOOM1.WAD FREEDOOM2.WAD", - INK_GRAY); - y = screen_wrap(16, y + 5, SCREEN_WIDTH - 32, - "Or download a WAD, ZIP, 7Z or RAR, or a folder your PC shares over HTTP:", INK_RED); + "DOOM.WAD, DOOM2.WAD, TNT.WAD, PLUTONIA.WAD or Freedoom, on their own or inside " + "ZIP, 7Z or RAR files.", INK_GRAY); + draw_row(30, y + 6, "Send from PC or phone", add_row == 0, 1); + y = screen_wrap(16, y + 6 + ROW + 6, SCREEN_WIDTH - 32, + "Or download a file, or a folder your PC shares over HTTP:", INK_RED); snprintf(line, sizeof(line), "Link: %s", settings.url[0] ? settings.url : "not set"); - if (add_row == 0) + if (add_row == 1) screen_skull(16, y + 4, frame / SKULL_FRAMES); - screen_text_fit(30, y + 6, SCREEN_WIDTH - 46, line, add_row == 0 ? INK_WHITE : INK_RED, 1); - draw_row(30, y + 6 + ROW, "Download", add_row == 1, settings.url[0] != 0); - draw_row(30, y + 6 + ROW * 2, "Back", add_row == 2, 1); + screen_text_fit(30, y + 6, SCREEN_WIDTH - 46, line, add_row == 1 ? INK_WHITE : INK_RED, 1); + draw_row(30, y + 6 + ROW, "Download", add_row == 2, settings.url[0] != 0); + draw_row(30, y + 6 + ROW * 2, "Back", add_row == 3, 1); + y = screen_wrap(16, y + 6 + ROW * 3 + 4, SCREEN_WIDTH - 32, + "Or copy them over FTP or USB to this folder; Doom adds them when it starts:", INK_RED); + screen_text_fit(16, y + 1, SCREEN_WIDTH - 32, plat_wad_folder(), INK_GOLD, 1); draw_status(FOOTER_Y - 11, 0); - screen_text_center(FOOTER_Y, add_row == 0 ? GLYPH_CROSS " edit link " GLYPH_CIRCLE " back" - : GLYPH_CROSS " select " GLYPH_CIRCLE " back", INK_GRAY, 1); + screen_text_center(FOOTER_Y, add_row == 1 ? GLYPH_CROSS " edit link " GLYPH_CIRCLE " back" + : GLYPH_CROSS " select " GLYPH_CIRCLE " back", INK_GRAY, 1); } static void notice_view(void) @@ -412,6 +422,23 @@ static void finish_import(const import_status_t *status) auto_import = 0; } +static void draw_bar(int y, long long done, long long total) +{ + char line[64]; + + screen_box(20, y, SCREEN_WIDTH - 40, 10, 0); + if (total > 0) + { + if (done > total) + done = total; + screen_box(22, y + 2, (int)((SCREEN_WIDTH - 44) * done / total), 6, 1); + snprintf(line, sizeof(line), "%.1f / %.1f MB", done / MB, total / MB); + } + else + snprintf(line, sizeof(line), "%.1f MB", done / MB); + screen_text(20, y + 16, line, INK_RED, 1); +} + static void draw_progress(const import_status_t *status) { char line[160]; @@ -424,17 +451,7 @@ static void draw_progress(const import_status_t *status) snprintf(line, sizeof(line), "File %d of %d", status->source_index + 1, status->source_count); screen_text(20, 64, line, INK_GRAY, 1); } - screen_box(20, 80, SCREEN_WIDTH - 40, 10, 0); - if (status->total > 0) - { - long long done = status->done < status->total ? status->done : status->total; - - screen_box(22, 82, (int)((SCREEN_WIDTH - 44) * done / status->total), 6, 1); - snprintf(line, sizeof(line), "%.1f / %.1f MB", status->done / MB, status->total / MB); - } - else - snprintf(line, sizeof(line), "%.1f MB", status->done / MB); - screen_text(20, 96, line, INK_RED, 1); + draw_bar(80, status->done, status->total); if (status->current[0]) { snprintf(line, sizeof(line), "Writing %s", status->current); @@ -473,6 +490,206 @@ static void import_view(void) draw_progress(&status); } +// Reports what happened to a sent file, on the screen and on the page. +static void note(const char *text, int ok) +{ + plat_log("launcher: %s\n", text); + upload_note(text, ok); + if (send_line_count == SEND_LINES) + { + memmove(send_lines, send_lines + 1, sizeof(send_lines[0]) * (SEND_LINES - 1)); + memmove(send_line_ok, send_line_ok + 1, sizeof(send_line_ok[0]) * (SEND_LINES - 1)); + send_line_count--; + } + snprintf(send_lines[send_line_count], sizeof(send_lines[0]), "%s", text); + send_line_ok[send_line_count++] = ok; +} + +static void start_send(void) +{ + send_ready = upload_start(import_dir) == 0; + send_added = 0; + send_line_count = 0; + send_archive[0] = 0; + status_line[0] = 0; + view = VIEW_SEND; +} + +static void leave_send(void) +{ + if (send_archive[0]) + { + import_cancel(); + import_finish(); + unlink(send_archive); + send_archive[0] = 0; + } + upload_stop(); + if (send_added) + { + char text[64]; + + snprintf(text, sizeof(text), "Added %d game%s", send_added, send_added == 1 ? "" : "s"); + set_status(text, INK_GREEN); + game_row = 0; + } + view = send_added ? VIEW_GAMES : VIEW_ADD; +} + +static void add_uploaded_wad(const char *path, const char *name) +{ + char target[GAME_PATH + 16], text[192]; + const char *file = games_known_file(name); + const game_t *game; + + if (!file || games_check_wad(path, NULL)) + { + snprintf(text, sizeof(text), file ? "%s is not a valid game WAD" : "%s is not a game Doom knows", + name); + note(text, 0); + unlink(path); + return; + } + snprintf(target, sizeof(target), "%s/%s", import_dir, file); + unlink(target); + if (rename(path, target)) + { + snprintf(text, sizeof(text), "Cannot store %s on the console", name); + note(text, 0); + unlink(path); + return; + } + games_scan(&games); + game = games_find(&games, file); + snprintf(text, sizeof(text), "Added %s", game ? game->title : file); + note(text, 1); + send_added++; +} + +// Takes the next file the upload server finished. Game WADs are moved into place; archives go +// through the importer under their own name and are deleted once read. +static void take_upload(void) +{ + char path[UPLOAD_PATH], name[UPLOAD_NAME], text[192], source[1][LINK_URL]; + uint8_t magic[4] = {0}; + int fd; + + if (send_archive[0] || !upload_take(path, sizeof(path), name, sizeof(name))) + return; + if ((fd = open(path, O_RDONLY)) >= 0) + { + if (read(fd, magic, sizeof(magic)) != (ssize_t)sizeof(magic)) + memset(magic, 0, sizeof(magic)); + close(fd); + } + if (!memcmp(magic, "IWAD", 4)) + add_uploaded_wad(path, name); + else if (!memcmp(magic, "PWAD", 4)) + { + snprintf(text, sizeof(text), "%s is an add-on (pwad), not a game", name); + note(text, 0); + unlink(path); + } + else + { + snprintf(send_archive, sizeof(send_archive), "%s/%s", import_dir, name); + if (rename(path, send_archive)) + { + snprintf(text, sizeof(text), "Cannot store %s on the console", name); + note(text, 0); + unlink(path); + send_archive[0] = 0; + return; + } + snprintf(source[0], LINK_URL, "%s", send_archive); + import_start(source, 1, import_dir); + } +} + +static void check_archive_import(void) +{ + import_status_t status; + char text[256]; + const char *name; + + if (!send_archive[0]) + return; + import_status(&status); + if (status.state == IMPORT_RUNNING) + return; + import_finish(); + name = strrchr(send_archive, '/') + 1; + if (status.found_count) + snprintf(text, sizeof(text), "Added %s from %s", status.found, name); + else if (status.message[0]) + snprintf(text, sizeof(text), "%s", status.message); + else + snprintf(text, sizeof(text), "No game WADs found in %s", name); + note(text, status.found_count > 0); + send_added += status.found_count; + unlink(send_archive); + send_archive[0] = 0; + games_scan(&games); +} + +static void send_view(void) +{ + const char *address = upload_address(); + upload_progress_t progress; + import_status_t status; + char line[UPLOAD_NAME + 32]; + int y, x, size, i; + + take_upload(); + check_archive_import(); + if (pressed & PAD_CIRCLE) + { + leave_send(); + return; + } + upload_progress(&progress); + + screen_background(); + screen_text_center(6, "Send games", INK_GOLD, 2); + if (!send_ready || !address[0]) + y = screen_wrap(16, 34, SCREEN_WIDTH - 32, + !send_ready ? "The console could not start receiving files. Go back and try again." + : "The console has no network address. Connect it to your network, " + "then open this screen again.", + INK_RED) + 8; + else + { + size = screen_qr(16, 30, address, 3); + x = 16 + size + 12; + y = screen_wrap(x, 32, SCREEN_WIDTH - x - 12, + "On a PC or phone on the same network, open this address or scan the code:", INK_RED); + screen_text_fit(x, y + 3, SCREEN_WIDTH - x - 12, address, INK_GOLD, 0); + y = screen_wrap(x, y + 18, SCREEN_WIDTH - x - 12, + "Then drop your WAD, ZIP, 7Z or RAR files on the page.", INK_RED); + y = y > 30 + size ? y + 8 : 30 + size + 8; + } + + if (progress.receiving) + { + snprintf(line, sizeof(line), "Receiving %s", progress.name); + screen_text_fit(20, y, SCREEN_WIDTH - 40, line, INK_WHITE, 0); + draw_bar(y + 12, progress.done, progress.total); + } + else if (send_archive[0]) + { + import_status(&status); + snprintf(line, sizeof(line), "Adding games from %s", strrchr(send_archive, '/') + 1); + screen_text_fit(20, y, SCREEN_WIDTH - 40, line, INK_WHITE, 0); + draw_bar(y + 12, status.done, status.total); + } + else + for (i = 0; i < send_line_count; i++) + screen_text_fit(20, y + i * LINE_HEIGHT, SCREEN_WIDTH - 40, send_lines[i], + send_line_ok[i] ? INK_GREEN : INK_RED, 0); + screen_text_center(FOOTER_Y, progress.receiving || send_archive[0] ? GLYPH_CIRCLE " stop and go back" + : GLYPH_CIRCLE " back", INK_GRAY, 1); +} + static const game_t *preselected(void) { const game_t *game = games_find(&games, test_plan_game()); @@ -509,7 +726,7 @@ const game_t *launcher_choose(void) while (!chosen) { read_input(); - if (view != VIEW_IMPORT && pressed) + if (view != VIEW_IMPORT && view != VIEW_SEND && pressed) status_line[0] = 0; switch (view) { @@ -528,6 +745,9 @@ const game_t *launcher_choose(void) case VIEW_IMPORT: import_view(); break; + case VIEW_SEND: + send_view(); + break; } if (!chosen) present(); diff --git a/src/launcher/screen.c b/src/launcher/screen.c index 7ca1d2b..5abda5f 100644 --- a/src/launcher/screen.c +++ b/src/launcher/screen.c @@ -9,6 +9,7 @@ #include "glyphs.h" #include "platform.h" +#include "qrcodegen.h" #include "screen.h" #define FONT_FIRST '!' @@ -19,6 +20,8 @@ #define BACKGROUND_SHADE 29 #define COLORMAP_SIZE (34 * 256) #define MAX_PATCH_HEIGHT 256 +#define QR_VERSION_MAX 5 +#define QR_QUIET 2 static uint8_t pixels[SCREEN_WIDTH * SCREEN_HEIGHT]; static uint32_t palette[256]; @@ -27,7 +30,7 @@ static uint8_t shade[256]; static uint8_t inks[INKS][256]; static uint8_t *font[FONT_COUNT]; static uint8_t *title, *logo, *skulls[2]; -static uint8_t outline_color, fill_color; +static uint8_t outline_color, fill_color, paper_color, ink_color; static uint32_t le32(const uint8_t *p) { @@ -101,6 +104,8 @@ static void build_inks(void) } outline_color = nearest(150, 150, 150); fill_color = nearest(190, 30, 30); + paper_color = nearest(255, 255, 255); + ink_color = nearest(0, 0, 0); } int screen_init(const char *wad_path) @@ -334,6 +339,41 @@ int screen_wrap(int x, int y, int width, const char *text, ink_t ink) return y; } +static void fill(int x, int y, int width, int height, uint8_t color) +{ + int i, j; + + for (j = y; j < y + height; j++) + for (i = x; i < x + width; i++) + if (i >= 0 && i < SCREEN_WIDTH && j >= 0 && j < SCREEN_HEIGHT) + pixels[j * SCREEN_WIDTH + i] = color; +} + +int screen_qr(int x, int y, const char *text, int module) +{ + static char encoded[128]; + static uint8_t code[qrcodegen_BUFFER_LEN_FOR_VERSION(QR_VERSION_MAX)]; + static int ready; + uint8_t scratch[qrcodegen_BUFFER_LEN_FOR_VERSION(QR_VERSION_MAX)]; + int size, i, j; + + if (strcmp(encoded, text)) + { + snprintf(encoded, sizeof(encoded), "%s", text); + ready = qrcodegen_encodeText(text, scratch, code, qrcodegen_Ecc_MEDIUM, qrcodegen_VERSION_MIN, + QR_VERSION_MAX, qrcodegen_Mask_AUTO, true); + } + if (!ready) + return 0; + size = qrcodegen_getSize(code); + fill(x, y, (size + 2 * QR_QUIET) * module, (size + 2 * QR_QUIET) * module, paper_color); + for (j = 0; j < size; j++) + for (i = 0; i < size; i++) + if (qrcodegen_getModule(code, i, j)) + fill(x + (QR_QUIET + i) * module, y + (QR_QUIET + j) * module, module, module, ink_color); + return (size + 2 * QR_QUIET) * module; +} + void screen_present(void) { plat_video_present(pixels, palette); diff --git a/src/launcher/screen.h b/src/launcher/screen.h index 2fc6611..1442aa1 100644 --- a/src/launcher/screen.h +++ b/src/launcher/screen.h @@ -25,6 +25,9 @@ void screen_text(int x, int y, const char *text, ink_t ink, int scale); void screen_text_center(int y, const char *text, ink_t ink, int scale); void screen_text_fit(int x, int y, int width, const char *text, ink_t ink, int keep_end); int screen_wrap(int x, int y, int width, const char *text, ink_t ink); +// Draws text as a QR code with a white border, each module a square of the given size; returns its +// width in pixels, or 0 when the text does not fit. +int screen_qr(int x, int y, const char *text, int module); void screen_present(void); #endif diff --git a/src/launcher/upload.c b/src/launcher/upload.c new file mode 100644 index 0000000..a927e25 --- /dev/null +++ b/src/launcher/upload.c @@ -0,0 +1,568 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "platform.h" +#include "upload.h" +#include "upload_page.h" + +#define HEAD_LIMIT 8192 +#define CHUNK (256 * 1024) +#define MAX_FINISHED 16 +#define MAX_NOTES 32 +#define NOTE_TEXT 160 +#define MAX_LEFTOVERS 32 +#define RECEIVE_TIMEOUT_S 1 +#define SEND_TIMEOUT_S 5 +#define IDLE_LIMIT_S 30 + +#ifdef MSG_NOSIGNAL +#define SEND_FLAGS MSG_NOSIGNAL +#else +#define SEND_FLAGS 0 +#endif + +typedef struct +{ + char path[UPLOAD_PATH]; + char name[UPLOAD_NAME]; +} finished_t; + +typedef struct +{ + int id, ok; + char text[NOTE_TEXT]; +} note_t; + +static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_t server; +static int listener = -1, running, port; +static volatile int stopping; +static char folder[UPLOAD_PATH]; +static char address[64]; +static upload_progress_t progress; +static finished_t finished[MAX_FINISHED]; +static int finished_count; +static note_t notes[MAX_NOTES]; +static int note_count, next_note = 1; + +// Only the server thread touches these. +static char head[HEAD_LIMIT + 1]; +static uint8_t chunk[CHUNK]; + +static int send_all(int fd, const void *data, size_t size) +{ + const uint8_t *p = data; + + while (size) + { + ssize_t sent = send(fd, p, size, SEND_FLAGS); + + if (sent <= 0) + return -1; + p += sent; + size -= (size_t)sent; + } + return 0; +} + +static void respond(int fd, const char *status, const char *type, const void *body, size_t length) +{ + char top[256]; + int n = snprintf(top, sizeof(top), + "HTTP/1.1 %s\r\nContent-Type: %s\r\nContent-Length: %lu\r\n" + "Cache-Control: no-store\r\nConnection: close\r\n\r\n", + status, type, (unsigned long)length); + + if (send_all(fd, top, (size_t)n) == 0 && length) + send_all(fd, body, length); +} + +static void respond_text(int fd, const char *status, const char *text) +{ + respond(fd, status, "text/plain; charset=utf-8", text, strlen(text)); +} + +// recv that gives up when the server stops or the browser stays quiet for too long. +static ssize_t receive(int fd, void *buffer, size_t size) +{ + int idle = 0; + + for (;;) + { + ssize_t got = recv(fd, buffer, size, 0); + + if (got >= 0) + return got; + if ((errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) || stopping || + ++idle >= IDLE_LIMIT_S / RECEIVE_TIMEOUT_S) + return -1; + } +} + +// Reads the request head into head[]; returns its length including the blank line, with the +// number of bytes read so far (head plus the start of the body) in *used. +static int read_head(int fd, int *used) +{ + int length = 0; + + while (length < HEAD_LIMIT) + { + ssize_t got = receive(fd, head + length, (size_t)(HEAD_LIMIT - length)); + char *end; + + if (got <= 0) + return -1; + length += (int)got; + head[length] = 0; + if ((end = strstr(head, "\r\n\r\n"))) + { + *used = length; + return (int)(end - head) + 4; + } + } + return -1; +} + +static const char *header_value(const char *name) +{ + size_t length = strlen(name); + const char *line = strstr(head, "\r\n"); + + while (line && line[2] != '\r') + { + line += 2; + if (!strncasecmp(line, name, length) && line[length] == ':') + { + const char *value = line + length + 1; + + while (*value == ' ' || *value == '\t') + value++; + return value; + } + line = strstr(line, "\r\n"); + } + return NULL; +} + +static int hex(int c) +{ + return c >= '0' && c <= '9' ? c - '0' + : c >= 'a' && c <= 'f' ? c - 'a' + 10 + : c >= 'A' && c <= 'F' ? c - 'A' + 10 : -1; +} + +// Turns the request path after /upload/ into a safe file name: decoded, without folders, lower +// case, and one of the kinds the importer reads. +static int clean_name(char *out, size_t size, const char *path) +{ + char decoded[UPLOAD_NAME * 3]; + const char *base, *dot; + size_t n = 0; + + while (*path && *path != '?' && n < sizeof(decoded) - 1) + { + if (*path == '%' && hex(path[1]) >= 0 && hex(path[2]) >= 0) + { + decoded[n++] = (char)(hex(path[1]) * 16 + hex(path[2])); + path += 3; + } + else + decoded[n++] = *path++; + } + decoded[n] = 0; + base = decoded + n; + while (base > decoded && base[-1] != '/' && base[-1] != '\\') + base--; + for (n = 0; base[n] && n < size - 1; n++) + { + unsigned char c = (unsigned char)base[n]; + + out[n] = isalnum(c) || strchr(" .-_()[]+", c) ? (char)tolower(c) : '_'; + } + out[n] = 0; + dot = strrchr(out, '.'); + return out[0] != '.' && dot && + (!strcmp(dot, ".wad") || !strcmp(dot, ".zip") || !strcmp(dot, ".7z") || + !strcmp(dot, ".rar")) + ? 0 + : -1; +} + +static int write_all(int fd, const uint8_t *data, size_t size) +{ + while (size) + { + ssize_t written = write(fd, data, size); + + if (written <= 0) + return -1; + data += written; + size -= (size_t)written; + } + return 0; +} + +static void set_progress(int receiving, const char *name, long long done, long long total) +{ + pthread_mutex_lock(&lock); + progress.receiving = receiving; + snprintf(progress.name, sizeof(progress.name), "%s", name); + progress.done = done; + progress.total = total; + pthread_mutex_unlock(&lock); +} + +static void put_file(int fd, const char *target, int head_length, int used) +{ + char name[UPLOAD_NAME], part[UPLOAD_PATH]; + const char *value = header_value("Content-Length"); + long long length = value ? strtoll(value, NULL, 10) : -1, done = 0; + int out, full, lost = 0, error = 0; + + if (clean_name(name, sizeof(name), target)) + { + respond_text(fd, "415 Unsupported Media Type", "Send WAD, ZIP, 7Z or RAR files."); + return; + } + if (length < 0) + { + respond_text(fd, "411 Length Required", "The upload has no length."); + return; + } + pthread_mutex_lock(&lock); + full = finished_count == MAX_FINISHED; + pthread_mutex_unlock(&lock); + if (full) + { + respond_text(fd, "503 Service Unavailable", "The console is still busy with earlier files."); + return; + } + + snprintf(part, sizeof(part), "%s/%s.upload", folder, name); + if ((out = open(part, O_WRONLY | O_CREAT | O_TRUNC, 0644)) < 0) + { + plat_log("upload: cannot write %s (error %d)\n", part, errno); + respond_text(fd, "500 Internal Server Error", "The console cannot write the file."); + return; + } + set_progress(1, name, 0, length); + plat_log("upload: receiving %s, %lld bytes\n", name, length); + + if (used > head_length) + { + long long extra = used - head_length < length ? used - head_length : length; + + if (write_all(out, (const uint8_t *)head + head_length, (size_t)extra)) + error = errno ? errno : EIO; + done = extra; + } + while (!error && !lost && done < length) + { + ssize_t got = receive(fd, chunk, length - done < CHUNK ? (size_t)(length - done) : CHUNK); + + if (got <= 0) + lost = 1; + else if (write_all(out, chunk, (size_t)got)) + error = errno ? errno : EIO; + else + { + done += got; + set_progress(1, name, done, length); + } + } + close(out); + set_progress(0, "", 0, 0); + + if (lost || error) + { + unlink(part); + plat_log("upload: %s stopped at %lld of %lld bytes (error %d)\n", name, done, length, error); + if (error == ENOSPC) + respond_text(fd, "507 Insufficient Storage", "Not enough space on the console."); + else if (error) + respond_text(fd, "500 Internal Server Error", "Writing on the console failed."); + return; + } + pthread_mutex_lock(&lock); + snprintf(finished[finished_count].path, UPLOAD_PATH, "%s", part); + snprintf(finished[finished_count].name, UPLOAD_NAME, "%s", name); + finished_count++; + pthread_mutex_unlock(&lock); + plat_log("upload: received %s\n", name); + respond_text(fd, "200 OK", "Received."); +} + +static void send_status(int fd, const char *query) +{ + static char json[MAX_NOTES * (NOTE_TEXT + 40) + 16]; + const char *after_text = strstr(query, "after="); + int after = after_text ? atoi(after_text + 6) : 0, n, i, first = 1; + + n = snprintf(json, sizeof(json), "{\"notes\":["); + pthread_mutex_lock(&lock); + for (i = 0; i < note_count; i++) + if (notes[i].id > after) + { + n += snprintf(json + n, sizeof(json) - (size_t)n, "%s{\"id\":%d,\"ok\":%s,\"text\":\"%s\"}", + first ? "" : ",", notes[i].id, notes[i].ok ? "true" : "false", notes[i].text); + first = 0; + } + pthread_mutex_unlock(&lock); + n += snprintf(json + n, sizeof(json) - (size_t)n, "]}"); + respond(fd, "200 OK", "application/json", json, (size_t)n); +} + +static void handle(int fd) +{ + char method[8], target[UPLOAD_NAME * 3 + 16]; + int used, head_length = read_head(fd, &used); + size_t i = 0, j = 0; + + if (head_length < 0) + return; + for (; head[i] && head[i] != ' ' && i < sizeof(method) - 1; i++) + method[i] = head[i]; + method[i] = 0; + while (head[i] == ' ') + i++; + while (head[i] && head[i] != ' ' && head[i] != '\r' && j < sizeof(target) - 1) + target[j++] = head[i++]; + target[j] = 0; + + if (!strcmp(method, "GET") && (!strcmp(target, "/") || !strcmp(target, "/index.html"))) + respond(fd, "200 OK", "text/html; charset=utf-8", upload_page, sizeof(upload_page)); + else if (!strcmp(method, "GET") && !strncmp(target, "/status", 7)) + send_status(fd, target + 7); + else if (!strcmp(method, "PUT") && !strncmp(target, "/upload/", 8)) + put_file(fd, target + 8, head_length, used); + else + respond_text(fd, "404 Not Found", "Not found."); +} + +static void *serve(void *arg) +{ + struct timeval receive_timeout = {RECEIVE_TIMEOUT_S, 0}, send_timeout = {SEND_TIMEOUT_S, 0}; + + (void)arg; + while (!stopping) + { + int client = accept(listener, NULL, NULL); + + if (client < 0) + { + if (!stopping) + plat_sleep_us(100000); + continue; + } + if (!stopping) + { +#ifdef SO_NOSIGPIPE + int yes = 1; + + setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &yes, sizeof(yes)); +#endif + setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &receive_timeout, sizeof(receive_timeout)); + setsockopt(client, SOL_SOCKET, SO_SNDTIMEO, &send_timeout, sizeof(send_timeout)); + handle(client); + } + close(client); + } + return NULL; +} + +// Connecting a UDP socket sends nothing; it only picks the route, and with it the local address. +static void find_address(void) +{ + struct sockaddr_in remote, local; + socklen_t size = sizeof(local); + int fd = socket(AF_INET, SOCK_DGRAM, 0); + + address[0] = 0; + if (fd < 0) + return; + memset(&remote, 0, sizeof(remote)); + remote.sin_family = AF_INET; + remote.sin_port = htons(53); + remote.sin_addr.s_addr = htonl(0x08080808); + if (!connect(fd, (struct sockaddr *)&remote, sizeof(remote)) && + !getsockname(fd, (struct sockaddr *)&local, &size)) + { + uint32_t ip = ntohl(local.sin_addr.s_addr); + + if (ip) + snprintf(address, sizeof(address), "http://%u.%u.%u.%u:%d/", ip >> 24, (ip >> 16) & 255, + (ip >> 8) & 255, ip & 255, port); + } + close(fd); +} + +typedef struct +{ + char names[MAX_LEFTOVERS][UPLOAD_NAME + 8]; + int count; +} leftovers_t; + +static void collect_leftover(const char *name, void *user) +{ + leftovers_t *leftovers = user; + size_t length = strlen(name); + + if (length > 7 && !strcmp(name + length - 7, ".upload") && leftovers->count < MAX_LEFTOVERS && + length < sizeof(leftovers->names[0])) + strcpy(leftovers->names[leftovers->count++], name); +} + +// Removes partial files a crash or a power loss left behind. +static void remove_leftovers(void) +{ + leftovers_t leftovers; + char path[UPLOAD_PATH + UPLOAD_NAME + 8]; + int i; + + leftovers.count = 0; + plat_list_dir(folder, collect_leftover, &leftovers); + for (i = 0; i < leftovers.count; i++) + { + snprintf(path, sizeof(path), "%s/%s", folder, leftovers.names[i]); + unlink(path); + } +} + +static int listen_on(int candidate) +{ + struct sockaddr_in local; + int fd = socket(AF_INET, SOCK_STREAM, 0), yes = 1; + + if (fd < 0) + return -1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)); + memset(&local, 0, sizeof(local)); + local.sin_family = AF_INET; + local.sin_port = htons((uint16_t)candidate); + local.sin_addr.s_addr = htonl(INADDR_ANY); + if (bind(fd, (struct sockaddr *)&local, sizeof(local)) || listen(fd, 4)) + { + plat_log("upload: port %d refused (error %d)\n", candidate, errno); + close(fd); + return -1; + } + return fd; +} + +int upload_start(const char *dir) +{ + if (running) + return 0; + snprintf(folder, sizeof(folder), "%s", dir); + remove_leftovers(); + note_count = 0; + finished_count = 0; + memset(&progress, 0, sizeof(progress)); + + for (port = UPLOAD_PORT; port < UPLOAD_PORT + UPLOAD_PORT_TRIES; port++) + if ((listener = listen_on(port)) >= 0) + break; + if (listener < 0) + return -1; + find_address(); + stopping = 0; + if (pthread_create(&server, NULL, serve, NULL)) + { + plat_log("upload: no server thread\n"); + close(listener); + listener = -1; + return -1; + } + running = 1; + plat_log("upload: serving %s into %s\n", address[0] ? address : "without an address", folder); + return 0; +} + +void upload_stop(void) +{ + struct sockaddr_in self; + int fd, i; + + if (!running) + return; + stopping = 1; + + // accept() does not return on its own; a connection to ourselves wakes the thread up. + if ((fd = socket(AF_INET, SOCK_STREAM, 0)) >= 0) + { + memset(&self, 0, sizeof(self)); + self.sin_family = AF_INET; + self.sin_port = htons((uint16_t)port); + self.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + connect(fd, (struct sockaddr *)&self, sizeof(self)); + close(fd); + } + pthread_join(server, NULL); + close(listener); + listener = -1; + running = 0; + + // Files that arrived but were never taken are dropped. + for (i = 0; i < finished_count; i++) + unlink(finished[i].path); + finished_count = 0; + plat_log("upload: stopped\n"); +} + +const char *upload_address(void) +{ + return address; +} + +void upload_progress(upload_progress_t *out) +{ + pthread_mutex_lock(&lock); + *out = progress; + pthread_mutex_unlock(&lock); +} + +int upload_take(char *path, int path_size, char *name, int name_size) +{ + int taken = 0; + + pthread_mutex_lock(&lock); + if (finished_count) + { + snprintf(path, (size_t)path_size, "%s", finished[0].path); + snprintf(name, (size_t)name_size, "%s", finished[0].name); + memmove(finished, finished + 1, (size_t)--finished_count * sizeof(*finished)); + taken = 1; + } + pthread_mutex_unlock(&lock); + return taken; +} + +void upload_note(const char *text, int ok) +{ + note_t *note; + int i; + + pthread_mutex_lock(&lock); + if (note_count == MAX_NOTES) + memmove(notes, notes + 1, (size_t)--note_count * sizeof(*notes)); + note = ¬es[note_count++]; + note->id = next_note++; + note->ok = ok; + // Kept safe to place inside a JSON string as it is. + for (i = 0; text[i] && i < NOTE_TEXT - 1; i++) + note->text[i] = text[i] == '"' || text[i] == '\\' ? '\'' + : (unsigned char)text[i] < ' ' ? ' ' : text[i]; + note->text[i] = 0; + pthread_mutex_unlock(&lock); +} diff --git a/src/launcher/upload.h b/src/launcher/upload.h new file mode 100644 index 0000000..b731925 --- /dev/null +++ b/src/launcher/upload.h @@ -0,0 +1,35 @@ +#ifndef UPLOAD_H +#define UPLOAD_H + +// A small HTTP server for sending game files from a browser on the same network: it serves an +// upload page, stores each file it receives in a folder as .upload and hands the finished +// ones to the caller. It runs only between upload_start and upload_stop. + +// The first port tried. A title's sandbox refuses some ports (8666 and 50000 on FW 13.00) and +// others may be taken, so the next few are tried after it. +#define UPLOAD_PORT 9666 +#define UPLOAD_PORT_TRIES 8 +#define UPLOAD_NAME 64 +#define UPLOAD_PATH 512 + +typedef struct +{ + int receiving; + char name[UPLOAD_NAME]; + long long done, total; +} upload_progress_t; + +// Starts listening; 0 on success. The address is "" when the console has no network address. +int upload_start(const char *dir); +void upload_stop(void); +const char *upload_address(void); + +void upload_progress(upload_progress_t *progress); + +// Hands over the oldest finished upload: its path and the name it was sent with. 1 when there was one. +int upload_take(char *path, int path_size, char *name, int name_size); + +// Adds a line to the log the page shows, for what the console did with a file. +void upload_note(const char *text, int ok); + +#endif diff --git a/src/launcher/upload.html b/src/launcher/upload.html new file mode 100644 index 0000000..e129879 --- /dev/null +++ b/src/launcher/upload.html @@ -0,0 +1,221 @@ + + + + + + +Send games to DOOM + + + +
+

Send games to DOOM

+

Drop the game files you own: DOOM.WAD, DOOM2.WAD, TNT.WAD, PLUTONIA.WAD or Freedoom, or ZIP, + 7Z and RAR files with them inside. Whole folders work too; other files are left out.

+
The console is not listening. On the PS5, open DOOM, then Add games, then Send + from PC or phone.
+ +
    +

    On the console

    +
    • Nothing yet.
    +
    + + + diff --git a/test/console-import.cfg b/test/console-import.cfg index 4631e73..cefd63c 100644 --- a/test/console-import.cfg +++ b/test/console-import.cfg @@ -1,4 +1,4 @@ text http://192.168.0.10:8666/ -input 30:DOWN:2;60:CROSS:2;90:CROSS:2;130:CROSS:2;160:CROSS:2;190:DOWN:2;220:CROSS:2;300:CROSS:2 +input 30:UP:2;60:CROSS:2;75:DOWN:2;90:CROSS:2;130:CROSS:2;160:CROSS:2;190:DOWN:2;220:CROSS:2;300:CROSS:2 capture 25,80,120,180,250,400,1200,2400,3600,4800,6000,7100 frames 7200 diff --git a/test/console-send.cfg b/test/console-send.cfg new file mode 100644 index 0000000..8b5b973 --- /dev/null +++ b/test/console-send.cfg @@ -0,0 +1,3 @@ +input 30:UP:2;60:CROSS:2;90:CROSS:2;3300:CIRCLE:2 +capture 150,3200,3450 +frames 3600 diff --git a/third_party/third_party.mk b/third_party/third_party.mk index 97a3232..daf8fc1 100644 --- a/third_party/third_party.mk +++ b/third_party/third_party.mk @@ -1,8 +1,9 @@ -ARCHIVE_DEPS := .deps/archive -ARCHIVE_STAMP := $(ARCHIVE_DEPS)/zlib-1.3.2/.complete -LA_DIR := $(ARCHIVE_DEPS)/libarchive-3.8.9/libarchive -XZ_DIR := $(ARCHIVE_DEPS)/xz-5.8.4/src/liblzma -ZL_DIR := $(ARCHIVE_DEPS)/zlib-1.3.2 +THIRD_DEPS := .deps/third-party +THIRD_STAMP := $(THIRD_DEPS)/QR-Code-generator-1.8.0/.complete +LA_DIR := $(THIRD_DEPS)/libarchive-3.8.9/libarchive +XZ_DIR := $(THIRD_DEPS)/xz-5.8.4/src/liblzma +ZL_DIR := $(THIRD_DEPS)/zlib-1.3.2 +QR_DIR := $(THIRD_DEPS)/QR-Code-generator-1.8.0/c LA_SRC := $(addprefix $(LA_DIR)/, archive_read.c archive_read_set_options.c archive_options.c \ archive_util.c archive_string.c archive_string_sprintf.c archive_entry.c archive_check_magic.c \ @@ -21,21 +22,22 @@ XZ_SRC := $(addprefix $(XZ_DIR)/, common/common.c common/alone_decoder.c common/ simple/simple_decoder.c simple/x86.c simple/arm.c simple/armthumb.c simple/arm64.c \ simple/ia64.c simple/powerpc.c simple/sparc.c simple/riscv.c) ZL_SRC := $(addprefix $(ZL_DIR)/, adler32.c crc32.c inffast.c inflate.c inftrees.c zutil.c) -THIRD_SRC := $(LA_SRC) $(XZ_SRC) $(ZL_SRC) +THIRD_SRC := $(LA_SRC) $(XZ_SRC) $(ZL_SRC) $(QR_DIR)/qrcodegen.c -ARCHIVE_INCLUDES := -I$(LA_DIR) +THIRD_INCLUDES := -I$(LA_DIR) -I$(QR_DIR) LA_FLAGS := -DHAVE_CONFIG_H -Ithird_party/libarchive -I$(LA_DIR) -I$(XZ_DIR)/api -I$(ZL_DIR) XZ_FLAGS := -DHAVE_CONFIG_H -Ithird_party/xz -I$(XZ_DIR)/api -I$(XZ_DIR)/common -I$(XZ_DIR)/check \ -I$(XZ_DIR)/lz -I$(XZ_DIR)/rangecoder -I$(XZ_DIR)/lzma -I$(XZ_DIR)/delta -I$(XZ_DIR)/simple \ -I$(XZ_DIR)/../common ZL_FLAGS := -I$(ZL_DIR) +QR_FLAGS := -I$(QR_DIR) THIRD_FLAGS := -std=gnu11 -O2 -w -fno-strict-aliasing -DNDEBUG -Dmalloc=import_malloc \ -Dcalloc=import_calloc -Drealloc=import_realloc -Dfree=import_free -Dstrdup=import_strdup -third_obj = $(patsubst $(ARCHIVE_DEPS)/%.c,$(1)/third_party/%.o,$(THIRD_SRC)) -third_flags = $(if $(findstring /libarchive-,$(1)),$(LA_FLAGS),$(if $(findstring /xz-,$(1)),$(XZ_FLAGS),$(ZL_FLAGS))) +third_obj = $(patsubst $(THIRD_DEPS)/%.c,$(1)/third_party/%.o,$(THIRD_SRC)) +third_flags = $(if $(findstring /libarchive-,$(1)),$(LA_FLAGS),$(if $(findstring /xz-,$(1)),$(XZ_FLAGS),$(if $(findstring /QR-Code-,$(1)),$(QR_FLAGS),$(ZL_FLAGS)))) -$(THIRD_SRC): | $(ARCHIVE_STAMP) +$(THIRD_SRC): | $(THIRD_STAMP) -$(ARCHIVE_STAMP): - bash tools/fetch-archive-libs.sh $(ARCHIVE_DEPS) +$(THIRD_STAMP): + bash tools/fetch-third-party.sh $(THIRD_DEPS) diff --git a/tools/embed-file.py b/tools/embed-file.py new file mode 100644 index 0000000..b8aa3fb --- /dev/null +++ b/tools/embed-file.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +"""Writes a C header that holds a file's bytes as a static array: embed-file.py
    .""" +import sys + +source, name, header = sys.argv[1:] +data = open(source, "rb").read() +with open(header, "w", newline="\n") as out: + out.write(f"/* Generated from {source} by tools/embed-file.py. */\n") + out.write(f"static const unsigned char {name}[] = {{\n") + for i in range(0, len(data), 20): + out.write(" " + ", ".join(str(b) for b in data[i:i + 20]) + ",\n") + out.write("};\n") diff --git a/tools/fetch-archive-libs.sh b/tools/fetch-third-party.sh similarity index 73% rename from tools/fetch-archive-libs.sh rename to tools/fetch-third-party.sh index 5ccee46..20914c2 100644 --- a/tools/fetch-archive-libs.sh +++ b/tools/fetch-third-party.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# Downloads the archive libraries the importer is built from (libarchive, xz's liblzma, zlib) and checks them. +# Downloads the libraries built into the launcher and checks them: libarchive, xz's liblzma and zlib +# for the importer, qrcodegen for the address shown on the upload screen. set -euo pipefail -out=${1:-.deps/archive} +out=${1:-.deps/third-party} mkdir -p "$out" fetch() { @@ -37,3 +38,4 @@ fetch xz-5.8.4 0014c7886930454fe8bd4228665b51af55eeae560ea135c9c4cd33f55b2591d9 fetch zlib-1.3.2 bb329a0a2cd0274d05519d61c667c062e06990d72e125ee2dfa8de64f0119d16 \ https://github.com/madler/zlib/releases/download/v1.3.2/zlib-1.3.2.tar.gz \ https://zlib.net/zlib-1.3.2.tar.gz +fetch QR-Code-generator-1.8.0 2ec0a4d33d6f521c942eeaf473d42d5fe139abcfa57d2beffe10c5cf7d34ae60 https://github.com/nayuki/QR-Code-generator/archive/refs/tags/v1.8.0.tar.gz https://codeload.github.com/nayuki/QR-Code-generator/tar.gz/refs/tags/v1.8.0 diff --git a/tools/send.py b/tools/send.py new file mode 100644 index 0000000..6dde2bc --- /dev/null +++ b/tools/send.py @@ -0,0 +1,62 @@ +"""Sends files to DOOM's Send games screen, the way its web page does, and prints what the console did. + + uv run --no-project python tools/send.py http://192.168.0.90:9666/ FILE... + +The screen must be open on the console (Add games, then Send from PC or phone); it shows the address. +Used with test/console-send.cfg for hands-free tests. +""" +import argparse +import http.client +import json +import time +import urllib.parse +from pathlib import Path + + +def request(host, port, method, path, body=None): + conn = http.client.HTTPConnection(host, port, timeout=120) + conn.request(method, path, body=body) + response = conn.getresponse() + reply = response.read() + conn.close() + return response.status, reply.decode(errors="replace") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("address", help="the address the screen shows") + ap.add_argument("files", nargs="+", type=Path) + ap.add_argument("--wait", type=int, default=60, help="seconds to wait for the screen to answer") + args = ap.parse_args() + url = urllib.parse.urlsplit(args.address) + + deadline = time.time() + args.wait + while True: + try: + request(url.hostname, url.port, "GET", "/") + break + except OSError: + if time.time() > deadline: + raise SystemExit(f"nothing answers at {args.address}") + time.sleep(1) + + for path in args.files: + data = path.read_bytes() + started = time.time() + status, reply = request(url.hostname, url.port, "PUT", "/upload/" + urllib.parse.quote(path.name), data) + seconds = max(time.time() - started, 0.001) + print(f"{path.name}: {status} {reply} ({len(data) / 1048576:.1f} MB, {len(data) / 1048576 / seconds:.1f} MB/s)") + + # The console reports what it did with each file once it has read it. + last, quiet = 0, 0 + while quiet < 5: + notes = json.loads(request(url.hostname, url.port, "GET", f"/status?after={last}")[1])["notes"] + for note in notes: + print(("done: " if note["ok"] else "failed: ") + note["text"]) + last = note["id"] + quiet = 0 if notes else quiet + 1 + time.sleep(1) + + +if __name__ == "__main__": + main()