foxygit / doom Log in
commits tags

/src/doom/i_webinput.c · 35.98 KB

raw
//
// Copyright(C) 2026
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// DESCRIPTION:
//   Embedded HTTP/WebSocket server letting a phone browser join a
//   local splitscreen slot and drive it over the LAN. See i_webinput.h.
//
//   Spike scope only: plain ws:// (no TLS), no auth, a fixed compact
//   binary protocol (one bitmask byte per frame - see REMOTE_BIT_* in
//   g_game.h), and a hard cap on concurrent phones (MAX_REMOTE_SLOTS).
//   Good enough to prove a phone can drive a splitscreen slot over the
//   LAN; not meant to survive the open internet.
//

#include "i_webinput.h"

#ifndef _WIN32

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <ifaddrs.h>
#include <net/if.h>
#include <signal.h>

#include "SDL.h"

#include "doomtype.h"
#include "g_game.h"
#include "doomdef.h"
#include "doomstat.h"
#include "sha1.h"
#include "m_argv.h"
#include "m_misc.h"
#include "v_video.h"
#include "qrcodegen.h"
#include "w_wad.h"
#include "z_zone.h"
#include "deh_str.h"
#include "i_joystick.h"
#include "i_timer.h"

// One tracked TCP connection per player slot. Normally only
// MAXPLAYERS-2 slots are ever up for grabs by a phone (0 is the host,
// 1 is the local WASD zone - see G_SetRemoteInputState/
// GetLocalKeybinds in g_game.c), but under -nolocal all MAXPLAYERS
// slots are - see nolocal_enabled - so size for the larger case; the
// struct is a few bytes, the extra headroom costs nothing.
#define MAX_REMOTE_SLOTS MAXPLAYERS
#define HTTP_HEADER_MAX  4096
#define WS_PAYLOAD_MAX   256
#define TOKEN_LEN        40

typedef struct
{
    boolean active;         // socket connected, thread running
    boolean pending_join;   // handshake just completed, needs a player slot
    boolean pending_leave;  // connection just dropped, needs its slot freed
    int     player_slot;    // -1 until bound by I_WebInputTic
    unsigned int input_bits;
    int     turn_axis;      // -127..127, see G_SetRemoteInputState
    char    token[TOKEN_LEN]; // from the join page's localStorage-backed
                               // per-device id (see join_page's "?token="
                               // query string) - empty if the request had
                               // none. Used to reclaim a still-in-game
                               // player after a reconnect - see
                               // slot_owner_token/slot_disconnect_time.
} remote_client_t;

static remote_client_t remote_clients[MAX_REMOTE_SLOTS];
static SDL_mutex *remote_lock = NULL;
static boolean webinput_enabled = false;

// Which device token (if any) most recently claimed each player slot,
// and (if disconnected) when - kept separate from remote_clients[]
// because it must survive the TCP connection dropping, not just live
// alongside it. Indexed by player slot, not by remote_clients[] index -
// see I_WebInputTic's join/leave handling and ReclaimTimedOutSlots.
static char slot_owner_token[MAXPLAYERS][TOKEN_LEN];
static boolean slot_owner_disconnected[MAXPLAYERS];
static int slot_disconnect_time[MAXPLAYERS];

// How long a disconnected phone's player stays in the game, in tics,
// before being removed like any other departure - long enough to
// survive a page reload or a brief WiFi hiccup (the join page also
// auto-reconnects on its own - see join_page's ws.onclose) without the
// level yanking their body out from under them, short enough that a
// phone that's genuinely gone doesn't leave a permanent idle corpse.
#define RECLAIM_TIMEOUT_TICS (30 * TICRATE)

// -qrstart: show a join QR code in place of any empty splitscreen grid
// cell (see I_WebInputDrawJoinQR, called from R_RenderSplitViews).
static boolean qrstart_enabled = false;

// -nolocal: no local (keyboard/mouse) player at all - every player,
// including the first, joins via phone. Slots 0 and 1 (normally
// reserved for the local host and WASD zone - see MAX_REMOTE_SLOTS)
// become joinable too, and qrstart_enabled is forced on: with zero
// local players there would otherwise be no way to invite anyone,
// since nobody's at the keyboard to read a join URL off the console.
static boolean nolocal_enabled = false;

static const char websocket_guid[] = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";

// The join page: two twin-stick virtual joysticks (movement left,
// turn right - see makeStick), each continuously tracked while
// dragged rather than fixed-position buttons, plus FIRE/USE. Movement
// packs its held state into a bitmask (see REMOTE_BIT_* in g_game.h);
// turning is a separate signed -127..127 axis byte for a smooth,
// proportional feel (see G_SetRemoteInputState) instead of an on/off
// snap. Both are sent over the WebSocket whenever either changes.
//
// IMPORTANT: this whole page is generated by joining adjacent C
// string literals with no separator, which strips every line break -
// harmless for HTML/CSS, but a "//" JS comment then swallows
// everything after it (no newline left to end the comment at) up to
// the next literal newline, i.e. nothing, i.e. the rest of the
// script. Never put a "//" line comment in the embedded <script>
// here - use /* */ if a comment is truly needed.
static const char join_page[] =
"<!doctype html><html><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1,user-scalable=no'>"
"<title>Doom Controller</title><style>"
"html,body{margin:0;height:100%;background:#111;overflow:hidden;"
"font-family:sans-serif;user-select:none;-webkit-user-select:none}"
"#status{position:fixed;top:4px;left:8px;color:#888;font-size:12px;z-index:10}"
"#layout{position:fixed;top:0;left:0;right:0;bottom:84px;display:flex}"
".stickzone{flex:1;min-width:0;display:flex;align-items:center;justify-content:center;touch-action:none}"
".stickbase{width:82%;max-width:260px;aspect-ratio:1;"
"border-radius:50%;background:#222;position:relative}"
".stickknob{width:42%;height:42%;border-radius:50%;background:#555;"
"position:absolute;top:29%;left:29%}"
".stickknob.on{background:#888}"
"#buttons{position:fixed;left:0;right:0;bottom:0;height:84px;"
"display:flex;gap:6px;padding:0 6px 6px;box-sizing:border-box}"
".btn{border-radius:12px;background:#333;color:#eee;display:flex;"
"align-items:center;justify-content:center;font-size:24px;touch-action:none;flex:1}"
".btn.on{background:#666}"
"#fire{background:#711}"
"#use{background:#173}"
"</style></head><body>"
"<div id='status'>connecting...</div>"
"<div id='layout'>"
"<div class='stickzone' id='movezone'>"
"<div class='stickbase'><div class='stickknob' id='moveknob'></div></div>"
"</div>"
"<div class='stickzone' id='turnzone'>"
"<div class='stickbase'><div class='stickknob' id='turnknob'></div></div>"
"</div>"
"</div>"
"<div id='buttons'>"
"<div class='btn' id='use' data-bit='128'>USE</div>"
"<div class='btn' id='fire' data-bit='64'>FIRE</div>"
"</div>"
"<script>"
"var bits=0,turnAxis=0,sentBits=-1,sentTurn=-999;"
"var st=document.getElementById('status');"
"var token=localStorage.getItem('doomToken');"
"if(!token){"
"token=Date.now().toString(36)+Math.random().toString(36).slice(2);"
"localStorage.setItem('doomToken',token);"
"}"
"var ws,reconnectTimer=null;"
"function connect(){"
"ws=new WebSocket('ws://'+location.host+'/?token='+token);"
"ws.binaryType='arraybuffer';"
"ws.onopen=function(){"
"st.textContent='connected';"
"sentBits=-1;sentTurn=-999;"
"};"
"ws.onclose=function(){"
"st.textContent='reconnecting...';"
"if(!reconnectTimer)reconnectTimer=setTimeout(function(){reconnectTimer=null;connect();},1000);"
"};"
"}"
"connect();"
"function send(){"
"if((bits!==sentBits || turnAxis!==sentTurn) && ws.readyState===1){"
"ws.send(new Uint8Array([bits, turnAxis & 0xff]));"
"sentBits=bits;sentTurn=turnAxis;"
"}"
"}"
"document.querySelectorAll('.btn').forEach(function(el){"
"var bit=parseInt(el.getAttribute('data-bit'));"
"function on(e){e.preventDefault();bits|=bit;el.classList.add('on');send();}"
"function off(e){e.preventDefault();bits&=~bit;el.classList.remove('on');send();}"
"el.addEventListener('touchstart',on);el.addEventListener('touchend',off);"
"el.addEventListener('touchcancel',off);"
"el.addEventListener('mousedown',on);el.addEventListener('mouseup',off);"
"el.addEventListener('mouseleave',off);"
"});"
"function makeStick(zoneId,knobId,onUpdate){"
"var zone=document.getElementById(zoneId);"
"var knob=document.getElementById(knobId);"
"var base=knob.parentElement;"
"var dragging=false,touchId=null,cx=0,cy=0,maxR=1;"
"function begin(x,y){"
"var r=base.getBoundingClientRect();"
"cx=r.left+r.width/2;cy=r.top+r.height/2;maxR=r.width/2;"
"dragging=true;"
"move(x,y);"
"}"
"function move(x,y){"
"if(!dragging)return;"
"var dx=x-cx,dy=y-cy;"
"var dist=Math.sqrt(dx*dx+dy*dy);"
"if(dist>maxR){dx=dx/dist*maxR;dy=dy/dist*maxR;dist=maxR;}"
"knob.style.transform='translate('+dx+'px,'+dy+'px)';"
"knob.classList.toggle('on',dist>maxR*0.3);"
"onUpdate(dx/maxR,dy/maxR);"
"}"
"function end(){"
"dragging=false;touchId=null;"
"knob.style.transform='';"
"knob.classList.remove('on');"
"onUpdate(0,0);"
"}"
"zone.addEventListener('touchstart',function(e){"
"e.preventDefault();var t=e.changedTouches[0];touchId=t.identifier;begin(t.clientX,t.clientY);"
"});"
"zone.addEventListener('touchmove',function(e){"
"e.preventDefault();"
"for(var i=0;i<e.changedTouches.length;i++){"
"var t=e.changedTouches[i];"
"if(t.identifier===touchId){move(t.clientX,t.clientY);break;}"
"}"
"});"
"function touchDone(e){"
"e.preventDefault();"
"for(var i=0;i<e.changedTouches.length;i++){"
"if(e.changedTouches[i].identifier===touchId){end();break;}"
"}"
"}"
"zone.addEventListener('touchend',touchDone);"
"zone.addEventListener('touchcancel',touchDone);"
"zone.addEventListener('mousedown',function(e){begin(e.clientX,e.clientY);});"
"window.addEventListener('mousemove',function(e){if(dragging)move(e.clientX,e.clientY);});"
"window.addEventListener('mouseup',function(){if(dragging)end();});"
"}"
"var DEAD=0.35;"
"var FORWARD=1,BACK=2,STRAFELEFT=16,STRAFERIGHT=32;"
"makeStick('movezone','moveknob',function(nx,ny){"
"bits&=~(FORWARD|BACK|STRAFELEFT|STRAFERIGHT);"
"if(ny<-DEAD)bits|=FORWARD;"
"if(ny>DEAD)bits|=BACK;"
"if(nx<-DEAD)bits|=STRAFELEFT;"
"if(nx>DEAD)bits|=STRAFERIGHT;"
"send();"
"});"
"var TURN_DEAD=0.08;"
"makeStick('turnzone','turnknob',function(nx,ny){"
"var v=nx;"
"if(Math.abs(v)<TURN_DEAD)v=0;"
"else v=(v-(v>0?1:-1)*TURN_DEAD)/(1-TURN_DEAD);"
"turnAxis=Math.max(-127,Math.min(127,Math.round(v*127)));"
"send();"
"});"
"setInterval(function(){if(ws.readyState===1)send();},1000);"
"</script></body></html>";

static void Base64Encode(const byte *data, int len, char *out)
{
    static const char tbl[] =
        "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    int i, o = 0;

    for (i = 0; i < len; i += 3)
    {
        unsigned int v = data[i] << 16;
        if (i + 1 < len) v |= data[i + 1] << 8;
        if (i + 2 < len) v |= data[i + 2];

        out[o++] = tbl[(v >> 18) & 0x3f];
        out[o++] = tbl[(v >> 12) & 0x3f];
        out[o++] = (i + 1 < len) ? tbl[(v >> 6) & 0x3f] : '=';
        out[o++] = (i + 2 < len) ? tbl[v & 0x3f] : '=';
    }
    out[o] = '\0';
}

// Case-insensitive substring search (avoids relying on the
// platform-specific availability of strcasestr).
static const char *FindHeaderCI(const char *haystack, const char *needle)
{
    size_t hlen = strlen(haystack), nlen = strlen(needle);
    size_t i;

    if (nlen == 0 || nlen > hlen)
        return NULL;

    for (i = 0; i + nlen <= hlen; i++)
    {
        if (strncasecmp(haystack + i, needle, nlen) == 0)
            return haystack + i;
    }
    return NULL;
}

// Extracts the "token" query-string value from a request line like
// "GET /?token=abc123 HTTP/1.1\r\n..." into out (empty string if the
// request had none). The join page's token is always plain alphanumeric
// (see its Date.now()/Math.random().toString(36) generator in
// join_page), so no URL-decoding is needed.
static void ExtractToken(const char *request, char *out, size_t outsize)
{
    const char *p = FindHeaderCI(request, "token=");
    const char *line_end;
    size_t i;

    out[0] = '\0';

    if (p == NULL)
        return;

    // Only within the request line itself (up to its \r), not into
    // headers below it.
    line_end = strchr(request, '\r');
    if (line_end == NULL || p >= line_end)
        return;

    p += strlen("token=");

    for (i = 0; i < outsize - 1 && p[i] != '\0' && p[i] != '&'
             && p[i] != ' ' && p[i] != '\r' && p[i] != '\n'; i++)
    {
        out[i] = p[i];
    }
    out[i] = '\0';
}

static boolean RecvExact(int fd, void *buf, int len)
{
    int got = 0;
    while (got < len)
    {
        int n = recv(fd, (char *) buf + got, len - got, 0);
        if (n <= 0)
            return false;
        got += n;
    }
    return true;
}

static boolean SendAll(int fd, const void *buf, int len)
{
    int sent = 0;
    while (sent < len)
    {
        int n = send(fd, (const char *) buf + sent, len - sent, 0);
        if (n <= 0)
            return false;
        sent += n;
    }
    return true;
}

// Reads a raw HTTP request one byte at a time up to the blank line that
// ends its headers, so we never overread into the WebSocket frame
// stream that immediately follows on the same connection.
static int ReadHttpRequest(int fd, char *buf, int bufsize)
{
    int len = 0;

    while (len < bufsize - 1)
    {
        char c;
        if (recv(fd, &c, 1, 0) <= 0)
            return -1;

        buf[len++] = c;
        buf[len] = '\0';

        if (len >= 4 && strcmp(buf + len - 4, "\r\n\r\n") == 0)
            return len;
    }
    return -1;
}

static void ServeJoinPage(int fd)
{
    char header[256];
    int hlen = snprintf(header, sizeof(header),
                         "HTTP/1.1 200 OK\r\n"
                         "Content-Type: text/html; charset=utf-8\r\n"
                         "Content-Length: %d\r\n"
                         "Connection: close\r\n\r\n",
                         (int) strlen(join_page));

    SendAll(fd, header, hlen);
    SendAll(fd, join_page, (int) strlen(join_page));
}

static boolean DoHandshake(int fd, const char *request)
{
    const char *key_hdr = FindHeaderCI(request, "Sec-WebSocket-Key:");
    char key[128], accept_src[256], accept_b64[64], response[512];
    sha1_context_t ctx;
    sha1_digest_t digest;
    int i, rlen;

    if (key_hdr == NULL)
        return false;

    key_hdr += strlen("Sec-WebSocket-Key:");
    while (*key_hdr == ' ')
        key_hdr++;

    for (i = 0; i < (int) sizeof(key) - 1 && key_hdr[i] != '\r'
             && key_hdr[i] != '\n' && key_hdr[i] != '\0'; i++)
    {
        key[i] = key_hdr[i];
    }
    key[i] = '\0';

    snprintf(accept_src, sizeof(accept_src), "%s%s", key, websocket_guid);

    SHA1_Init(&ctx);
    SHA1_Update(&ctx, (byte *) accept_src, strlen(accept_src));
    SHA1_Final(digest, &ctx);

    Base64Encode(digest, sizeof(sha1_digest_t), accept_b64);

    rlen = snprintf(response, sizeof(response),
                     "HTTP/1.1 101 Switching Protocols\r\n"
                     "Upgrade: websocket\r\n"
                     "Connection: Upgrade\r\n"
                     "Sec-WebSocket-Accept: %s\r\n\r\n",
                     accept_b64);

    return SendAll(fd, response, rlen);
}

// Reads and decodes one client->server WebSocket frame. Returns false
// on close/error (caller should drop the connection). On true, sets
// *bits_updated if this was a data frame carrying a payload (in which
// case *out_bits holds byte 0, the held-button bitmask, and *out_turn
// holds byte 1 - a signed -127..127 turn-stick deflection, see
// G_SetRemoteInputState - if the frame was at least 2 bytes; otherwise
// *out_turn is left untouched) - ping/pong/other frames return true
// without touching either, so callers don't mistake "no news" for "all
// buttons released and stick centered".
static boolean ReadWsFrame(int fd, unsigned int *out_bits, int *out_turn, boolean *bits_updated)
{
    byte hdr[2];
    byte maskkey[4];
    byte payload[WS_PAYLOAD_MAX];
    int opcode, masked;
    uint64_t paylen;

    if (!RecvExact(fd, hdr, 2))
        return false;

    opcode = hdr[0] & 0x0f;
    masked = hdr[1] & 0x80;
    paylen = hdr[1] & 0x7f;

    if (paylen == 126)
    {
        byte ext[2];
        if (!RecvExact(fd, ext, 2))
            return false;
        paylen = (ext[0] << 8) | ext[1];
    }
    else if (paylen == 127)
    {
        byte ext[8];
        int i;
        if (!RecvExact(fd, ext, 8))
            return false;
        paylen = 0;
        for (i = 0; i < 8; i++)
            paylen = (paylen << 8) | ext[i];
    }

    if (masked && !RecvExact(fd, maskkey, 4))
        return false;

    if (paylen > WS_PAYLOAD_MAX)
        return false; // spike protocol never sends more than 1 byte

    if (paylen > 0 && !RecvExact(fd, payload, (int) paylen))
        return false;

    if (masked)
    {
        uint64_t i;
        for (i = 0; i < paylen; i++)
            payload[i] ^= maskkey[i % 4];
    }

    if (opcode == 0x8) // close
        return false;

    if ((opcode == 0x1 || opcode == 0x2) && paylen >= 1)
    {
        *out_bits = payload[0];
        if (paylen >= 2)
            *out_turn = (int) (int8_t) payload[1];
        *bits_updated = true;
    }

    return true;
}

typedef struct
{
    int fd;
    int slot_index;
} client_thread_args_t;

static int ClientThreadFunc(void *data)
{
    client_thread_args_t *args = (client_thread_args_t *) data;
    int fd = args->fd;
    int idx = args->slot_index;
    char request[HTTP_HEADER_MAX];
    int reqlen;

    free(args);

    reqlen = ReadHttpRequest(fd, request, sizeof(request));
    if (reqlen < 0)
    {
        close(fd);
        SDL_LockMutex(remote_lock);
        remote_clients[idx].active = false;
        SDL_UnlockMutex(remote_lock);
        return 0;
    }

    if (FindHeaderCI(request, "Upgrade: websocket") == NULL
     || !DoHandshake(fd, request))
    {
        // A plain page load (or anything else that isn't a WebSocket
        // upgrade) never becomes a tracked player connection - free
        // this remote_clients[] slot back up immediately instead of
        // leaving it permanently marked active (see ListenerThreadFunc,
        // which only ever hands out slots where !active).
        ServeJoinPage(fd);
        close(fd);
        SDL_LockMutex(remote_lock);
        remote_clients[idx].active = false;
        SDL_UnlockMutex(remote_lock);
        return 0;
    }

    SDL_LockMutex(remote_lock);
    ExtractToken(request, remote_clients[idx].token, sizeof(remote_clients[idx].token));
    remote_clients[idx].pending_join = true;
    SDL_UnlockMutex(remote_lock);

    printf("[webinput] phone connected (slot %d), awaiting player assignment\n", idx);

    for (;;)
    {
        unsigned int bits = 0;
        int turn;
        boolean bits_updated = false;

        SDL_LockMutex(remote_lock);
        turn = remote_clients[idx].turn_axis;
        SDL_UnlockMutex(remote_lock);

        if (!ReadWsFrame(fd, &bits, &turn, &bits_updated))
            break;

        if (bits_updated)
        {
            SDL_LockMutex(remote_lock);
            remote_clients[idx].input_bits = bits;
            remote_clients[idx].turn_axis = turn;
            SDL_UnlockMutex(remote_lock);
        }
    }

    close(fd);

    SDL_LockMutex(remote_lock);
    remote_clients[idx].pending_leave = true;
    remote_clients[idx].input_bits = 0;
    remote_clients[idx].turn_axis = 0;
    SDL_UnlockMutex(remote_lock);

    printf("[webinput] phone disconnected (slot %d)\n", idx);

    return 0;
}

// The join URL the QR cell encodes (see I_WebInputDrawJoinQR). Cached
// once at startup from whichever interface PickBestLanAddress prefers -
// good enough for the common single-NIC-plus-maybe-a-VPN home network
// this was tested on; not a guarantee of picking the "right" interface
// on more exotic setups.
static char cached_join_url[64];

// Home-network LAN ranges scored above anything else (VPN/virtual
// adapters tend to hand out other ranges) - see PrintJoinURL.
static int ScoreLanAddress(const char *ipstr)
{
    if (strncmp(ipstr, "192.168.", 8) == 0) return 3;
    if (strncmp(ipstr, "10.", 3) == 0) return 2;
    if (strncmp(ipstr, "172.", 4) == 0) return 1;
    return 0;
}

static void PrintJoinURL(int port)
{
    struct ifaddrs *ifap, *ifa;
    boolean found = false;
    int best_score = -1;

    if (getifaddrs(&ifap) != 0)
        return;

    for (ifa = ifap; ifa != NULL; ifa = ifa->ifa_next)
    {
        struct sockaddr_in *sin;
        char ipstr[INET_ADDRSTRLEN];
        int score;

        if (ifa->ifa_addr == NULL || ifa->ifa_addr->sa_family != AF_INET)
            continue;
        if (ifa->ifa_flags & IFF_LOOPBACK)
            continue;

        sin = (struct sockaddr_in *) ifa->ifa_addr;
        inet_ntop(AF_INET, &sin->sin_addr, ipstr, sizeof(ipstr));

        printf("[webinput] join from a phone on this network at: http://%s:%d/\n",
               ipstr, port);
        found = true;

        score = ScoreLanAddress(ipstr);
        if (score > best_score)
        {
            best_score = score;
            snprintf(cached_join_url, sizeof(cached_join_url),
                     "http://%s:%d/", ipstr, port);
        }
    }

    freeifaddrs(ifap);

    if (!found)
    {
        printf("[webinput] listening on port %d (no LAN IP found to print - "
               "check your network connection)\n", port);
    }
}

// I_WebInputQrEnabled
// Whether R_RenderSplitViews should draw a join QR into any empty
// splitscreen grid cell (see I_WebInputDrawJoinQR). False if -qrstart
// wasn't passed, or if no LAN address was found to encode.
boolean I_WebInputQrEnabled(void)
{
    return qrstart_enabled && cached_join_url[0] != '\0';
}

// I_WebInputNoLocalEnabled
// Whether -nolocal was passed - see D_Display (d_main.c), which uses
// this to show a fullscreen join QR instead of trying to render a
// nonexistent player's view while nobody has joined yet.
boolean I_WebInputNoLocalEnabled(void)
{
    return nolocal_enabled;
}

// Lazily-encoded, cached module matrix for cached_join_url - encoding
// (Reed-Solomon + mask search) only needs to happen once, since the
// join URL never changes after startup.
static uint8_t qr_tempbuf[qrcodegen_BUFFER_LEN_MAX];
static uint8_t qr_matrix[qrcodegen_BUFFER_LEN_MAX];
static boolean qr_matrix_ready = false;

// Palette index closest to the most saturated red in the loaded
// PLAYPAL, used as the dark-module color when drawing the join QR
// (background is black, palette index 0 - already used elsewhere in
// this codebase e.g. R_RenderSplitViews's screen clear - colors
// reversed from the usual black-on-white: see I_WebInputDrawJoinQR).
static int FindRedPaletteIndex(void)
{
    byte *playpal = W_CacheLumpName(DEH_String("PLAYPAL"), PU_CACHE);
    int i, best = 0, best_score = -1;

    for (i = 0; i < 256; i++)
    {
        int r = playpal[i * 3];
        int g = playpal[i * 3 + 1];
        int b = playpal[i * 3 + 2];
        int score = 2 * r - g - b;

        if (score > best_score)
        {
            best_score = score;
            best = i;
        }
    }

    return best;
}

// I_WebInputDrawJoinQR
// Draws a join QR code, fit and centered, into the screen-buffer
// rectangle (x, y, w, h). No-op if there's nothing to encode. Doesn't
// gate on I_WebInputQrEnabled (the -qrstart flag) itself - that only
// controls whether R_RenderSplitViews/the plain -nolocal wait screen
// draw one automatically; both already check it themselves before
// calling this, and other callers (e.g. the menu-triggered "Multiplayer"
// lobby - see d_main.c) legitimately want a QR without -qrstart.
void I_WebInputDrawJoinQR(int x, int y, int w, int h)
{
    int size, quiet, scale, total, px_size, ox, oy;
    int mx, my;
    static int red_index = -1;

    if (cached_join_url[0] == '\0')
        return;

    if (!qr_matrix_ready)
    {
        if (!qrcodegen_encodeText(cached_join_url, qr_tempbuf, qr_matrix,
                                   qrcodegen_Ecc_LOW,
                                   qrcodegen_VERSION_MIN, qrcodegen_VERSION_MAX,
                                   qrcodegen_Mask_AUTO, true))
        {
            return;
        }
        qr_matrix_ready = true;
    }

    if (red_index < 0)
        red_index = FindRedPaletteIndex();

    size = qrcodegen_getSize(qr_matrix);
    quiet = 4; // standard recommended quiet zone, in modules
    total = size + 2 * quiet;

    scale = (w < h ? w : h) / total;
    if (scale < 1)
        scale = 1;

    px_size = total * scale;
    ox = x + (w - px_size) / 2;
    oy = y + (h - px_size) / 2;

    // Colors reversed from the usual QR convention: black background
    // (light modules - palette index 0, matching R_RenderSplitViews's
    // own screen clear elsewhere), red dark modules instead of black.
    V_DrawFilledBox(ox, oy, px_size, px_size, 0);

    for (my = 0; my < size; my++)
    {
        for (mx = 0; mx < size; mx++)
        {
            if (qrcodegen_getModule(qr_matrix, mx, my))
            {
                V_DrawFilledBox(ox + (quiet + mx) * scale,
                                 oy + (quiet + my) * scale,
                                 scale, scale, red_index);
            }
        }
    }
}

static int ListenerThreadFunc(void *data)
{
    int port = (int)(intptr_t) data;
    int listen_fd;
    struct sockaddr_in addr;
    int opt = 1;

    listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0)
    {
        printf("[webinput] socket() failed, phone join disabled\n");
        return 0;
    }

    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(port);

    if (bind(listen_fd, (struct sockaddr *) &addr, sizeof(addr)) < 0)
    {
        printf("[webinput] bind() on port %d failed, phone join disabled\n", port);
        close(listen_fd);
        return 0;
    }

    if (listen(listen_fd, 8) < 0)
    {
        printf("[webinput] listen() failed, phone join disabled\n");
        close(listen_fd);
        return 0;
    }

    PrintJoinURL(port);

    for (;;)
    {
        struct sockaddr_in client_addr;
        socklen_t addrlen = sizeof(client_addr);
        int client_fd = accept(listen_fd, (struct sockaddr *) &client_addr, &addrlen);
        int idx, opt2 = 1;

        if (client_fd < 0)
            continue;

        setsockopt(client_fd, IPPROTO_TCP, TCP_NODELAY, &opt2, sizeof(opt2));

        SDL_LockMutex(remote_lock);
        for (idx = 0; idx < MAX_REMOTE_SLOTS; idx++)
        {
            if (!remote_clients[idx].active)
            {
                remote_clients[idx].active = true;
                remote_clients[idx].player_slot = -1;
                remote_clients[idx].input_bits = 0;
                remote_clients[idx].turn_axis = 0;
                break;
            }
        }
        SDL_UnlockMutex(remote_lock);

        if (idx == MAX_REMOTE_SLOTS)
        {
            close(client_fd); // full up, spike cap reached
            continue;
        }

        {
            client_thread_args_t *args = malloc(sizeof(client_thread_args_t));
            SDL_Thread *t;
            args->fd = client_fd;
            args->slot_index = idx;
            t = SDL_CreateThread(ClientThreadFunc, "webinput-client", args);
            if (t != NULL)
                SDL_DetachThread(t);
        }
    }

    return 0;
}

void I_WebInputInit(int port)
{
    int i;

    // A phone can drop its TCP connection at any moment; without this,
    // the next send() to that socket raises SIGPIPE, whose default
    // disposition kills the whole process (not just the failing
    // connection thread). recv()/send() already check for and handle
    // the resulting EPIPE return individually.
    signal(SIGPIPE, SIG_IGN);

    //!
    // @category net
    //
    // Show a join QR code in place of any empty splitscreen grid cell
    // (see I_WebInputDrawJoinQR), so bystanders can scan to join
    // instead of needing to be told the join URL out of band.
    //

    qrstart_enabled = M_ParmExists("-qrstart");

    //!
    // @category net
    //
    // No local (keyboard/mouse) player - everybody, including the
    // first player, joins via phone. Implies -qrstart, since otherwise
    // there would be no local player to read a join URL off the
    // console for. See D_CheckNetGame (d_net.c) for where this also
    // keeps slot 0 from auto-joining at startup.
    //

    nolocal_enabled = M_ParmExists("-nolocal");
    if (nolocal_enabled)
        qrstart_enabled = true;

    for (i = 0; i < MAX_REMOTE_SLOTS; i++)
    {
        remote_clients[i].active = false;
        remote_clients[i].pending_join = false;
        remote_clients[i].pending_leave = false;
        remote_clients[i].player_slot = -1;
        remote_clients[i].input_bits = 0;
        remote_clients[i].turn_axis = 0;
        remote_clients[i].token[0] = '\0';
    }

    for (i = 0; i < MAXPLAYERS; i++)
    {
        slot_owner_token[i][0] = '\0';
        slot_owner_disconnected[i] = false;
        slot_disconnect_time[i] = 0;
    }

    // Turning for a phone-driven slot goes through joyxmove[] (see
    // G_SetRemoteInputState) to get the same cubic response curve a
    // real analog joystick's x-axis gets in G_BuildTiccmd, rather than
    // an on/off snap-to-full-speed turn. That branch is gated on
    // use_analog, which is otherwise a user preference for their own
    // hardware (default off) - force it on globally here since it only
    // affects players whose turning comes through joyxmove/joyymove at
    // all (a keyboard-only local player's are always 0, so this is a
    // no-op for them).
    use_analog = 1;

    remote_lock = SDL_CreateMutex();
    if (remote_lock == NULL)
        return;

    if (SDL_CreateThread(ListenerThreadFunc, "webinput-listener",
                          (void *)(intptr_t) port) == NULL)
    {
        printf("[webinput] failed to start listener thread, phone join disabled\n");
        return;
    }

    webinput_enabled = true;
}

// Removes any player whose owning phone disconnected more than
// RECLAIM_TIMEOUT_TICS ago without reconnecting (see the do_leave/
// do_join handling in I_WebInputTic) - called every tic, outside
// remote_lock since G_RemoveLocalPlayer runs game logic, not
// networking state.
static void ReclaimTimedOutSlots(void)
{
    int slot;

    for (slot = 0; slot < MAXPLAYERS; slot++)
    {
        if (!slot_owner_disconnected[slot])
            continue;

        if (!playeringame[slot])
        {
            // Already gone by some other path (e.g. the debug leave
            // key, or a fresh game) - nothing left to reclaim.
            slot_owner_disconnected[slot] = false;
            slot_owner_token[slot][0] = '\0';
            continue;
        }

        if (I_GetTime() - slot_disconnect_time[slot] < RECLAIM_TIMEOUT_TICS)
            continue;

        G_RemoveLocalPlayer(slot);
        slot_owner_disconnected[slot] = false;
        slot_owner_token[slot][0] = '\0';
        printf("[webinput] player slot %d timed out waiting to reconnect, removing\n", slot);
    }
}

void I_WebInputTic(void)
{
    int i;

    if (!webinput_enabled)
        return;

    SDL_LockMutex(remote_lock);

    for (i = 0; i < MAX_REMOTE_SLOTS; i++)
    {
        remote_client_t *rc = &remote_clients[i];
        boolean do_join = rc->pending_join;
        boolean do_leave = rc->pending_leave;
        unsigned int bits = rc->input_bits;
        int turn = rc->turn_axis;

        rc->pending_join = false;
        rc->pending_leave = false;

        if (do_leave)
        {
            // Don't remove the player from the game on a mere dropped
            // connection - a page reload or brief WiFi hiccup shouldn't
            // yank someone's body out of the level. Just free up this
            // connection slot and start the reclaim clock; the same
            // device reconnecting with the same token (see do_join
            // below) picks the player back up exactly where they were.
            // ReclaimTimedOutSlots (called below, outside the lock)
            // handles actually removing them if nobody reclaims in
            // time.
            if (rc->player_slot >= 0)
            {
                slot_owner_disconnected[rc->player_slot] = true;
                slot_disconnect_time[rc->player_slot] = I_GetTime();
            }
            rc->player_slot = -1;
            rc->active = false;
            continue;
        }

        if (do_join && rc->player_slot < 0)
        {
            int slot;
            int reclaim_slot = -1;

            // Same device (matching token) reconnecting to a player
            // that's still in the game (nobody else has since claimed
            // that connection slot, and the timeout sweep hasn't
            // removed them) - resume controlling them in place, no
            // despawn/respawn.
            if (rc->token[0] != '\0')
            {
                for (slot = 0; slot < MAXPLAYERS; slot++)
                {
                    int j;
                    boolean claimed_elsewhere = false;

                    if (!playeringame[slot] || !slot_owner_disconnected[slot])
                        continue;
                    if (strncmp(slot_owner_token[slot], rc->token, TOKEN_LEN) != 0)
                        continue;

                    for (j = 0; j < MAX_REMOTE_SLOTS; j++)
                    {
                        if (j != i && remote_clients[j].active
                         && remote_clients[j].player_slot == slot)
                        {
                            claimed_elsewhere = true;
                            break;
                        }
                    }

                    if (!claimed_elsewhere)
                    {
                        reclaim_slot = slot;
                        break;
                    }
                }
            }

            if (reclaim_slot >= 0)
            {
                rc->player_slot = reclaim_slot;
                slot_owner_disconnected[reclaim_slot] = false;
                printf("[webinput] phone reclaimed player slot %d\n", reclaim_slot);
            }
            else
            {
                // Slots 0/1 are normally reserved for a local host/WASD
                // zone (see MAX_REMOTE_SLOTS); under -nolocal, or while
                // still in the menu-triggered multiplayer lobby (see
                // lobby_active, g_game.c), neither is guaranteed to
                // exist - the host there is just as optional as any
                // other player (see G_Responder's key_use handling) -
                // so there is nothing to reserve them for and the
                // first phone can take slot 0 itself.
                int start_slot = (nolocal_enabled || lobby_active) ? 0 : 2;
                SDL_UnlockMutex(remote_lock);
                for (slot = start_slot; slot < MAXPLAYERS; slot++)
                {
                    if (G_AddLocalPlayer(slot))
                        break;
                }
                SDL_LockMutex(remote_lock);

                if (slot < MAXPLAYERS)
                {
                    rc->player_slot = slot;
                    M_StringCopy(slot_owner_token[slot], rc->token, TOKEN_LEN);
                    slot_owner_disconnected[slot] = false;
                    printf("[webinput] phone bound to player slot %d\n", slot);
                }
            }
        }

        if (rc->player_slot >= 0)
        {
            SDL_UnlockMutex(remote_lock);
            G_SetRemoteInputState(rc->player_slot, bits, turn);
            SDL_LockMutex(remote_lock);
        }
    }

    SDL_UnlockMutex(remote_lock);

    ReclaimTimedOutSlots();
}

#else // _WIN32

void I_WebInputInit(int port)
{
    // Not implemented on Windows yet - native POSIX sockets only for
    // this spike (see i_webinput.c).
}

void I_WebInputTic(void)
{
}

boolean I_WebInputQrEnabled(void)
{
    return false;
}

boolean I_WebInputNoLocalEnabled(void)
{
    return false;
}

void I_WebInputDrawJoinQR(int x, int y, int w, int h)
{
}

#endif