//
// 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