diff --git a/Build/godot.sh b/Build/godot.sh new file mode 100644 index 0000000..34eae6e --- /dev/null +++ b/Build/godot.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +./scavgame.x86_64 & +GODOT_PID=$! + +wmctrl -r "scavgame" -b add,hidden + +wait "$GODOT_PID" diff --git a/Build/index.html b/Build/index.html new file mode 100644 index 0000000..ab91d56 --- /dev/null +++ b/Build/index.html @@ -0,0 +1,1243 @@ + + + + + + Remote Godot Game + + + + + +
Connecting...
+ + + + + + + diff --git a/Build/libwebrtc_native.linux.template_release.x86_64.so b/Build/libwebrtc_native.linux.template_release.x86_64.so new file mode 100644 index 0000000..cc754f1 Binary files /dev/null and b/Build/libwebrtc_native.linux.template_release.x86_64.so differ diff --git a/Build/scavgame.pck b/Build/scavgame.pck index f7d0128..fdef250 100644 Binary files a/Build/scavgame.pck and b/Build/scavgame.pck differ diff --git a/Build/scavgame.x86_64 b/Build/scavgame.x86_64 new file mode 100644 index 0000000..b7a063b Binary files /dev/null and b/Build/scavgame.x86_64 differ diff --git a/Build/server.py b/Build/server.py new file mode 100644 index 0000000..c408256 --- /dev/null +++ b/Build/server.py @@ -0,0 +1,906 @@ +#!/usr/bin/env python3 + +import asyncio +import json +import os +import uuid +from pathlib import Path +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer + +from websockets.asyncio.server import serve +from websockets.exceptions import ConnectionClosed + + +# ============================================================ +# Configuration +# ============================================================ + +HTTP_HOST = "0.0.0.0" +HTTP_PORT = 8080 + +WS_HOST = "0.0.0.0" +WS_PORT = 8081 + +GAME_EXECUTABLE = Path( + "/data/1/Projects/Scavenger-2027/Build/scavgame.x86_64" +) + +GAME_DIRECTORY = GAME_EXECUTABLE.parent +WEB_DIRECTORY = GAME_DIRECTORY + +MAX_SESSIONS = 4 + +DEBUG_LOGGING = True + +sessions: dict[str, "GameSession"] = {} + + +# ============================================================ +# Logging +# ============================================================ + +def log(*args): + print("[Server]", *args, flush=True) + + +def debug(*args): + if DEBUG_LOGGING: + print("[Server][DEBUG]", *args, flush=True) + + +def warning(*args): + print("[Server][WARNING]", *args, flush=True) + + +def error(*args): + print("[Server][ERROR]", *args, flush=True) + + +# ============================================================ +# Message helpers +# ============================================================ + +def normalize_sdp_message(message: dict) -> dict | None: + """ + Normalizes these formats: + + Browser: + { + "type": "offer", + "offer": { + "type": "offer", + "sdp": "..." + } + } + + Godot: + { + "type": "offer", + "sdp": "..." + } + + The same applies to answers. + """ + + message_type = message.get("type") + + if message_type not in {"offer", "answer"}: + return None + + nested_key = message_type + + nested = message.get(nested_key) + + if isinstance(nested, dict): + sdp = nested.get("sdp", "") + + if not sdp: + return None + + return { + "type": message_type, + "sdp": sdp + } + + sdp = message.get("sdp", "") + + if not sdp: + return None + + return { + "type": message_type, + "sdp": sdp + } + + +def normalize_ice_message(message: dict) -> dict | None: + """ + Normalizes both: + + { "type": "ice", "candidate": {...} } + + and: + + { "type": "candidate", "candidate": {...} } + """ + + candidate = message.get("candidate") + + if not isinstance(candidate, dict): + return None + + if not candidate.get("candidate"): + return None + + return { + "type": "ice", + "candidate": candidate + } + + +# ============================================================ +# Game session +# ============================================================ + +class GameSession: + def __init__( + self, + browser, + requested_session_id: str | None = None + ): + self.id = ( + requested_session_id + if requested_session_id + else uuid.uuid4().hex + ) + + self.browser = browser + self.godot = None + self.process = None + + self.browser_connected = True + self.godot_connected = False + self.stopping = False + + self.send_lock = asyncio.Lock() + + # -------------------------------------------------------- + # Game process + # -------------------------------------------------------- + + async def start(self): + if not GAME_EXECUTABLE.exists(): + raise FileNotFoundError( + f"Game executable not found: {GAME_EXECUTABLE}" + ) + + if not os.access(GAME_EXECUTABLE, os.X_OK): + raise PermissionError( + f"Game is not executable: {GAME_EXECUTABLE}" + ) + + env = os.environ.copy() + + env["SCAV_SESSION_ID"] = self.id + + # Keep Forward+ rendering. + env["DISPLAY"] = ":1" + + # Start minimized and avoid taking focus. + env["SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS"] = "1" + + command = [ + str(GAME_EXECUTABLE), + + "--session-id", + self.id, + + "--debug-logging", + + "--resolution", + "1x1", + ] + + debug( + f"[{self.id}] Starting game command:", + command + ) + + self.process = await asyncio.create_subprocess_exec( + *command, + + cwd=str(GAME_DIRECTORY), + env=env, + + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + + log( + f"[{self.id}] Started game " + f"PID={self.process.pid}" + ) + + asyncio.create_task( + self.read_output() + ) + + async def read_output(self): + process = self.process + + if process is None: + return + + if process.stdout is None: + return + + try: + async for line in process.stdout: + output = line.decode( + errors="replace" + ).rstrip() + + print( + f"[{self.id}] {output}", + flush=True + ) + + except asyncio.CancelledError: + raise + + except Exception as exc: + error( + f"[{self.id}] Output reader error:", + exc + ) + + async def stop(self): + if self.stopping: + return + + self.stopping = True + + process = self.process + self.process = None + + if process is None: + return + + if process.returncode is not None: + return + + log( + f"[{self.id}] Stopping game " + f"PID={process.pid}" + ) + + try: + process.terminate() + + await asyncio.wait_for( + process.wait(), + timeout=3 + ) + + log( + f"[{self.id}] Game exited " + f"code={process.returncode}" + ) + + except asyncio.TimeoutError: + warning( + f"[{self.id}] Game did not exit; " + f"killing PID={process.pid}" + ) + + try: + process.kill() + await process.wait() + except ProcessLookupError: + pass + + except ProcessLookupError: + pass + + # -------------------------------------------------------- + # WebSocket sending + # -------------------------------------------------------- + + async def send_json( + self, + websocket, + message: dict + ) -> bool: + if websocket is None: + return False + + try: + payload = json.dumps(message) + + async with self.send_lock: + await websocket.send(payload) + + debug( + f"[{self.id}] Sent:", + message + ) + + return True + + except ConnectionClosed: + return False + + except Exception as exc: + error( + f"[{self.id}] Send error:", + exc + ) + + return False + + async def send_to_godot( + self, + message: dict + ) -> bool: + if self.godot is None: + warning( + f"[{self.id}] Cannot send to Godot; " + f"Godot is not connected" + ) + + return False + + success = await self.send_json( + self.godot, + message + ) + + if not success: + self.godot = None + self.godot_connected = False + + return success + + async def send_to_browser( + self, + message: dict + ) -> bool: + if self.browser is None: + warning( + f"[{self.id}] Cannot send to browser; " + f"browser is not connected" + ) + + return False + + success = await self.send_json( + self.browser, + message + ) + + if not success: + self.browser = None + self.browser_connected = False + + return success + + +# ============================================================ +# HTTP server +# ============================================================ + +class HTTPHandler(SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__( + *args, + directory=str(WEB_DIRECTORY), + **kwargs + ) + + def log_message(self, format_string, *args): + print( + "[HTTP] " + format_string % args, + flush=True + ) + + +def start_http_server(): + server = ThreadingHTTPServer( + (HTTP_HOST, HTTP_PORT), + HTTPHandler + ) + + log( + f"HTTP server listening on " + f"http://{HTTP_HOST}:{HTTP_PORT}" + ) + + try: + server.serve_forever() + finally: + server.server_close() + + +# ============================================================ +# Browser connection +# ============================================================ + +async def handle_browser( + websocket, + requested_session_id: str | None = None +): + if len(sessions) >= MAX_SESSIONS: + await websocket.send(json.dumps({ + "type": "error", + "message": "Server is full" + })) + + await websocket.close() + return + + session = GameSession( + websocket, + requested_session_id + ) + + if session.id in sessions: + await websocket.send(json.dumps({ + "type": "error", + "message": "Session ID is already in use" + })) + + await websocket.close() + return + + sessions[session.id] = session + + log( + f"[{session.id}] Browser connected" + ) + + try: + await session.send_to_browser({ + "type": "session_created", + "session_id": session.id + }) + + await session.start() + + # Wait for Godot to connect. + for _ in range(100): + if session.godot is not None: + break + + await asyncio.sleep(0.1) + + if session.godot is None: + warning( + f"[{session.id}] Godot did not connect" + ) + + await session.send_to_browser({ + "type": "error", + "message": "Godot did not connect" + }) + + return + + await session.send_to_godot({ + "type": "browser_connected" + }) + + await session.send_to_browser({ + "type": "godot_connected" + }) + + log( + f"[{session.id}] Browser and Godot paired" + ) + + async for raw in websocket: + try: + message = json.loads(raw) + + except json.JSONDecodeError: + warning( + f"[{session.id}] Invalid browser JSON:", + raw + ) + + continue + + if not isinstance(message, dict): + warning( + f"[{session.id}] Browser message " + f"is not an object" + ) + + continue + + debug( + f"[{session.id}] Browser message:", + message + ) + + message_type = message.get("type") + + # ------------------------------------------------ + # WebRTC offer + # ------------------------------------------------ + + if message_type == "offer": + normalized = normalize_sdp_message( + message + ) + + if normalized is None: + warning( + f"[{session.id}] Invalid browser offer" + ) + + continue + + await session.send_to_godot( + normalized + ) + + # ------------------------------------------------ + # WebRTC ICE + # ------------------------------------------------ + + elif message_type in { + "ice", + "candidate" + }: + normalized = normalize_ice_message( + message + ) + + if normalized is None: + warning( + f"[{session.id}] Invalid browser ICE" + ) + + continue + + await session.send_to_godot( + normalized + ) + + else: + debug( + f"[{session.id}] Ignoring browser message:", + message_type + ) + + except ConnectionClosed: + log( + f"[{session.id}] Browser WebSocket closed" + ) + + except Exception as exc: + error( + f"[{session.id}] Browser error:", + exc + ) + + finally: + log( + f"[{session.id}] Browser disconnected" + ) + + session.browser_connected = False + + if session.godot is not None: + await session.send_to_godot({ + "type": "browser_disconnected" + }) + + await asyncio.sleep(0.2) + + try: + await session.godot.close() + except Exception: + pass + + session.godot = None + session.godot_connected = False + + await session.stop() + + sessions.pop( + session.id, + None + ) + + log( + f"[{session.id}] Session removed" + ) + + +# ============================================================ +# Godot connection +# ============================================================ + +async def handle_godot( + websocket, + session_id: str +): + session = sessions.get(session_id) + + if session is None: + warning( + f"[{session_id}] Godot attempted to connect " + f"to unknown session" + ) + + await websocket.send(json.dumps({ + "type": "error", + "message": "Unknown session" + })) + + await websocket.close() + return + + if session.godot is not None: + warning( + f"[{session.id}] Replacing existing Godot connection" + ) + + try: + await session.godot.close() + except Exception: + pass + + session.godot = websocket + session.godot_connected = True + + log( + f"[{session.id}] Godot connected" + ) + + try: + await session.send_to_godot({ + "type": "registered", + "session_id": session.id + }) + + if session.browser_connected: + await session.send_to_godot({ + "type": "browser_connected" + }) + + await session.send_to_browser({ + "type": "godot_connected" + }) + + async for raw in websocket: + try: + message = json.loads(raw) + + except json.JSONDecodeError: + warning( + f"[{session.id}] Invalid Godot JSON:", + raw + ) + + continue + + if not isinstance(message, dict): + warning( + f"[{session.id}] Godot message " + f"is not an object" + ) + + continue + + debug( + f"[{session.id}] Godot message:", + message + ) + + message_type = message.get("type") + + # ------------------------------------------------ + # WebRTC answer + # ------------------------------------------------ + + if message_type == "answer": + normalized = normalize_sdp_message( + message + ) + + if normalized is None: + warning( + f"[{session.id}] Invalid Godot answer" + ) + + continue + + await session.send_to_browser({ + "type": "answer", + "answer": { + "type": "answer", + "sdp": normalized["sdp"] + } + }) + + # ------------------------------------------------ + # WebRTC ICE + # ------------------------------------------------ + + elif message_type in { + "ice", + "candidate" + }: + normalized = normalize_ice_message( + message + ) + + if normalized is None: + warning( + f"[{session.id}] Invalid Godot ICE" + ) + + continue + + await session.send_to_browser( + normalized + ) + + else: + debug( + f"[{session.id}] Ignoring Godot message:", + message_type + ) + + except ConnectionClosed: + log( + f"[{session.id}] Godot WebSocket closed" + ) + + except Exception as exc: + error( + f"[{session.id}] Godot error:", + exc + ) + + finally: + log( + f"[{session.id}] Godot disconnected" + ) + + if session.godot is websocket: + session.godot = None + session.godot_connected = False + + if session.browser is not None: + await session.send_to_browser({ + "type": "godot_disconnected" + }) + + +# ============================================================ +# WebSocket routing +# ============================================================ + +async def websocket_handler(websocket): + try: + raw = await websocket.recv() + + message = json.loads(raw) + + if not isinstance(message, dict): + await websocket.close() + return + + role = message.get("role") + + debug( + "Initial WebSocket registration:", + message + ) + + if role == "browser": + requested_session_id = message.get( + "session_id" + ) + + await handle_browser( + websocket, + requested_session_id + ) + + elif role == "godot": + session_id = message.get( + "session_id" + ) + + if not session_id: + await websocket.send(json.dumps({ + "type": "error", + "message": "Missing session_id" + })) + + await websocket.close() + return + + await handle_godot( + websocket, + session_id + ) + + else: + await websocket.send(json.dumps({ + "type": "error", + "message": "Invalid role" + })) + + await websocket.close() + + except ConnectionClosed: + pass + + except json.JSONDecodeError: + warning( + "Received invalid initial WebSocket JSON" + ) + + try: + await websocket.close() + except Exception: + pass + + except Exception as exc: + error( + "WebSocket handler error:", + exc + ) + + +# ============================================================ +# WebSocket server +# ============================================================ + +async def websocket_server(): + log( + f"WebSocket server listening on " + f"ws://{WS_HOST}:{WS_PORT}" + ) + + async with serve( + websocket_handler, + WS_HOST, + WS_PORT + ): + await asyncio.Future() + + +# ============================================================ +# Main +# ============================================================ + +async def main(): + http_task = asyncio.to_thread( + start_http_server + ) + + websocket_task = asyncio.create_task( + websocket_server() + ) + + await asyncio.gather( + http_task, + websocket_task + ) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + + except KeyboardInterrupt: + print( + "\nServer stopped", + flush=True + )