#!/usr/bin/env python3 """ gu3server - a reimplementation of the Foglo "Gu3" backend protocol used by Snipers vs Thieves (se.foglo.svt), reconstructed from the shipped client. Protocol (recovered from Gu3.ServerMessenger / Gu3.Dataset in libil2cpp.so): transport : JSON text frames over a WebSocket request : {"id":, "index":, "noreply":, "payload":{...}} reply : {"id":, "index":, "timestamp":, "timestampOffset":, "error":, "payload":{...}} framework commands gu3session handshake; client sends device/build identity gu3new create a new user -> uid/token gu3sync pull the player's dataset (sections of key/value JSON) gu3close detach current identity gu3ping heartbeat -> server pushes gu3pong gu3log client log upload _custom wrapper carrying a game command (see COMMANDS below) server push (unsolicited, no matching index) gu3pong, maintenance, tuning, gu3sync The client computes MD5(message + "REQSALT!") but stores it on its own Message object and never puts it in the envelope, so nothing here validates it. """ import asyncio import json import time import uuid import argparse import logging import os from pathlib import Path try: import websockets except ImportError: raise SystemExit("pip install websockets (or run via ./.venv/bin/python)") LOG = logging.getLogger("gu3") STATE_DIR = Path(__file__).with_name("gu3state") STATE_DIR.mkdir(exist_ok=True) SERVER_VERSION = "1.20.22969" SERVER_BUILD = "22969" # -------------------------------------------------------------------------- # player store # -------------------------------------------------------------------------- # Owned starter commodities (product ids from the client's embedded catalog). # Start with just the equipped mask to prove the LoadInventoryItem path; expand # to bag/rifle/gadgets once confirmed. STARTER_LOADOUT = ["mask_1", "bg_0", "r_1", "tg_1", "sg_1"] def _ordered_sections(sections: dict) -> dict: """Emit inventory item sections ("gadget_N") BEFORE "profile". The client processes top-level sync keys in order, firing OnSectionChange per section. The "profile" section triggers LinkInventory, which equips the rifle/bag by looking them up in the typed owned-item lists. Those lists are only populated when the "gadget_N" item sections are processed (each builds an OwnedRifle/OwnedBag). If "profile" is seen first, LinkInventory runs against empty lists and SetEquippedRifle -> UpdateRifle NREs. Ordering the inventory ahead of the profile guarantees ownership before the equip.""" gadgets = {k: v for k, v in sections.items() if k.startswith("gadget")} rest = {k: v for k, v in sections.items() if not k.startswith("gadget")} return {**gadgets, **rest} def _default_profile(uid: str) -> dict: """The 'sections' the client expects from a sync. Section names are taken from Dataset.GetSection call sites in the client.""" now = int(time.time()) # Schema recovered from the client itself: section names come from # PlayerProfile::OnSectionChange, and each section's keys from the # corresponding PlayerProfile::Load* method's string literals. return { # PlayerProfile::LoadProfile reads all of these from the "profile" # section; missing ones NRE inside FE.EnterVault.OnPlayerProfileChanged. "profile": { "nick": "Thief", "nickChosen": 1, # 0 would divert to the pick-a-name flow "enabled": 1, # 0 sends the client to user_disabled "optOut": 0, "termsVer": 1, "vipLevel": 0, "vipSuit": 0, "title": "", "generalChatChannel": 0, "generalChatMute": 0, "faqViews": 0, "wallResistance": 0, "xp": 0, "customers": 0, "gold": 100, "cash": 5000, "lifetimeCash": 5000, "lifetimeGold": 100, "lastRank": 0, "lastLevel": 1, "currentSeason": 0, "seasonElite": 0, "curEvt": "", "iaps": 0, "avatar": "ava_1", # equipped ids; must also be OWNED (gadget_N "mask": "mask_1", # inventory sections below) for LinkInventory "currClanEff": 0, # to link them without NRE "currClanTopPrize": 0, "currClanParts": 0, # Equipped-loadout pointers are read by PlayerProfile.LoadProfile # via DataSection.Get(id, int* value, int def) -> they are INTEGER # INSTANCE-IDS (the "N" leaf of the owning "gadget_N" section), NOT # productId strings. LinkInventory does inventory.TryGetValue(index). # bg_0 lives in gadget_1, r_1 lives in gadget_2 (see STARTER_LOADOUT # order below), so bag=1, rifle=2. Sending "bg_0"/"r_1" here made the # int Get fall back to its default and mis-point the equip. "thief_bag": "1", "sniper_rifle": "2", "lastBonusCaseTs": 0, "rankingBlacklistUntil": 0, "lastMarketRefresh": 0, "lastMarketPrompt": 0, "lastMarketReport": 0, "soloProgress": 0, "mainVideoAdTs": 0, "mainVideoAdRerollTs": 0, "mainVideoAdID": "", "mainVideoAdType": "", "mainVideoPID": "", "mainVideoAdQuantity": 0, "thiefRounds": 0, # PlayerProfile::Check25RoundsPlayed "sniperRounds": 0, }, # PlayerProfile::LoadSettings "settings": { "masterVolume": 1, "musicVolume": 1, "sfxVolume": 1, "seenTou": 1, "disableVibration": 0, }, # PlayerProfile::LoadDevSettings "devSettings": { "always_tutorial": 0, "disableSniperBot": 0, "botDefsAsTitles": 0, "noTimeLimit": 0, "newSpeedCalc": 0, "showDebugUI": 0, "disableAutoSignIn": 0, "forceDailyRewardUI": 0, }, # PlayerProfile::LoadClearedTutorials "tutorials": {"clearedB": 2147483647}, # Gu3.TuningHelper::GetPublishTimestamp / GetHash "tuning": {"ts": now, "hash": "0"}, # inventory: the client encodes hierarchy in the section NAME via '_' # (Gu3.Dataset::CreateSection splits on the first underscore: prefix = # parent, suffix = leaf/instance id). An owned commodity is therefore a # TOP-LEVEL section named "gadget_" whose parent resolves to # "gadget", which PlayerProfile::OnSectionChange routes to # LoadInventoryItem. Value keys: productId (commodity id, REQUIRED) + # amount. The leaf int is Int32.Parse'd as the instance id. **{f"gadget_{i}": {"productId": pid, "amount": 1} for i, pid in enumerate(STARTER_LOADOUT)}, # PlayerProfile::LoadProgressionSafe "progressionSafe": {"goalType": 0, "points": 0, "lastOpenedTs": 0}, "dailyrewards": {"opened": 0, "ts": now, "dayIndex": 0}, # PlayerProfile::LoadFriendsSection / LoadUserReviewSection. # DataSection.Get(id, List) reads these as lists -> must be arrays, not # empty strings, or it NREs. "friends": {"list": [], "sent": [], "requests": []}, "reviews": {"requests": []}, # PlayerProfile.Start waits for these four sections before it sets # `initialized` (the PC=18 gate that lets the title exit to the vault): # user data, clan data, profile, clan. Provide all four, non-empty # (empty {} sections crash Gu3.Parse). "user data": {"uid": uid, "created": now, "name": "Thief"}, "clan": {"id": ""}, # no clan -> empty id "clan data": {"id": "", "numMembers": 0}, "session": {"ts": now}, } # Set MINIMAL=1 to send the smallest plausible dataset, for bisecting which # part of the payload Gu3.Parse rejects. Oracle: does the client write # svt:shared:uid / :token into shared_prefs/profile.xml ? MINIMAL = os.environ.get("GU3_MINIMAL") == "1" # PlayerProfile subscribes to Dataset.onSectionChange only when FE.Core awakes # (~8.6s). Hold the dataset reply until after that or the section-change # events fire with nothing listening. SYNC_DELAY = float(os.environ.get("GU3_SYNC_DELAY", "12")) def _minimal_profile(uid): return {"profile": {"nick": "Thief", "nickChosen": 1, "enabled": 1, "xp": 0, "cash": 5000, "gold": 100}} class Store: def __init__(self): self.path = STATE_DIR / "players.json" self.players = {} if self.path.exists(): try: self.players = json.loads(self.path.read_text()) except Exception: LOG.warning("could not read %s, starting fresh", self.path) def save(self): self.path.write_text(json.dumps(self.players, indent=2)) def new_user(self): uid = uuid.uuid4().hex[:16] token = uuid.uuid4().hex sections = _minimal_profile(uid) if MINIMAL else _default_profile(uid) self.players[uid] = {"token": token, "sections": sections} self.save() LOG.info("created user uid=%s", uid) return uid, token def get(self, uid): return self.players.get(uid) STORE = Store() # -------------------------------------------------------------------------- # command handlers # -------------------------------------------------------------------------- COMMANDS = {} def command(name): def deco(fn): COMMANDS[name] = fn return fn return deco @command("gu3session") async def cmd_session(sess, payload): """Handshake. The client sends build/device identity and expects a session back. ParseSession() disconnects on an empty session id and can trip maintenance mode, so both must be well-formed.""" sess.uid = payload.get("uid") or None sess.devhash = payload.get("devhash", "") sess.devid = payload.get("devid", "") # SyncReply compares stored devhash against GetDeviceHash().Substring(0,7); # store/echo only the 7-char prefix or it reads as another device. sess.devhash7 = sess.devhash[:7] LOG.info("handshake ver=%s build=%s env=%s stream=%s platform=%s dev=%s", payload.get("ver"), payload.get("build"), payload.get("env"), payload.get("stream"), payload.get("platform"), payload.get("devmodel")) # ParseSession() references no string literals, so we cannot read the exact # key names out of the binary; it disconnects when a required string comes # back empty. Answer with a superset of plausible names -- extras are # ignored, and whichever one it actually reads will be populated. ver = payload.get("ver") or SERVER_VERSION build = payload.get("build") or SERVER_BUILD now = int(time.time()) out = { "sid": sess.sid, "session": sess.sid, "sessionid": sess.sid, "sessionId": sess.sid, "id": sess.sid, "uid": sess.uid or "", "token": sess.token, "ver": ver, "version": ver, "sver": ver, "serverversion": ver, "serverVersion": ver, "build": build, "serverbuild": build, "serverBuild": build, "time": now, "ts": now, "timestamp": now, "servertime": now, "maintenance": False, "maint": False, "upgrade": False, "upgradeclient": False, "forceupgrade": False, "devicelimit": False, "deviceLimit": False, "minver": "1.0.0", "minversion": "1.0.0", "minimumrequiredversion": "1.0.0", "minimumRequiredVersion": "1.0.0", "ip": sess.ip, "publicip": sess.ip, "publicIp": sess.ip, "env": payload.get("env", "production"), "stream": payload.get("stream", "livex"), } LOG.info("--> session reply keys: %s", ",".join(sorted(out))) return out @command("gu3new") async def cmd_new(sess, payload): uid, token = STORE.new_user() sess.uid = uid await asyncio.sleep(SYNC_DELAY) # SyncReply compares the echoed devhash against DeviceHelper.GetDeviceHash(); # a mismatch fires OnPlayingOnAnotherDevice and the client parks behind a # "playing on another device" modal. Echo exactly what the handshake sent. # # Two-phase send (see _push_profile_later): the "profile" section change # fires LoadProfile -> LinkInventory -> SetEquippedRifle/Bag, which iterate # the OwnedRifle/OwnedBag lists. Those lists are only populated once the # "gadget_N" item sections have been processed by LoadInventoryItem. The # client does NOT honour JSON key order within a fullSync, so a single reply # containing both fired "profile" before the gadgets -> ownedRifles empty -> # UpdateRifle NRE. Send everything EXCEPT profile now, then push the profile # a beat later as a partial update once the gadgets are owned. asyncio.create_task(_push_profile_later(sess, uid, delay=4.0)) # See _repush_dataset: PlayerProfile only subscribes to Dataset.onSectionChange # once FE.Core awakes (~8s in); this loopback reply lands well before that, so # LoadInventoryItem/LinkInventory never run for the initial payload (confirmed: # ownedMasks stays empty, PlayerProfile.instance.mask stays permanently null, # crashing MenuCharacter::ConfigureFromPlayerProfile). Re-push once FE.Core is up. asyncio.create_task(_repush_dataset(sess, uid)) return {"uid": uid, "user": uid, "token": token, "fullSync": True, "devhash": sess.devhash7, "dev": sess.devid, **_sections_no_profile(STORE.get(uid)["sections"])} async def _repush_dataset(sess, uid, delay=10.0): """The client's PlayerProfile subscribes to Dataset.onSectionChange only once FE.Core awakes (~8s in). Our loopback reply lands at ~4.6s, so the section-change events fire before anything is listening and PlayerProfile waits forever. The real backend was an AWS round-trip away and never hit this. Re-push the dataset once the frontend is up: Dataset.OnPushMessage handles an unsolicited "gu3sync" and re-runs SyncReply.""" try: await asyncio.sleep(delay) rec = STORE.get(uid) if rec is None: return LOG.info("[%d] re-pushing dataset for %s (frontend should be listening " "by now)", sess.n, uid) # ServerMessenger.OnReply resets its parsed `index` to 0 before parsing, # so an absent "index" key still reply-matches as index 0 and gets # dropped ("message not found gu3sync:0") instead of push-routing. # Routing to onPushMessage -> Dataset.OnPushMessage requires index < 0 # explicitly (verified via disassembly of OnReply @ 0x10740C7). await sess.send({ "id": "gu3sync", "index": -1, "timestamp": int(time.time()), "timestampOffset": 0, "error": "", "payload": {"uid": uid, "user": uid, "token": rec["token"], "fullSync": True, **_ordered_sections(rec["sections"])}, }) except Exception as e: LOG.debug("re-push aborted: %s", e) def _sections_no_profile(sections: dict) -> dict: """Everything except the 'profile' section, inventory first.""" return {k: v for k, v in _ordered_sections(sections).items() if k != "profile"} async def _push_profile_later(sess, uid, delay=2.5): """Deferred, second-phase push of the 'profile' section only. 'profile' triggers LinkInventory, which equips the rifle/bag by looking them up in the typed owned lists. Those lists are populated by the 'gadget_N' item sections. Within a single fullSync the client processes 'profile' before the gadgets have all been turned into OwnedRifle/OwnedBag, so SetEquippedRifle -> UpdateRifle NREs (a genuine race, masked only by Frida's hook overhead). Sending the inventory first in the sync reply and the profile a beat later as a PARTIAL update (fullSync:false, so it does not wipe the already-owned gadgets) makes the ownership-before-equip ordering deterministic.""" try: await asyncio.sleep(delay) rec = STORE.get(uid) if rec is None or "profile" not in rec["sections"]: return LOG.info("[%d] deferred profile push for %s (inventory owned by now)", sess.n, uid) await sess.send({ "id": "gu3sync", "index": -1, "timestamp": int(time.time()), "timestampOffset": 0, "error": "", "payload": {"uid": uid, "user": uid, "token": rec["token"], "fullSync": False, "profile": rec["sections"]["profile"]}, }) except Exception as e: LOG.debug("deferred profile push aborted: %s", e) @command("gu3sync") async def cmd_sync(sess, payload): uid = payload.get("uid") or sess.uid rec = STORE.get(uid) if uid else None if rec is None: if uid: # The client presents a uid from its keychain. Returning a # *different* uid reads to the client as the account having moved, # which trips OnPlayingOnAnotherDevice and parks it behind a modal. # Adopt the uid it asked for instead of minting a new one. LOG.info("adopting client-supplied uid %s", uid) STORE.players[uid] = { "token": payload.get("token") or uuid.uuid4().hex, "sections": (_minimal_profile(uid) if MINIMAL else _default_profile(uid)), } STORE.save() else: uid, _ = STORE.new_user() sess.uid = uid rec = STORE.get(uid) await asyncio.sleep(SYNC_DELAY) # Two-phase send: inventory (and everything non-profile) first so the # OwnedRifle/OwnedBag lists are populated, then the profile section a beat # later so LinkInventory's SetEquippedRifle/Bag find a non-empty list # instead of NRE-ing in UpdateRifle. See cmd_new / _push_profile_later. asyncio.create_task(_push_profile_later(sess, uid, delay=4.0)) # See _repush_dataset / cmd_new: same subscribe-race fix for reconnect/resync. asyncio.create_task(_repush_dataset(sess, uid)) return { "uid": uid, "user": uid, "token": rec["token"], "fullSync": True, "devhash": sess.devhash7, "dev": sess.devid, **_sections_no_profile(rec["sections"]), } @command("gu3close") async def cmd_close(sess, payload): sess.uid = None return {} @command("gu3log") async def cmd_log(sess, payload): LOG.info("client log: %s", json.dumps(payload)[:400]) return {} @command("gu3ip") async def cmd_ip(sess, payload): return {"ip": sess.ip} @command("bmm") async def cmd_bmm(sess, payload): """Begin matchmaking -> return opponent (bot thief) personas. Persona fields (from the local persona the client Writes in the request): avtr,mask,gear,name (str); vip,scol,kuki (int); cid,cln,clntg (str clan); clnb0/1/2 (int); per gadget slot 0..2: gid(str), gdl/gdc/gcd/gsc/gdr/gdh(int). MatchPersona parses ints via Int32.Parse so int fields must be numeric. """ import random NAMES = ["XxShovellordxx", "bonkmaster69", "GamerGandalf", "sussy_baka", "L0rdF4rquaad", "ThiefJoeBiden", "yeetlord", "notabot_trustme", "CardboardCrim", "MoistVillain", "pp_largeman", "OwO_whatsthis"] CLANS = ["Pizzamen", "AAAA", "UwU", "CHUDINO", "GYATTgang", "Skibidi", "MilkGang", "SigmaBoys", "TheBackrooms"] TAGS = ["PZZA", "AAAA", "UwU", "CHUD", "GYAT", "SKBD", "MILK", "SIG", "BACK"] def bot(i): clan = random.randrange(len(CLANS)) pr = { "avtr": "", "mask": random.choice(["mask_1","mask_2","mask_3","mask_5"]), "gear": "", "name": random.choice(NAMES), "vip": 0, "scol": random.randint(0, 8), "cid": "", "cln": CLANS[clan], "clntg": TAGS[clan], "clnb0": 0, "clnb1": 0, "clnb2": 0, "kuki": 0, } for g in range(3): pr["gid%d"%g] = "" for f in ("gdl","gdc","gcd","gsc","gdr","gdh"): pr["%s%d"%(f, g)] = 0 return pr # Live-override hook: if /root/ants/bmm_reply.json exists, return it verbatim # so the reply shape can be iterated without restarting the server. try: import os, json as _json ov = "/root/ants/bmm_reply.json" if os.path.exists(ov): data = _json.load(open(ov)) # personalize: persona[0] is the local player -> stamp the requester's uid try: if sess.uid: data["x"]["res"][0]["uid"] = sess.uid except Exception: pass LOG.info("[%d] bmm -> OVERRIDE (persona0 uid=%s) %s", sess.n, (sess.uid or "?"), _json.dumps(data)[:200]) return data except Exception as e: LOG.warning("[%d] bmm override error: %s", sess.n, e) personas = [bot(i) for i in range(3)] LOG.info("[%d] bmm -> %d bot personas: %s", sess.n, len(personas), ", ".join(p["name"] for p in personas)) return personas @command("equip gadgets") async def cmd_equip_gadgets(sess, payload): return {} @command("refresh queue") async def cmd_refresh_queue(sess, payload): return {} @command("register push token") async def cmd_reg_push(sess, payload): return {} @command("_custom") async def cmd_custom(sess, payload): """Game commands ride inside _custom. 138 distinct commands were recovered from Dataset.Execute call sites; unknown ones are logged and acked so the client keeps moving instead of stalling on a missing reply.""" name = payload.get("_custom") or payload.get("cmd") or "?" LOG.info("custom command %r payload=%s", name, json.dumps(payload)[:300]) return {} # -------------------------------------------------------------------------- # session / dispatch # -------------------------------------------------------------------------- class Session: _n = 0 def __init__(self, ws): Session._n += 1 self.ws = ws self.sid = uuid.uuid4().hex self.token = uuid.uuid4().hex self.uid = None self.devhash = "" self.devid = "" self.devhash7 = "" self.hash_checked = False self.n = Session._n try: self.ip = ws.remote_address[0] except Exception: self.ip = "127.0.0.1" async def send(self, obj): raw = json.dumps(obj, separators=(",", ":")) LOG.debug("[%d] --> %s", self.n, raw[:8000]) await self.ws.send(raw) import hashlib import re # GetRequestHash(): MD5(message + "REQSALT!") rendered as lowercase hex # and appended to the raw frame after the JSON body. Salt is version-derived, so # 1.19.22110 -> "1.19.22110REQSALT!". SALTS = ["1.19.22110REQSALT!", "1.20.22969REQSALT!"] _HEX32 = re.compile(r"^[0-9a-f]{32}$") def parse_envelope(sess, raw): """Split '' and verify the trailing request hash.""" try: return json.loads(raw) except Exception: pass end = raw.rfind("}") if end == -1: LOG.warning("[%d] frame has no JSON body, ignored", sess.n) return None body, trailer = raw[:end + 1], raw[end + 1:].strip() try: msg = json.loads(body) except Exception as e: LOG.warning("[%d] could not parse JSON body: %s", sess.n, e) LOG.warning("[%d] tail was: %r", sess.n, raw[-80:]) return None if not sess.hash_checked: sess.hash_checked = True LOG.info("[%d] trailer after JSON: %r (%d chars, hex32=%s)", sess.n, trailer, len(trailer), bool(_HEX32.match(trailer))) # verified live: md5(body + salt).hexdigest()[:8] for salt in SALTS: want = hashlib.md5((body + salt).encode("ascii", "replace")).hexdigest() if want[:len(trailer)] == trailer: LOG.info("[%d] *** REQUEST HASH VERIFIED md5(body+%r)[:%d] ***", sess.n, salt, len(trailer)) sess.salt = salt break else: LOG.warning("[%d] trailer did not match MD5(body+salt) for any " "known salt -- accepting anyway", sess.n) return msg async def handle(ws): sess = Session(ws) LOG.info("=== client connected from %s (session %d) ===", sess.ip, sess.n) try: async for raw in ws: if isinstance(raw, bytes): raw = raw.decode("utf-8", "replace") LOG.info("[%d] <-- (%d bytes) %s", sess.n, len(raw), raw[:400]) msg = parse_envelope(sess, raw) if msg is None: continue cmd = msg.get("id") index = msg.get("index", 0) payload = msg.get("payload") or {} noreply = bool(msg.get("noreply")) # track the latest uid seen from any request (used to personalize bmm) if isinstance(payload, dict) and payload.get("uid"): sess.uid = payload["uid"] # heartbeat is answered with an unsolicited push, not a reply if cmd == "gu3ping": await sess.send({"id": "gu3pong", "index": 0, "timestamp": int(time.time()), "timestampOffset": 0, "error": "", "payload": {}}) continue handler = COMMANDS.get(cmd) if handler is None: LOG.warning("[%d] !! UNHANDLED COMMAND %r payload=%s", sess.n, cmd, json.dumps(payload)[:300]) result, error = {}, "" else: try: result, error = await handler(sess, payload), "" except Exception as e: LOG.exception("[%d] handler %r failed", sess.n, cmd) result, error = {}, "ERR_INTERNAL" if not noreply: await sess.send({ "id": cmd, "index": index, "timestamp": int(time.time()), "timestampOffset": 0, "error": error, "payload": result, }) except websockets.exceptions.ConnectionClosed: pass finally: LOG.info("=== client disconnected (session %d) ===", sess.n) import http class Gu3Protocol(websockets.WebSocketServerProtocol): """Answer the client's pre-login HTTP 'get-ip' probe. Before opening the gu3 WebSocket, Gu3.ServerMessenger.FetchPublicIp issues a plain HTTP `GET /` with header `gu3-request: get-ip` to the SAME host:port and expects the response BODY to be the caller's public IP (it runs IPAddress.TryParse on it). A WebSocket-only server rejects that as a non- upgrade request (426), and this build blocks login until it succeeds — the phone loops here forever. process_request runs before the upgrade check, so we intercept the probe and return the client's source IP as text.""" async def process_request(self, path, request_headers): try: is_getip = (request_headers.get("gu3-request", "").strip().lower() == "get-ip") # Fallback: any non-WebSocket GET (no Upgrade header) is treated as # the get-ip probe too, so a header rename can't relock login. has_upgrade = "upgrade" in ( request_headers.get("Upgrade", "").lower() + request_headers.get("Connection", "").lower()) if is_getip or not has_upgrade: ip = self.remote_address[0] if self.remote_address else "0.0.0.0" body = ip.encode("ascii", "replace") LOG.info("get-ip probe from %s -> %s", ip, ip) return (http.HTTPStatus.OK, [("Content-Type", "text/plain"), ("Content-Length", str(len(body))), ("Connection", "close")], body) except Exception as e: LOG.warning("process_request error: %s", e) return None async def main(): ap = argparse.ArgumentParser() ap.add_argument("--host", default="0.0.0.0") ap.add_argument("--port", type=int, default=9001) ap.add_argument("-v", "--verbose", action="store_true") a = ap.parse_args() logging.basicConfig( level=logging.DEBUG if a.verbose else logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s", datefmt="%H:%M:%S", ) LOG.info("gu3server listening on ws://%s:%d", a.host, a.port) LOG.info("(emulator reaches the host loopback as 10.0.2.2)") async with websockets.serve(handle, a.host, a.port, ping_interval=None, create_protocol=Gu3Protocol, max_size=8 * 1024 * 1024): await asyncio.Future() if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: pass