diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..1d25ad3c --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Copy to .env (gitignored) and fill in. Used by scripts/build-steam.sh. +# Real environment variables and CLI args override these. + +# --- local Windows deploy target --- +GAME_DIR=C:/Games/MBAACC + +# logging (default; built with SYMBOLS=1 so it's optimized AND symbolicatable) | debug | release +BUILD_TYPE=logging + +# --- remote macOS / CrossOver deploy target (set all three to enable) --- +# REMOTE_GAME_DIR must be absolute (no leading ~); spaces are fine. +#REMOTE_USER=sky +#REMOTE_HOST=macbook.local +#REMOTE_GAME_DIR=/Users/sky/Library/Application Support/CrossOver/Bottles/MBAACC/drive_c/Games/MBAACC + +# --- build inputs (defaults usually fine) --- +#STEAM_SDK=../SteamworksSDK/public +#MINGW32_BIN=/c/msys64/mingw32/bin + +# --- crash reporting (optional) --- +# Sentry/Glitchtip ingest DSN, baked into the build. Empty/unset => reporting disabled at runtime. +#SENTRY_DSN=https://@glitchtip.example.com/ diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ae597fdf..93214197 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,41 +1,195 @@ -# This is a basic workflow to help you get started with Actions name: CI -# Controls when the workflow will run +# Build on every push to master, every PR, and every tag. Tags additionally +# cut a draft GitHub release with the built artifact + an auto-generated changelog. on: - # Triggers the workflow on push or pull request events but only for the master branch push: branches: [ master ] + tags: [ 'v*' ] pull_request: branches: [ master ] - - # Allows you to run this workflow manually from the Actions tab workflow_dispatch: -# A workflow run is made up of one or more jobs that can run sequentially or in parallel +permissions: + contents: read + jobs: - # This workflow contains a single job called "build" + # ------------------------------------------------------------------ build build: - # The type of runner that the job will run on - runs-on: ubuntu-latest + runs-on: windows-latest + defaults: + run: + shell: msys2 {0} + steps: + - name: Checkout (with submodules) + uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 + fetch-tags: true + + - name: Set up MSYS2 (MINGW32 / i686) + uses: msys2/setup-msys2@v2 + with: + msystem: MINGW32 + update: true + install: >- + make + rsync + zip + git + mingw-w64-i686-toolchain + + - name: Build Steam release + env: + TAG_VERSION: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }} + run: | + set -euo pipefail + # The Makefile hardcodes the toolchain prefix as ../mingw32/bin/i686-w64-mingw32-* + # (a symlink to the MSYS2 mingw32 tree on the dev's machine). Reproduce that layout. + ln -sfn /mingw32 ../mingw32 + # actions/checkout only marks the workdir safe for the *Windows* git; the MSYS2 git used + # by the Makefile has its own config and otherwise refuses the repo ("dubious ownership"), + # which makes make_version's `git rev-parse`/`git status` return empty (baking a bogus + # version + bare "-custom" revision). Trust the workdir for the MSYS2 git too. + git config --global --add safe.directory "$(pwd)" || true + # MSYS2's recipe shell can blank TMP/TEMP, making native gcc/collect2 fall back to a + # non-writable C:\WINDOWS temp. Wrap the recipe shell to force a writable temp — same + # fix as scripts/build-steam.sh. + printf '%s\n' '#!/usr/bin/sh' 'export TMPDIR=/tmp TMP=/tmp TEMP=/tmp' 'shift' 'eval "$@"' > /tmp/recipe-shell.sh + chmod +x /tmp/recipe-shell.sh + # On a tag build, pin the version straight from the tag (e.g. v4.1.0 -> 4.1.0). Passing + # FULL_VERSION makes the baked-in version deterministic and bypasses git-in-make entirely, + # so a flaky git on the runner can't produce a version that mismatches the tag. + version_arg= + if [ -n "${TAG_VERSION:-}" ]; then + version_arg="FULL_VERSION=${TAG_VERSION#v}" + echo "Pinning release version: ${version_arg}" + fi + # STEAM_SDK enables -DENABLE_STEAM and links the 32-bit steam_api import lib; the + # resulting zip bundles steam_api.dll + steam_appid.txt via the Makefile's STEAM_PKG_* + # hooks. CHMOD_X/GRANT are no-oped to skip per-file icacls calls (not needed in CI). + make release \ + $version_arg \ + STEAM_SDK=3rdparty/SteamworksSDK/public \ + OS=Windows_NT \ + SHELL=/tmp/recipe-shell.sh \ + CHMOD_X=true GRANT=true \ + -j "$(nproc)" + # Diagnostic: show exactly what version got baked in, so any future mismatch is obvious. + echo "=== generated lib/Version.local.hpp ===" + cat lib/Version.local.hpp || true + + - name: Verify baked version matches tag + if: startsWith(github.ref, 'refs/tags/v') + run: | + set -euo pipefail + ver="${GITHUB_REF_NAME#v}" + zip="cccaster.v${ver}.zip" + if [ ! -f "$zip" ]; then + echo "::error::expected archive $zip not produced (got: $(ls cccaster.v*.zip 2>/dev/null))" + exit 1 + fi + # The zip's existence proves the Makefile derived the right major.minor+suffix (ARCHIVE is + # named from VERSION+SUFFIX). Now assert the full tag version is embedded as a CONTIGUOUS + # string in both shipped binaries. This is exactly what the runtime needs: the exe's + # hook.dll compatibility check (targets/Main.cpp) searches the dll's bytes for the version + # string, so a release whose binaries lack a contiguous copy boots as "Incompatible + # hook.dll". scripts/make_version embeds LocalVersionCode[] precisely so this holds; grep + # here so any regression of that embedding fails CI instead of shipping a broken release. + exe="cccaster.v$(echo "$ver" | cut -d. -f1,2).exe" + dll="cccaster/hook.dll" + for bin in "$exe" "$dll"; do + if [ ! -f "$bin" ]; then + echo "::error::expected built binary $bin not found" + exit 1 + fi + if ! grep -aqF "$ver" "$bin"; then + echo "::error::$bin does not embed contiguous version '$ver' (would boot as Incompatible hook.dll)" + exit 1 + fi + done + echo "OK: $zip / $exe / $dll embed version $ver" - # Steps represent a sequence of tasks that will be executed as part of the job + - name: Stage artifact + run: | + set -euo pipefail + mkdir -p dist + cp cccaster.v*.zip dist/ + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: cccaster-${{ github.sha }} + path: dist/*.zip + if-no-files-found: error + + # ---------------------------------------------------------------- release + # On a tag push (v*), create a DRAFT release with the built zip attached and + # release notes auto-generated from the git log since the previous tag. + release: + needs: build + if: startsWith(github.ref, 'refs/tags/v') + runs-on: windows-latest + permissions: + contents: write steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v3 - - # Runs a single command using the runners shell - - name: Run a one-line script - run: uname - - name: Set up MinGW - uses: egor-tensin/setup-mingw@v2 - - name: Install wine + - name: Checkout (full history + tags for changelog) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + path: dist + + - name: Generate changelog from git log + shell: bash + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + prev="$(git describe --tags --abbrev=0 "${tag}^" 2>/dev/null || true)" + { + echo "## ${tag}" + echo + if [ -n "${prev}" ]; then + echo "Changes since ${prev}:" + echo + git log "${prev}..${tag}" --no-merges --pretty='- %s (%h)' + else + echo "Changes:" + echo + git log "${tag}" --no-merges --pretty='- %s (%h)' + fi + } > RELEASE_NOTES.md + echo "----- generated notes -----" + cat RELEASE_NOTES.md + + - name: Create or update draft release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - sudo dpkg --add-architecture i386 - wget -qO - https://dl.winehq.org/wine-builds/winehq.key | sudo apt-key add - - sudo add-apt-repository ppa:cybermax-dexter/sdl2-backport - sudo apt-add-repository "deb https://dl.winehq.org/wine-builds/ubuntu $(lsb_release -cs) main" - sudo apt install --install-recommends winehq-stable - # Runs a set of commands using the runners shell - - name: Run a multi-line script - run: make release + set -euo pipefail + tag="${GITHUB_REF_NAME}" + mapfile -t assets < <(find dist -type f -name '*.zip') + if [ "${#assets[@]}" -eq 0 ]; then + echo "No build artifacts (*.zip) found to attach" >&2 + exit 1 + fi + echo "Attaching: ${assets[*]}" + # Idempotent: if a re-run (or re-pushed tag) finds the release already exists, replace its + # asset with --clobber instead of failing on `gh release create`, so the published asset + # always reflects the latest build for this tag. + if gh release view "${tag}" >/dev/null 2>&1; then + gh release upload "${tag}" "${assets[@]}" --clobber + gh release edit "${tag}" --notes-file RELEASE_NOTES.md + else + gh release create "${tag}" \ + --draft \ + --title "${tag}" \ + --notes-file RELEASE_NOTES.md \ + "${assets[@]}" + fi diff --git a/.gitignore b/.gitignore index 22f9e90c..523b879d 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ TAGS *.png scripts/seqlists* debugging +CLAUDE.local.md +spike/ +scripts/.recipe-shell.sh +.env diff --git a/.gitmodules b/.gitmodules index f787a778..a5cda9ca 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "3rdparty/imgui"] path = 3rdparty/imgui url = https://github.com/ocornut/imgui +[submodule "3rdparty/SteamworksSDK"] + path = 3rdparty/SteamworksSDK + url = https://github.com/rlabrecque/SteamworksSDK.git diff --git a/3rdparty/SteamworksSDK b/3rdparty/SteamworksSDK new file mode 160000 index 00000000..494c2d68 --- /dev/null +++ b/3rdparty/SteamworksSDK @@ -0,0 +1 @@ +Subproject commit 494c2d680b9e47bbc369496b57568f44ef2f6796 diff --git a/Makefile b/Makefile index 2ff20d86..0c32095f 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,16 @@ -VERSION = 3.1 -SUFFIX = .007 +# Full version code derived from a git tag (leading 'v' stripped). +# When several tags share the HEAD commit (e.g. v4.0 and v4.0.1), prefer the HIGHEST version +# rather than whatever `git describe` happens to pick first. Fall back to the nearest reachable +# tag, then to a literal when no tag is reachable (e.g. a source export with no git history). +GIT_TAG := $(shell git tag --points-at HEAD --sort=-v:refname 2>/dev/null | head -n1) +GIT_TAG := $(or $(GIT_TAG),$(shell git describe --tags --abbrev=0 2>/dev/null)) +GIT_VERSION := $(patsubst v%,%,$(GIT_TAG)) +FULL_VERSION := $(or $(GIT_VERSION),3.1.007) + +# major.minor: drives the binary/archive filenames and netplay-compat comparison +VERSION := $(shell echo '$(FULL_VERSION)' | cut -d. -f1,2) +# remaining patch part, with its leading dot (empty for tags like v2.1e) +SUFFIX := $(patsubst $(VERSION)%,%,$(FULL_VERSION)) NAME = cccaster TAG = BRANCH := $(shell git rev-parse --abbrev-ref HEAD) @@ -90,13 +101,57 @@ DEFINES += -DLOBBY_LIST='"$(LOBBY_LIST)"' INCLUDES = -I$(CURDIR) -I$(CURDIR)/netplay -I$(CURDIR)/lib -I$(CURDIR)/tests -I$(CURDIR)/3rdparty -I$(CURDIR)/sequences INCLUDES += -I$(CURDIR)/3rdparty/cereal/include -I$(CURDIR)/3rdparty/gtest/include -I$(CURDIR)/3rdparty/minhook/include INCLUDES += -I$(CURDIR)/3rdparty/d3dhook -I$(CURDIR)/3rdparty/framedisplay -I$(CURDIR)/3rdparty/imgui -I$(CURDIR)/3rdparty/imgui/backends -CC_FLAGS = -m32 $(INCLUDES) $(DEFINES) +# -include stdint.h: some libstdc++ versions (e.g. CI's MSYS2 toolchain) don't transitively +# pull in the fixed-width int types that ~95 files rely on, so force them into every TU rather +# than hand-adding the include everywhere. Use stdint.h (not the C++-only ) because +# CC_FLAGS is shared with the C sources (3rdparty/md5.c, miniz.c, ...); stdint.h works for both +# and still exposes the bare global uint32_t the codebase uses. +CC_FLAGS = -m32 -include stdint.h $(INCLUDES) $(DEFINES) # https://gcc.gnu.org/onlinedocs/gcc/x86-Options.html # Intel Celeron 440 is listed as minimum CPU for melty on steam CC_FLAGS += -mmmx -msse -msse2 -msse3 -mssse3 # Linker flags -LD_FLAGS = -m32 -static -lws2_32 -lpsapi -lwinpthread -lwinmm -lole32 -ldinput -lwininet -ldwmapi -lgdi32 +LD_FLAGS = -m32 -static -lws2_32 -lpsapi -lwinpthread -lwinmm -lole32 -ldinput -lwininet -ldwmapi -lgdi32 -ldbghelp + +# ----- Steamworks (optional Steam connection method) ----- +# Disabled unless STEAM_SDK is set to the 'public' dir of the Steamworks SDK, e.g.: +# make STEAM_SDK=../SteamworksSDK/public +# When set, -DENABLE_STEAM is defined and the 32-bit steam_api import lib is linked. +# steam_api.dll (32-bit, from /redistributable_bin/) + a steam_appid.txt containing +# 411370 must be shipped next to MBAA.exe at runtime. All Steam code is #ifdef ENABLE_STEAM +# so builds without STEAM_SDK are byte-for-byte unaffected. +STEAM_SDK ?= +STEAM_LIB = +STEAM_PKG_ROOT = true +STEAM_PKG_FOLDER = true +ifneq ($(STEAM_SDK),) + DEFINES += -DENABLE_STEAM + INCLUDES += -I$(STEAM_SDK) + # MinGW links the MSVC import lib directly (verified). If a future toolchain refuses, + # generate one with: gendef steam_api.dll && dlltool -d steam_api.def -l libsteam_api.a + # Linked ONLY into the main exe + hook.dll (see their recipes), NOT the build-time tools + # (generator/debugger), so those don't acquire a steam_api.dll runtime dependency. + STEAM_LIB = $(STEAM_SDK)/../redistributable_bin/steam_api.lib + # Runtime files: steam_api.dll (32-bit) + steam_appid.txt(411370) must sit in the game + # dir (next to MBAA.exe / the main exe, which is also MBAA.exe's working dir) so both the + # launcher and the injected hook.dll can load Steam. We drop them next to the main exe and + # bundle a copy in the cccaster/ folder for the release archive. + STEAM_DLL = $(STEAM_SDK)/../redistributable_bin/steam_api.dll + STEAM_PKG_ROOT = cp -f $(STEAM_DLL) ./ && printf '411370' > ./steam_appid.txt + STEAM_PKG_FOLDER = cp -f $(STEAM_DLL) $(FOLDER)/ && printf '411370' > $(FOLDER)/steam_appid.txt +endif + +# ----- Sentry / Glitchtip crash reporting (optional) ----- +# Set SENTRY_DSN to your ingest DSN to enable crash/error reporting, e.g.: +# make SENTRY_DSN='https://@glitchtip.example.com/1' +# Empty (default) => the client is compiled in but disabled at runtime (no-op, no network), so +# builds without a DSN are unaffected. Like the relay IPs, the DSN is baked into the binary; keep +# real DSNs out of git (pass via CLI or source from .env). lib/SentryClient.cpp links into the main +# exe + hook.dll automatically (lib/*.cpp wildcard); the launcher recipe pulls it in explicitly. +SENTRY_DSN ?= +SENTRY_DEFINE = -DSENTRY_DSN='"$(SENTRY_DSN)"' +DEFINES += $(SENTRY_DEFINE) # Build options # DEFINES += -DDISABLE_LOGGING @@ -116,6 +171,17 @@ else endif RELEASE_FLAGS = -s -Os -Ofast -fno-rtti -DNDEBUG -DRELEASE -DDISABLE_LOGGING -DDISABLE_ASSERTS +# SYMBOLS=1 keeps DWARF debug info in the optimized logging build so Sentry/crash-report addresses +# can be resolved with scripts/symbolicate.sh. It adds -ggdb (line info) and -fno-omit-frame-pointer +# (clean StackWalk64 stacks), drops -s, and skips the strip step — WITHOUT pulling in the debug +# build's -O0 or -D_GLIBCXX_DEBUG (which alter timing and abort on latent UB). Only affects logging; +# scripts/build-steam.sh passes it by default. Release stays stripped. +SYMBOLS ?= 0 +ifeq ($(SYMBOLS),1) + LOGGING_FLAGS := $(filter-out -s,$(LOGGING_FLAGS)) -ggdb -fno-omit-frame-pointer + STRIP = touch +endif + # Build type BUILD_TYPE = build_debug BUILD_PREFIX = $(BUILD_TYPE)_$(BRANCH) @@ -152,6 +218,7 @@ ifneq (,$(findstring release,$(MAKECMDGOALS))) $(ZIP) $(ARCHIVE) -j scripts/Add_Handler_Protocol.bat $(ZIP) $(ARCHIVE) -j $(RELAY_LIST) $(ZIP) $(ARCHIVE) -j $(LOBBY_LIST) + $(ZIP) $(ARCHIVE) -j vendor/* cp -r res/GRP GRP $(ZIP) $(ARCHIVE) -r GRP $(ZIP) $(ARCHIVE) -r cccaster/trials @@ -162,21 +229,23 @@ endif $(BINARY): $(addprefix $(BUILD_PREFIX)/,$(MAIN_OBJECTS)) res/icon.res rm -f $(filter-out $(BINARY),$(wildcard $(NAME)*.exe)) - $(CXX) -o $@ $(CC_FLAGS) -Wall -std=c++2a -fconcepts $^ $(LD_FLAGS) + $(CXX) -o $@ $(CC_FLAGS) -Wall -std=c++2a -fconcepts $^ $(LD_FLAGS) $(STEAM_LIB) @echo $(STRIP) $@ $(CHMOD_X) + $(STEAM_PKG_ROOT) @echo $(FOLDER)/$(DLL): $(addprefix $(BUILD_PREFIX)/,$(DLL_OBJECTS)) res/rollback.o targets/CallDraw.s | $(FOLDER) - $(CXX) -o $@ $(CC_FLAGS) -Wall -std=c++2a -fconcepts $^ -shared $(LD_FLAGS) -ld3dx9 + $(CXX) -o $@ $(CC_FLAGS) -Wall -std=c++2a -fconcepts $^ -shared $(LD_FLAGS) -ld3dx9 $(STEAM_LIB) @echo $(STRIP) $@ $(GRANT) + $(STEAM_PKG_FOLDER) @echo -$(FOLDER)/$(LAUNCHER): tools/Launcher.cpp | $(FOLDER) - $(CXX) -o $@ $^ -m32 -s -Os -O2 -Wall -static -mwindows +$(FOLDER)/$(LAUNCHER): tools/Launcher.cpp lib/SentryClient.cpp | $(FOLDER) + $(CXX) -o $@ $^ -m32 -s -Os -O2 -Wall -static -mwindows -I$(CURDIR)/lib -I$(CURDIR)/3rdparty/cereal/include $(SENTRY_DEFINE) -lwininet -ldbghelp @echo $(STRIP) $@ $(CHMOD_X) @@ -229,7 +298,7 @@ res/icon.res: res/icon.rc res/icon.ico LOGGING_PREFIX = build_logging_$(BRANCH) DEBUGGER_LIB_OBJECTS = \ - $(addprefix $(LOGGING_PREFIX)/,$(filter-out lib/Version.o lib/LoggerLogVersion.o lib/ConsoleUi.o,$(LIB_OBJECTS))) + $(addprefix $(LOGGING_PREFIX)/,$(filter-out lib/Version.o lib/LoggerLogVersion.o lib/ConsoleUi.o lib/SteamManager.o lib/SteamSocket.o lib/SteamLobby.o lib/SteamMatchmaking.o,$(LIB_OBJECTS))) tools/$(DEBUGGER): tools/Debugger.cpp $(DEBUGGER_LIB_OBJECTS) $(CXX) -o $@ $(CC_FLAGS) $(LOGGING_FLAGS) -Wall -std=c++2a -fconcepts $^ $(LD_FLAGS) \ @@ -241,7 +310,7 @@ tools/$(DEBUGGER): tools/Debugger.cpp $(DEBUGGER_LIB_OBJECTS) GENERATOR_LIB_OBJECTS = \ - $(addprefix $(LOGGING_PREFIX)/,$(filter-out lib/Version.o lib/LoggerLogVersion.o lib/ConsoleUi.o,$(LIB_OBJECTS))) + $(addprefix $(LOGGING_PREFIX)/,$(filter-out lib/Version.o lib/LoggerLogVersion.o lib/ConsoleUi.o lib/SteamManager.o lib/SteamSocket.o lib/SteamLobby.o lib/SteamMatchmaking.o,$(LIB_OBJECTS))) tools/$(GENERATOR): tools/Generator.cpp $(GENERATOR_LIB_OBJECTS) $(CXX) -o $@ $(CC_FLAGS) $(LOGGING_FLAGS) -Wall -std=c++2a -fconcepts $^ $(LD_FLAGS) diff --git a/lib/ConsoleUi.cpp b/lib/ConsoleUi.cpp index 55244c16..04387090 100644 --- a/lib/ConsoleUi.cpp +++ b/lib/ConsoleUi.cpp @@ -378,9 +378,18 @@ ConsoleUi::ConsoleUi ( const string& title, bool isWine ) // Get handle HANDLE handle = GetStdHandle ( STD_OUTPUT_HANDLE ); + // These undocumented console-font APIs are absent on modern Windows: GetProcAddress returns + // null (calling through it would crash) or GetNumberOfConsoleFonts returns 0. Skip the font + // tweak in that case rather than indexing an empty vector (which also trips _GLIBCXX_DEBUG). + if ( ! SetConsoleFont || ! GetConsoleFontInfo || ! GetNumberOfConsoleFonts ) + return; + // Get Number of console fonts DWORD numFounts = GetNumberOfConsoleFonts(); + if ( numFounts == 0 ) + return; + // Setup array vector fonts ( numFounts ); diff --git a/lib/EventManager.hpp b/lib/EventManager.hpp index d202fed5..358dceee 100644 --- a/lib/EventManager.hpp +++ b/lib/EventManager.hpp @@ -3,6 +3,7 @@ #include "Thread.hpp" #include "BlockingQueue.hpp" +#include #include diff --git a/lib/ILobbyBackend.hpp b/lib/ILobbyBackend.hpp new file mode 100644 index 00000000..9f7c09bc --- /dev/null +++ b/lib/ILobbyBackend.hpp @@ -0,0 +1,151 @@ +#pragma once + +#include "IpAddrPort.hpp" +#include "Thread.hpp" + +#include +#include + + +// Game types advertised by a lobby (server-backed Concerto lobbies use these). +enum GameType +{ + FFA, + WinnerStaysOn, +}; + +// The lobby UI in MainUi::lobby() is a small state machine over these modes. Both the +// server-backed (ServerLobby) and Steam-backed (SteamLobby) implementations populate the +// same menu/ip/id vectors per mode, so the UI loop is backend-agnostic. +enum LobbyMode +{ + MENU, + CONCERTO_BROWSE, + CONCERTO_LOBBY, + DEFAULT_LOBBY, +}; + + +// Role this client plays in the current king-of-the-hill match. +enum class QueueRole +{ + None, // not in a match: sit in the lobby menu (un-readied) + Host, // play, hosting (the reigning "king") + Client, // play, connecting to the king + Spectator, // readied but waiting: auto-spectate the live match +}; + +// Phase of the queue, broadcast by the lobby owner. +enum class QueuePhase +{ + Assembling, // fewer than two ready, or between matches: no live match + Playing, // a match is live (hostId vs clientId) +}; + +// Snapshot of the king-of-the-hill queue, rebuilt each refresh() and read by MainUi (Steam only). +struct QueueState +{ + uint32_t gen = 0; // current match generation; 0 == none yet + QueuePhase phase = QueuePhase::Assembling; + QueueRole myRole = QueueRole::None; + uint64_t hostId = 0; // current match host (the king) + uint64_t clientId = 0; // current challenger + IpAddrPort hostAddr; // steam:, for Client / Spectator to connect to + bool iAmReady = false; + std::vector rows; // display rows for the lobby UI +}; + + +// Backend interface for the "Server -> Lobby" menu. MainUi drives this surface; a concrete +// backend is either the relay/Concerto server (ServerLobby, a Thread+Socket) or Steam lobbies +// (SteamLobby, driven by SteamManager's pump). The seam virtuals (isSteam/needsEventManager/ +// refresh/pumpUntilSettled) let the single UI loop in MainUi::lobby() serve both: the server +// backend signals MainUi's uiCondVar from its own thread, while the Steam backend has no thread +// and must be pumped from the UI thread instead. +struct ILobbyBackend +{ + struct Owner + { + virtual void connectionFailed ( ILobbyBackend *lobby ) = 0; + virtual void unlock ( ILobbyBackend *lobby ) = 0; + }; + + Owner *owner = 0; + + // ---- shared observable state (read by MainUi, some under entryMutex) ---- + IpAddrPort _address; // host port ("46318") on the server host path; unused for Steam + Mutex entryMutex; // guards the menu/ip/id vectors during refresh + LobbyMode mode = MENU; + int numEntries = 0; + bool hostSuccess = false; + bool connectionSuccess = false; + std::string lobbyError; + std::string lobbyMsg; // room code shown after create + + virtual ~ILobbyBackend() {} + + // ---- lifecycle ---- + virtual void start() = 0; + virtual bool isRunning() = 0; + virtual void stop() = 0; + + // ---- menu data (per mode) ---- + virtual std::vector getMenu() = 0; + virtual std::vector getIps() = 0; + virtual std::vector getIds() = 0; + + // ---- browse / create / join ---- + virtual void fetchPublicLobby() = 0; + virtual void create ( std::string name, std::string type ) = 0; // type: "Public" / "Private" + virtual std::string join ( std::string name, int selection ) = 0; // browse-index join, returns code + virtual void join ( std::string name, std::string code ) = 0; // code join + virtual bool checkLobbyCode ( std::string code ) = 0; + + // ---- in-lobby challenge handshake ---- + virtual void challenge ( std::string target, IpAddrPort port ) = 0; // becomes Host + virtual void preaccept ( std::string id ) = 0; // becomes Client + virtual void accept() = 0; // confirm connection + virtual void unhost() = 0; + virtual void end() = 0; + virtual void host ( std::string name, IpAddrPort port ) = 0; // DEFAULT_LOBBY only + + // ---- backend seam (server vs Steam) ---- + + // True for the Steam backend. MainUi uses this to gate the OR-in of ClientMode::IsSteam + + // a steam: address at the RUN sites, and to choose pump-vs-condvar at every wait point. + virtual bool isSteam() const { return false; } + + // The server backend runs its own EventManager loop on a thread; the Steam backend does not. + // MainUi gates its EventManager::isRunning() guards on this. + virtual bool needsEventManager() const { return true; } + + // Re-read the current mode's menu/ip/id vectors (under entryMutex). The server backend keeps + // these current via its background thread + poll timer, so this is a no-op there; the Steam + // backend re-enumerates lobby members / public lobbies here, called on the UI's periodic tick. + virtual void refresh() {} + + // Block (pumping Steam) until the in-flight async op settles. The server backend signals + // MainUi's uiCondVar from its thread instead, so this is a no-op there; the Steam backend + // runs a bounded pump loop until its pending op reaches a terminal state. + virtual void pumpUntilSettled() {} + + // ---- king-of-the-hill queue (Steam backend only; no-ops elsewhere) ---- + + // True if this backend drives the auto-matchmaking queue instead of the manual challenge flow. + virtual bool supportsQueue() const { return false; } + + // Toggle our own "ready to play" flag (advertised as member data). + virtual void setReady ( bool ready ) {} + + // The current queue snapshot. Called by MainUi while it holds entryMutex (like getMenu()), so + // implementations must NOT re-lock; refresh() rebuilds the cached snapshot under the lock. + virtual QueueState getQueueState() { return {}; } + + // Publish the outcome of match `gen` (our own member data). Both players report; on a clean + // finish they agree, on a disconnect only the survivor reports itself. + virtual void reportResult ( uint32_t gen, uint64_t winnerId ) {} + + // Owner-only: advance the queue and publish the next assignment. No-op when not the owner. + // Safe to call every UI tick. + virtual void coordinatorTick() {} +}; diff --git a/lib/IMatchmakingBackend.hpp b/lib/IMatchmakingBackend.hpp new file mode 100644 index 00000000..20f8095a --- /dev/null +++ b/lib/IMatchmakingBackend.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include "IpAddrPort.hpp" +#include "KeyboardManager.hpp" + +#include + + +// Backend interface for the "Server -> Matchmaking" menu. The concrete backend is either the +// relay server (ServerMatchmaking, a Thread+Socket that exchanges MMSTART/HOST/CLIENT) or Steam +// (SteamMatchmaking, a region-filtered create-or-join over Steam lobbies). Inherits +// KeyboardManager::Owner so AutoManager can hook ESC-to-cancel for either backend (the hook is +// only active under RELEASE, but the type must satisfy it). The waitConnected/waitMatched seam +// mirrors ILobbyBackend's pumpUntilSettled: the server backend signals MainUi's uiCondVar from +// its thread, while the Steam backend pumps from the UI thread. +struct IMatchmakingBackend : public KeyboardManager::Owner +{ + struct Owner + { + virtual void connectionFailed ( IMatchmakingBackend *mmm ) = 0; + virtual void setAddr ( IMatchmakingBackend *mmm, std::string addr ) = 0; + virtual void setMode ( IMatchmakingBackend *mmm, std::string mode ) = 0; + virtual void unlock ( IMatchmakingBackend *mmm ) = 0; + }; + + Owner *owner = 0; + + bool connectionSuccess = false; + bool matchSuccess = false; + bool ignoreKb = false; + + virtual ~IMatchmakingBackend() {} + + virtual void start() = 0; + virtual bool isRunning() = 0; + virtual void stop() = 0; + + virtual void sendHostReady() = 0; + + // ---- backend seam (server vs Steam) ---- + virtual bool isSteam() const { return false; } + virtual bool needsEventManager() const { return true; } + + // Block until connected to the pool (server) / the lobby search settles (Steam). + virtual void waitConnected() {} + // Block until a Host/Client role is decided (setMode/setAddr fired). + virtual void waitMatched() {} +}; diff --git a/lib/Lobby.cpp b/lib/Lobby.cpp index 58c0563d..88c813c6 100644 --- a/lib/Lobby.cpp +++ b/lib/Lobby.cpp @@ -32,9 +32,9 @@ vector Lobby::getIds(){ return lobbyids; } -Lobby::Lobby( Owner* owner ) - : owner( owner ) +Lobby::Lobby( ILobbyBackend::Owner* owner ) { + this->owner = owner; timeout = 5000; numEntries = 0; blankEntry = ""; diff --git a/lib/Lobby.hpp b/lib/Lobby.hpp index b4275bef..7c122e43 100644 --- a/lib/Lobby.hpp +++ b/lib/Lobby.hpp @@ -1,76 +1,54 @@ #pragma once +#include "ILobbyBackend.hpp" #include "ConsoleUi.hpp" #include "Socket.hpp" #include "Timer.hpp" #define DEFAULT_GET_TIMEOUT ( 5000 ) -enum GameType -{ - FFA, - WinnerStaysOn, -}; - -enum LobbyMode -{ - MENU, - CONCERTO_BROWSE, - CONCERTO_LOBBY, - DEFAULT_LOBBY, -}; - +// Relay/Concerto server-backed lobby (the "fallback" backend). A background Thread runs an +// EventManager loop over a TcpSocket to the lobby server and signals MainUi's uiCondVar via the +// ILobbyBackend::Owner callbacks. See SteamLobby for the Steam-backed implementation of the same +// interface. class Lobby : Socket::Owner , Timer::Owner , public Thread + , public ILobbyBackend { public: - struct Owner - { - virtual void connectionFailed( Lobby* lobby ) = 0; - virtual void unlock( Lobby* lobby ) = 0; - }; - - Owner *owner = 0; - std::vector getMenu(); - std::vector getIps(); - std::vector getIds(); + std::vector getMenu() override; + std::vector getIps() override; + std::vector getIds() override; - Lobby ( Owner* owner ); + Lobby ( ILobbyBackend::Owner* owner ); bool connect( std::string url ); void disconnect(); - void host( std::string name, IpAddrPort port ); - void unhost(); - std::string join( std::string name, int selection ); - void join( std::string name, std::string code ); - void challenge( std::string target, IpAddrPort port ); - void create( std::string name, std::string type ); - void preaccept( std::string id ); - void accept(); - void end(); - void fetchPublicLobby(); - bool checkLobbyCode( std::string code ); - - void init( Owner* owner ); - - void stop(); + void host( std::string name, IpAddrPort port ) override; + void unhost() override; + std::string join( std::string name, int selection ) override; + void join( std::string name, std::string code ) override; + void challenge( std::string target, IpAddrPort port ) override; + void create( std::string name, std::string type ) override; + void preaccept( std::string id ) override; + void accept() override; + void end() override; + void fetchPublicLobby() override; + bool checkLobbyCode( std::string code ) override; + + void init( ILobbyBackend::Owner* owner ); + + // ILobbyBackend lifecycle (unify with Thread's start/isRunning) + void start() override { Thread::start(); } + bool isRunning() override { return Thread::isRunning(); } + void stop() override; uint64_t timeout; - IpAddrPort _address; - bool connectionSuccess; bool newRequestSuccess; - int numEntries; - bool hostSuccess; - Mutex entryMutex; - LobbyMode mode; - - std::string lobbyError; - std::string lobbyMsg; - private: SocketPtr _socket; diff --git a/lib/LobbyQueue.hpp b/lib/LobbyQueue.hpp new file mode 100644 index 00000000..ed71d4e4 --- /dev/null +++ b/lib/LobbyQueue.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include +#include +#include + + +// King-of-the-hill queue ordering rules, deliberately free of any Steam / networking dependency +// so they can be unit-tested in isolation (see tests/Test.LobbyQueue.cpp). SteamLobby owns the +// transport + state; these functions only decide who is where in line. +// +// The queue is an ordered list of readied players' SteamIDs, front..back. The two at the front +// play the current match: order[0] hosts (the reigning "king") and order[1] challenges. When the +// match ends the winner stays at the front and the loser drops to the very back. +namespace LobbyQueue +{ + +inline bool contains ( const std::vector& v, uint64_t x ) +{ + return std::find ( v.begin(), v.end(), x ) != v.end(); +} + +// Drop ids no longer in `ready` (left or un-readied) while preserving their relative order, then +// append any newly-readied ids (in `ready` but missing from `order`) to the back in `ready` order. +inline std::vector reconcile ( const std::vector& order, + const std::vector& ready ) +{ + std::vector out; + + for ( uint64_t id : order ) + if ( contains ( ready, id ) && ! contains ( out, id ) ) + out.push_back ( id ); + + for ( uint64_t id : ready ) + if ( ! contains ( out, id ) ) + out.push_back ( id ); + + return out; +} + +// Advance the queue after a match between `host` (front) and `client` (second) won by `winner`. +// King-of-the-hill: the winner becomes/stays the front king; the loser drops to the very back. +// Members missing from `ready` are removed; newly-readied members are appended ahead of the loser. +// `winner` is only re-seated if it is still in `ready`. +inline std::vector advance ( const std::vector& order, + uint64_t host, uint64_t client, uint64_t winner, + const std::vector& ready ) +{ + const uint64_t loser = ( winner == host ) ? client : host; + + std::vector out; + + // The winner is the new king at the front (if still ready). + if ( winner && contains ( ready, winner ) ) + out.push_back ( winner ); + + // The existing queue order, minus the two who just played. + for ( uint64_t id : order ) + if ( id != winner && id != loser && contains ( ready, id ) && ! contains ( out, id ) ) + out.push_back ( id ); + + // Members who readied / joined during the match, ahead of the loser. + for ( uint64_t id : ready ) + if ( id != loser && ! contains ( out, id ) ) + out.push_back ( id ); + + // The loser goes to the very back. + if ( loser && loser != winner && contains ( ready, loser ) ) + out.push_back ( loser ); + + return out; +} + +} // namespace LobbyQueue diff --git a/lib/MatchmakingManager.cpp b/lib/MatchmakingManager.cpp index bb93307b..ddc558a3 100644 --- a/lib/MatchmakingManager.cpp +++ b/lib/MatchmakingManager.cpp @@ -7,9 +7,10 @@ using namespace std; -MatchmakingManager::MatchmakingManager( Owner* owner, IpAddrPort _address, string region ) - : owner( owner ), _address( _address ), region( region ) +MatchmakingManager::MatchmakingManager( IMatchmakingBackend::Owner* owner, IpAddrPort _address, string region ) + : _address( _address ), region( region ) { + this->owner = owner; timeout = 1000; connectionSuccess = false; ignoreKb = false; diff --git a/lib/MatchmakingManager.hpp b/lib/MatchmakingManager.hpp index e5ea03ef..f0bce81e 100644 --- a/lib/MatchmakingManager.hpp +++ b/lib/MatchmakingManager.hpp @@ -1,5 +1,6 @@ #pragma once +#include "IMatchmakingBackend.hpp" #include "ConsoleUi.hpp" #include "Socket.hpp" #include "Timer.hpp" @@ -8,37 +9,31 @@ #define DEFAULT_GET_TIMEOUT ( 5000 ) +// Relay server-backed matchmaking (the "fallback" backend). A background Thread connects to the +// matchmaking server (MMSTART,region) and is told HOST or CLIENT,, signalling MainUi's +// uiCondVar via the IMatchmakingBackend::Owner callbacks. See SteamMatchmaking for the +// Steam-backed implementation of the same interface. class MatchmakingManager : Socket::Owner , Timer::Owner - , public KeyboardManager::Owner , public Thread + , public IMatchmakingBackend { public: - struct Owner - { - virtual void connectionFailed( MatchmakingManager* lobby ) = 0; - virtual void setAddr( MatchmakingManager* lobby, std::string addr ) = 0; - virtual void setMode( MatchmakingManager* lobby, std::string mode ) = 0; - virtual void unlock( MatchmakingManager* lobby ) = 0; - }; - Owner *owner = 0; + MatchmakingManager ( IMatchmakingBackend::Owner* owner, IpAddrPort _address, std::string region ); - MatchmakingManager ( Owner* owner, IpAddrPort _address, std::string region ); - - void stop(); + void start() override { Thread::start(); } + bool isRunning() override { return Thread::isRunning(); } + void stop() override; void connect(); void disconnect(); - void sendHostReady(); + void sendHostReady() override; uint64_t timeout; IpAddrPort _address; - bool connectionSuccess; - bool matchSuccess; - bool ignoreKb; Mutex hostMutex; CondVar hostCondVar; diff --git a/lib/ProtocolEnums.hpp b/lib/ProtocolEnums.hpp index 1ccae9c3..c6d7d3c2 100644 --- a/lib/ProtocolEnums.hpp +++ b/lib/ProtocolEnums.hpp @@ -32,3 +32,4 @@ VersionConfig, JoysticksChanged, TransitionIndex, PaletteManager, +MatchResult, diff --git a/lib/SentryClient.cpp b/lib/SentryClient.cpp new file mode 100644 index 00000000..32f473f4 --- /dev/null +++ b/lib/SentryClient.cpp @@ -0,0 +1,884 @@ +#include "SentryClient.hpp" + +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace std; + + +// SDK identity reported to Sentry / Glitchtip. +#define SENTRY_CLIENT_NAME "cccaster.sentry" +#define SENTRY_CLIENT_VERSION "1.0" + +// Max breadcrumbs retained for context, and max captured stack frames per crash. +#define MAX_BREADCRUMBS ( 30 ) +#define MAX_STACK_FRAMES ( 48 ) + +// WinINet timeouts (ms) so a dead endpoint never stalls a crash path for long. +#define HTTP_TIMEOUT_MS ( 5000 ) + + +namespace +{ + +// JSON is built with the rapidjson Writer that cereal already vendors (the same library the netplay +// protocol's JSON archive uses). The Writer handles all string escaping and framing for us, so the +// event payload is always well-formed regardless of what ends up in an exception/log message. +typedef rapidjson::Writer JsonWriter; + + +struct State +{ + bool enabled = false; + + // Parsed DSN. + bool https = true; + string host; + INTERNET_PORT port = 443; + string path; // /api//envelope/ + string dsn; // original DSN, echoed in the envelope header + string authHeader; // value of X-Sentry-Auth + + // Event metadata. + string release; + string environment; + string dist; + + // Guards breadcrumbs, tags and the async queue. + CRITICAL_SECTION cs; + bool csReady = false; + + vector> tags; + vector> breadcrumbs; // (timestamp, message) + + // Async upload worker. + HANDLE workerThread = 0; + HANDLE wakeEvent = 0; + vector queue; // ready-to-POST envelope bodies + + // Diagnostics sink (e.g. routed to the app's logger). + SentryClient::LogSink logSink = 0; + + // DbgHelp symbol handler initialized (for StackWalk64 + symbol names). + bool symReady = false; + + // Chained crash handlers + dedupe/reentrancy guards. + PVOID vehHandle = 0; + LPTOP_LEVEL_EXCEPTION_FILTER prevFilter = 0; + terminate_handler prevTerminate = 0; + void ( *prevSigabrt ) ( int ) = 0; + volatile LONG vehReported = 0; // VEH reports a fatal first-chance fault at most once + volatile LONG inFilter = 0; // reentrancy guard for the unhandled filter +}; + +State g; + + +// Emit an internal diagnostic line. Routed to the registered sink (the app logger) and always to +// OutputDebugString, so a crash reporter that goes quiet can still be debugged from the logs. +void diag ( const string& msg ) +{ + if ( g.logSink ) + g.logSink ( msg.c_str() ); + + OutputDebugStringA ( ( "[sentry] " + msg + "\n" ).c_str() ); +} + + +// ----- small helpers ------------------------------------------------------- + +string isoTimestamp() +{ + time_t t = time ( 0 ); + char buf[32] = { 0 }; + strftime ( buf, sizeof ( buf ), "%Y-%m-%dT%H:%M:%SZ", gmtime ( &t ) ); + return buf; +} + +string randomEventId() +{ + static const char *hex = "0123456789abcdef"; + char id[33]; + for ( int i = 0; i < 32; ++i ) + id[i] = hex[rand() % 16]; + id[32] = 0; + return id; +} + +string hexPtr ( const void *p ) +{ + char buf[24]; + snprintf ( buf, sizeof ( buf ), "0x%08x", ( unsigned ) ( uintptr_t ) p ); + return buf; +} + +string hexCode ( unsigned code ) +{ + char buf[16]; + snprintf ( buf, sizeof ( buf ), "0x%08x", code ); + return buf; +} + +const char *levelStr ( SentryClient::Level level ) +{ + switch ( level ) + { + case SentryClient::Level::Debug: + return "debug"; + case SentryClient::Level::Info: + return "info"; + case SentryClient::Level::Warning: + return "warning"; + case SentryClient::Level::Error: + return "error"; + case SentryClient::Level::Fatal: + return "fatal"; + } + return "error"; +} + +// Crash-class exception codes worth reporting from the first-chance vectored handler. Excludes C++ +// exceptions (0xE06D7363) and debugger/control codes so normal handled exceptions aren't reported. +bool isFatalCode ( DWORD code ) +{ + switch ( code ) + { + case EXCEPTION_ACCESS_VIOLATION: + case EXCEPTION_STACK_OVERFLOW: + case EXCEPTION_ILLEGAL_INSTRUCTION: + case EXCEPTION_PRIV_INSTRUCTION: + case EXCEPTION_INT_DIVIDE_BY_ZERO: + case EXCEPTION_IN_PAGE_ERROR: + case EXCEPTION_ARRAY_BOUNDS_EXCEEDED: + return true; + default: + return false; + } +} + +// Write a "key": "value" string member onto the current JSON object. +void writeMember ( JsonWriter& w, const char *key, const string& value ) +{ + w.String ( key ); + w.String ( value.c_str(), ( rapidjson::SizeType ) value.size() ); +} + +string baseName ( const string& path ) +{ + const size_t i = path.find_last_of ( "\\/" ); + return ( i == string::npos ) ? path : path.substr ( i + 1 ); +} + +// Resolve the module owning an address; returns false if unknown. +bool moduleForAddr ( const void *addr, HMODULE& mod, string& path ) +{ + mod = 0; + if ( ! GetModuleHandleExA ( GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS + | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + ( LPCSTR ) addr, &mod ) ) + return false; + + char name[MAX_PATH] = { 0 }; + if ( GetModuleFileNameA ( mod, name, sizeof ( name ) ) ) + path = name; + return true; +} + +// True if the address belongs to a module under the Windows directory (a system DLL). Used to +// ignore benign first-chance exceptions that Windows DLLs (dxdiag/setupapi/...) raise as control +// flow, so the vectored handler only reports faults in the game / our own code. +bool addrInSystemModule ( const void *addr ) +{ + HMODULE mod = 0; + string path; + if ( ! moduleForAddr ( addr, mod, path ) || path.empty() ) + return false; + + char winDir[MAX_PATH] = { 0 }; + const UINT n = GetWindowsDirectoryA ( winDir, sizeof ( winDir ) ); + if ( n == 0 || n >= sizeof ( winDir ) ) + return false; + + return _strnicmp ( path.c_str(), winDir, n ) == 0; +} + + +// ----- HTTP ---------------------------------------------------------------- + +// Synchronous fire-and-forget POST of an already-framed envelope body. +void httpPost ( const string& body ) +{ + HINTERNET hInet = InternetOpenA ( SENTRY_CLIENT_NAME "/" SENTRY_CLIENT_VERSION, + INTERNET_OPEN_TYPE_PRECONFIG, 0, 0, 0 ); + if ( ! hInet ) + { + diag ( "InternetOpen failed err=" + hexCode ( GetLastError() ) ); + return; + } + + HINTERNET hConn = InternetConnectA ( hInet, g.host.c_str(), g.port, 0, 0, INTERNET_SERVICE_HTTP, 0, 0 ); + if ( ! hConn ) + { + diag ( "InternetConnect failed err=" + hexCode ( GetLastError() ) ); + InternetCloseHandle ( hInet ); + return; + } + + DWORD flags = INTERNET_FLAG_NO_UI | INTERNET_FLAG_RELOAD | INTERNET_FLAG_NO_CACHE_WRITE; + if ( g.https ) + { + // Be lenient on certs: self-hosted Glitchtip instances often use private/relaxed certs, + // and a crash report is best-effort anyway. + flags |= INTERNET_FLAG_SECURE + | INTERNET_FLAG_IGNORE_CERT_CN_INVALID + | INTERNET_FLAG_IGNORE_CERT_DATE_INVALID; + } + + HINTERNET hReq = HttpOpenRequestA ( hConn, "POST", g.path.c_str(), 0, 0, 0, flags, 0 ); + if ( ! hReq ) + { + diag ( "HttpOpenRequest failed err=" + hexCode ( GetLastError() ) ); + InternetCloseHandle ( hConn ); + InternetCloseHandle ( hInet ); + return; + } + + DWORD timeout = HTTP_TIMEOUT_MS; + InternetSetOption ( hReq, INTERNET_OPTION_CONNECT_TIMEOUT, &timeout, sizeof ( timeout ) ); + InternetSetOption ( hReq, INTERNET_OPTION_SEND_TIMEOUT, &timeout, sizeof ( timeout ) ); + InternetSetOption ( hReq, INTERNET_OPTION_RECEIVE_TIMEOUT, &timeout, sizeof ( timeout ) ); + + const string headers = "Content-Type: application/x-sentry-envelope\r\nX-Sentry-Auth: " + g.authHeader + "\r\n"; + + const BOOL ok = HttpSendRequestA ( hReq, headers.c_str(), ( DWORD ) headers.size(), + ( LPVOID ) body.data(), ( DWORD ) body.size() ); + + if ( ! ok ) + { + diag ( "HttpSendRequest failed err=" + hexCode ( GetLastError() ) ); + } + else + { + DWORD status = 0, len = sizeof ( status ); + HttpQueryInfoA ( hReq, HTTP_QUERY_STATUS_CODE | HTTP_QUERY_FLAG_NUMBER, &status, &len, 0 ); + char buf[16]; + snprintf ( buf, sizeof ( buf ), "%u", ( unsigned ) status ); + diag ( "POST " + g.host + g.path + " -> HTTP " + buf ); + } + + InternetCloseHandle ( hReq ); + InternetCloseHandle ( hConn ); + InternetCloseHandle ( hInet ); +} + +DWORD WINAPI workerMain ( LPVOID ) +{ + for ( ;; ) + { + WaitForSingleObject ( g.wakeEvent, INFINITE ); + + for ( ;; ) + { + string body; + + EnterCriticalSection ( &g.cs ); + if ( g.queue.empty() ) + { + LeaveCriticalSection ( &g.cs ); + break; + } + body = g.queue.front(); + g.queue.erase ( g.queue.begin() ); + LeaveCriticalSection ( &g.cs ); + + httpPost ( body ); + } + } + return 0; +} + +void enqueue ( const string& body ) +{ + EnterCriticalSection ( &g.cs ); + + if ( ! g.workerThread ) + { + g.wakeEvent = CreateEvent ( 0, FALSE, FALSE, 0 ); + g.workerThread = CreateThread ( 0, 0, workerMain, 0, 0, 0 ); + } + + g.queue.push_back ( body ); + LeaveCriticalSection ( &g.cs ); + + if ( g.wakeEvent ) + SetEvent ( g.wakeEvent ); +} + + +// ----- event / envelope building ------------------------------------------- + +// Collect the real faulting call chain. When we have the fault CONTEXT we walk it with StackWalk64 +// (the actual call stack, not the exception-dispatch path that RtlCaptureStackBackTrace returns +// from inside a filter). Otherwise (terminate/SIGABRT) we fall back to the current call stack. +int captureFrames ( EXCEPTION_POINTERS *info, void *addrs[MAX_STACK_FRAMES] ) +{ + int count = 0; + + if ( info && info->ContextRecord ) + { + CONTEXT ctx = *info->ContextRecord; // StackWalk64 mutates the context, so copy it + STACKFRAME64 sf = { 0 }; + sf.AddrPC.Offset = ctx.Eip; + sf.AddrPC.Mode = AddrModeFlat; + sf.AddrFrame.Offset = ctx.Ebp; + sf.AddrFrame.Mode = AddrModeFlat; + sf.AddrStack.Offset = ctx.Esp; + sf.AddrStack.Mode = AddrModeFlat; + + const HANDLE proc = GetCurrentProcess(); + const HANDLE thread = GetCurrentThread(); + + while ( count < MAX_STACK_FRAMES + && StackWalk64 ( IMAGE_FILE_MACHINE_I386, proc, thread, &sf, &ctx, + 0, SymFunctionTableAccess64, SymGetModuleBase64, 0 ) ) + { + if ( sf.AddrPC.Offset == 0 ) + break; + addrs[count++] = ( void * ) ( uintptr_t ) sf.AddrPC.Offset; + } + } + + if ( count == 0 ) + { + if ( info && info->ExceptionRecord ) + addrs[count++] = info->ExceptionRecord->ExceptionAddress; + count += CaptureStackBackTrace ( 0, MAX_STACK_FRAMES - count, &addrs[count], 0 ); + } + + return count; +} + +// Write a "stacktrace" member (frames oldest-first) onto the current exception-value object. Each +// frame carries the absolute address, owning module + base (so a module-relative offset can be +// derived) and, when DbgHelp can resolve it, a symbol name. For MinGW DWARF binaries DbgHelp only +// resolves exported names; the authoritative source location comes from running addr2line on the +// unstripped binary with (instruction_addr - image_addr). +void writeStacktrace ( JsonWriter& w, EXCEPTION_POINTERS *info ) +{ + void *addrs[MAX_STACK_FRAMES] = { 0 }; + const int count = captureFrames ( info, addrs ); + + if ( count == 0 ) + return; + + const HANDLE proc = GetCurrentProcess(); + + // Symbol buffer for SymFromAddr (struct + room for the name). + char symBuf[sizeof ( SYMBOL_INFO ) + 256] = { 0 }; + SYMBOL_INFO *sym = ( SYMBOL_INFO * ) symBuf; + sym->SizeOfStruct = sizeof ( SYMBOL_INFO ); + sym->MaxNameLen = 255; + + w.String ( "stacktrace" ); + w.StartObject(); + w.String ( "frames" ); + w.StartArray(); + + // Sentry expects frames oldest-first, so emit in reverse of the captured (newest-first) order. + for ( int i = count - 1; i >= 0; --i ) + { + if ( ! addrs[i] ) + continue; + + HMODULE mod = 0; + string path; + const bool haveMod = moduleForAddr ( addrs[i], mod, path ); + + // Function name: prefer a DbgHelp symbol, else fall back to module!+0xRVA so the frame is + // still human-readable and the RVA is right there for addr2line. + string function; + DWORD64 disp = 0; + if ( g.symReady && SymFromAddr ( proc, ( DWORD64 ) ( uintptr_t ) addrs[i], &disp, sym ) ) + { + char d[16]; + snprintf ( d, sizeof ( d ), "+0x%llx", ( unsigned long long ) disp ); + function = string ( sym->Name ) + d; + } + else if ( haveMod ) + { + const uintptr_t rva = ( uintptr_t ) addrs[i] - ( uintptr_t ) mod; + char r[24]; + snprintf ( r, sizeof ( r ), "!0x%x", ( unsigned ) rva ); + function = baseName ( path ) + r; + } + + w.StartObject(); + if ( ! function.empty() ) + writeMember ( w, "function", function ); + writeMember ( w, "instruction_addr", hexPtr ( addrs[i] ) ); + if ( haveMod ) + { + writeMember ( w, "package", path ); + writeMember ( w, "image_addr", hexPtr ( ( void * ) mod ) ); + } + w.EndObject(); + } + + w.EndArray(); + w.EndObject(); +} + +// Build the Sentry event payload. type empty => a message event; type non-empty => an exception +// event (optionally carrying a stacktrace from info). The generated event_id is returned via param. +string buildEvent ( SentryClient::Level level, const string& type, const string& value, + EXCEPTION_POINTERS *info, bool includeStack, string& eventId ) +{ + eventId = randomEventId(); + + rapidjson::StringBuffer sb; + JsonWriter w ( sb ); + + w.StartObject(); + + writeMember ( w, "event_id", eventId ); + writeMember ( w, "timestamp", isoTimestamp() ); + w.String ( "platform" ); + w.String ( "native" ); + writeMember ( w, "level", levelStr ( level ) ); + w.String ( "logger" ); + w.String ( "cccaster" ); + + w.String ( "sdk" ); + w.StartObject(); + w.String ( "name" ); + w.String ( SENTRY_CLIENT_NAME ); + w.String ( "version" ); + w.String ( SENTRY_CLIENT_VERSION ); + w.EndObject(); + + if ( ! g.release.empty() ) + writeMember ( w, "release", g.release ); + if ( ! g.environment.empty() ) + writeMember ( w, "environment", g.environment ); + if ( ! g.dist.empty() ) + writeMember ( w, "dist", g.dist ); + + EnterCriticalSection ( &g.cs ); + + if ( ! g.tags.empty() ) + { + w.String ( "tags" ); + w.StartObject(); + for ( const auto& t : g.tags ) + writeMember ( w, t.first.c_str(), t.second ); + w.EndObject(); + } + + if ( ! g.breadcrumbs.empty() ) + { + w.String ( "breadcrumbs" ); + w.StartObject(); + w.String ( "values" ); + w.StartArray(); + for ( const auto& b : g.breadcrumbs ) + { + w.StartObject(); + writeMember ( w, "timestamp", b.first ); + writeMember ( w, "message", b.second ); + w.EndObject(); + } + w.EndArray(); + w.EndObject(); + } + + LeaveCriticalSection ( &g.cs ); + + if ( type.empty() ) + { + w.String ( "message" ); + w.StartObject(); + writeMember ( w, "formatted", value ); + w.EndObject(); + } + else + { + w.String ( "exception" ); + w.StartObject(); + w.String ( "values" ); + w.StartArray(); + w.StartObject(); + writeMember ( w, "type", type ); + writeMember ( w, "value", value ); + if ( includeStack ) + writeStacktrace ( w, info ); + w.EndObject(); + w.EndArray(); + w.EndObject(); + } + + w.EndObject(); + + return string ( sb.GetString(), sb.Size() ); +} + +// Assemble a full Sentry envelope (header line, item header line, event payload) ready to POST. +string buildEnvelope ( SentryClient::Level level, const string& type, const string& value, + EXCEPTION_POINTERS *info, bool includeStack ) +{ + string eventId; + const string payload = buildEvent ( level, type, value, info, includeStack, eventId ); + + rapidjson::StringBuffer header; + JsonWriter hw ( header ); + hw.StartObject(); + writeMember ( hw, "event_id", eventId ); + writeMember ( hw, "sent_at", isoTimestamp() ); + writeMember ( hw, "dsn", g.dsn ); + hw.EndObject(); + + rapidjson::StringBuffer item; + JsonWriter iw ( item ); + iw.StartObject(); + iw.String ( "type" ); + iw.String ( "event" ); + iw.String ( "length" ); + iw.Uint ( ( unsigned ) payload.size() ); + iw.EndObject(); + + string envelope ( header.GetString(), header.Size() ); + envelope += "\n"; + envelope.append ( item.GetString(), item.Size() ); + envelope += "\n"; + envelope += payload; + envelope += "\n"; + return envelope; +} + +// Build a crash event and upload it. Fatal/terminating paths post synchronously (must deliver +// before the process dies); a swallowed first-chance fault posts async (process lives on, and we +// must not stall gameplay). +void captureCrash ( SentryClient::Level level, const string& type, const string& value, + EXCEPTION_POINTERS *info, bool async ) +{ + if ( ! g.enabled ) + return; + + const string envelope = buildEnvelope ( level, type, value, info, true ); + + if ( async ) + enqueue ( envelope ); + else + httpPost ( envelope ); +} + + +// ----- crash handlers ------------------------------------------------------ + +// First-chance vectored handler. Catches crash-class faults even when a downstream __except (the +// game's or D3D's) would swallow them before the unhandled filter runs. Reports at most once and +// posts asynchronously so it never blocks the game; the unhandled filter below is the reliable +// synchronous path when the fault actually terminates the process. +LONG CALLBACK vehHandler ( EXCEPTION_POINTERS *info ) +{ + if ( ! g.enabled || ! info || ! info->ExceptionRecord ) + return EXCEPTION_CONTINUE_SEARCH; + + if ( ! isFatalCode ( info->ExceptionRecord->ExceptionCode ) ) + return EXCEPTION_CONTINUE_SEARCH; + + // Ignore first-chance faults inside Windows system DLLs: they're handled internally (e.g. + // dxdiag/setupapi during device enumeration) and are not crashes in the game or our code. + if ( addrInSystemModule ( info->ExceptionRecord->ExceptionAddress ) ) + return EXCEPTION_CONTINUE_SEARCH; + + if ( InterlockedExchange ( &g.vehReported, 1 ) ) + return EXCEPTION_CONTINUE_SEARCH; + + const string code = hexCode ( info->ExceptionRecord->ExceptionCode ); + const string value = "First-chance fatal exception " + code + + " at " + hexPtr ( info->ExceptionRecord->ExceptionAddress ); + + diag ( "VEH caught " + value ); + captureCrash ( SentryClient::Level::Fatal, "VEH " + code, value, info, /* async */ true ); + + return EXCEPTION_CONTINUE_SEARCH; +} + +LONG WINAPI sehFilter ( EXCEPTION_POINTERS *info ) +{ + if ( g.enabled && info && info->ExceptionRecord && ! InterlockedExchange ( &g.inFilter, 1 ) ) + { + const string code = hexCode ( info->ExceptionRecord->ExceptionCode ); + const string value = "Unhandled SEH exception " + code + + " at " + hexPtr ( info->ExceptionRecord->ExceptionAddress ); + + diag ( "unhandled filter caught " + value ); + captureCrash ( SentryClient::Level::Fatal, "SEH " + code, value, info, /* async */ false ); + } + + // Preserve the host's normal crash behaviour. + return g.prevFilter ? g.prevFilter ( info ) : EXCEPTION_CONTINUE_SEARCH; +} + +void terminateHandler() +{ + string what = "Unhandled C++ exception"; + + try + { + exception_ptr e = current_exception(); + if ( e ) + rethrow_exception ( e ); + } + catch ( const exception& ex ) + { + what = string ( "Unhandled exception: " ) + ex.what(); + } + catch ( ... ) + { + } + + diag ( "terminate: " + what ); + captureCrash ( SentryClient::Level::Fatal, "terminate", what, 0, /* async */ false ); + + if ( g.prevTerminate ) + g.prevTerminate(); + else + abort(); +} + +void sigabrtHandler ( int sig ) +{ + diag ( "SIGABRT caught" ); + captureCrash ( SentryClient::Level::Fatal, "SIGABRT", "abort() called (assertion failure)", 0, + /* async */ false ); + + // Chain to whatever was installed before us so existing cleanup/exit still runs. + if ( g.prevSigabrt && g.prevSigabrt != SIG_DFL && g.prevSigabrt != SIG_IGN ) + { + g.prevSigabrt ( sig ); + } + else + { + signal ( SIGABRT, SIG_DFL ); + raise ( SIGABRT ); + } +} + +} // anonymous namespace + + +// ----- public API ---------------------------------------------------------- + +namespace SentryClient +{ + +void setLogSink ( LogSink sink ) +{ + g.logSink = sink; +} + +void init ( const string& dsn, const string& release, const string& environment, const string& dist ) +{ + if ( ! g.csReady ) + { + InitializeCriticalSection ( &g.cs ); + g.csReady = true; + } + + g.enabled = false; + + if ( dsn.empty() ) + { + diag ( "disabled: no DSN configured (build with -DSENTRY_DSN / SENTRY_DSN=...)" ); + return; + } + + // Parse: ://@[:]/ + const size_t schemeEnd = dsn.find ( "://" ); + if ( schemeEnd == string::npos ) + { + diag ( "disabled: malformed DSN (no scheme)" ); + return; + } + + g.https = ( dsn.compare ( 0, schemeEnd, "https" ) == 0 ); + + const string rest = dsn.substr ( schemeEnd + 3 ); + + const size_t at = rest.find ( '@' ); + if ( at == string::npos ) + { + diag ( "disabled: malformed DSN (no '@')" ); + return; + } + + const string publicKey = rest.substr ( 0, at ); + string hostPart = rest.substr ( at + 1 ); + + const size_t slash = hostPart.find ( '/' ); + if ( slash == string::npos ) + { + diag ( "disabled: malformed DSN (no project id)" ); + return; + } + + string projectId = hostPart.substr ( slash + 1 ); + const string hostPort = hostPart.substr ( 0, slash ); + + // projectId may have a trailing slash or query; keep just the leading number/segment. + const size_t projEnd = projectId.find_first_of ( "/?" ); + if ( projEnd != string::npos ) + projectId = projectId.substr ( 0, projEnd ); + + if ( publicKey.empty() || hostPort.empty() || projectId.empty() ) + { + diag ( "disabled: malformed DSN (empty key/host/project)" ); + return; + } + + const size_t colon = hostPort.find ( ':' ); + if ( colon != string::npos ) + { + g.host = hostPort.substr ( 0, colon ); + g.port = ( INTERNET_PORT ) atoi ( hostPort.substr ( colon + 1 ).c_str() ); + } + else + { + g.host = hostPort; + g.port = ( g.https ? 443 : 80 ); + } + + g.path = "/api/" + projectId + "/envelope/"; + g.dsn = dsn; + g.authHeader = "Sentry sentry_version=7, sentry_key=" + publicKey + + ", sentry_client=" SENTRY_CLIENT_NAME "/" SENTRY_CLIENT_VERSION; + + g.release = release; + g.environment = environment; + g.dist = dist; + + g.enabled = true; + + char portBuf[8]; + snprintf ( portBuf, sizeof ( portBuf ), "%u", ( unsigned ) g.port ); + diag ( "enabled: " + string ( g.https ? "https" : "http" ) + " host=" + g.host + ":" + portBuf + + " path=" + g.path + " release=" + g.release + " env=" + g.environment ); +} + +bool isEnabled() +{ + return g.enabled; +} + +void setTag ( const string& key, const string& value ) +{ + if ( ! g.enabled ) + return; + + EnterCriticalSection ( &g.cs ); + for ( auto& t : g.tags ) + { + if ( t.first == key ) + { + t.second = value; + LeaveCriticalSection ( &g.cs ); + return; + } + } + g.tags.push_back ( { key, value } ); + LeaveCriticalSection ( &g.cs ); +} + +void addBreadcrumb ( const string& message ) +{ + if ( ! g.enabled ) + return; + + EnterCriticalSection ( &g.cs ); + g.breadcrumbs.push_back ( { isoTimestamp(), message } ); + if ( g.breadcrumbs.size() > MAX_BREADCRUMBS ) + g.breadcrumbs.erase ( g.breadcrumbs.begin() ); + LeaveCriticalSection ( &g.cs ); +} + +void captureMessage ( Level level, const string& message, bool async ) +{ + if ( ! g.enabled ) + return; + + diag ( "captureMessage: " + message ); + + const string envelope = buildEnvelope ( level, "", message, 0, false ); + + if ( async ) + enqueue ( envelope ); + else + httpPost ( envelope ); +} + +void captureException ( const string& type, const string& value, bool async ) +{ + if ( ! g.enabled ) + return; + + diag ( "captureException: " + type + ": " + value ); + + const string envelope = buildEnvelope ( Level::Error, type, value, 0, false ); + + if ( async ) + enqueue ( envelope ); + else + httpPost ( envelope ); +} + +void installCrashHandler() +{ + if ( ! g.enabled ) + return; + + // DbgHelp: used to walk the faulting stack and resolve symbol names at crash time. + if ( ! g.symReady ) + { + SymSetOptions ( SYMOPT_DEFERRED_LOADS | SYMOPT_UNDNAME | SYMOPT_LOAD_LINES ); + if ( SymInitialize ( GetCurrentProcess(), 0, TRUE ) ) + g.symReady = true; + } + + g.vehHandle = AddVectoredExceptionHandler ( 1, vehHandler ); + g.prevFilter = SetUnhandledExceptionFilter ( sehFilter ); + g.prevTerminate = set_terminate ( terminateHandler ); + g.prevSigabrt = signal ( SIGABRT, sigabrtHandler ); + + diag ( "crash handlers installed (veh + unhandled filter + terminate + SIGABRT)" ); +} + +void reassertCrashHandler() +{ + if ( ! g.enabled ) + return; + + // Reclaim the top-level filter if something replaced it; only update the chain when the current + // top isn't already ours (else we'd chain to ourselves and recurse). + LPTOP_LEVEL_EXCEPTION_FILTER cur = SetUnhandledExceptionFilter ( sehFilter ); + if ( cur != sehFilter ) + { + g.prevFilter = cur; + diag ( "reasserted unhandled filter (it had been replaced)" ); + } +} + +} // namespace SentryClient diff --git a/lib/SentryClient.hpp b/lib/SentryClient.hpp new file mode 100644 index 00000000..a4e190e8 --- /dev/null +++ b/lib/SentryClient.hpp @@ -0,0 +1,75 @@ +#pragma once + +#include + + +// The DSN is baked in at build time via -DSENTRY_DSN (see the Makefile). Fall back to an empty +// string so any translation unit that includes this header still compiles if the define is absent +// (e.g. cppcheck, ad-hoc tool builds). An empty DSN leaves reporting disabled. +#ifndef SENTRY_DSN +#define SENTRY_DSN "" +#endif + + +// Lightweight Sentry / Glitchtip crash & error reporter. +// +// Self-contained: depends only on , and the C++ stdlib, so it can be linked +// into the standalone, the injected hook.dll, AND the dependency-light launcher.exe. It deliberately +// does NOT use LOG/ASSERT (would recurse through the crash path) or EventManager/Thread (uses raw +// Win32 threading so launcher.exe doesn't need to pull in winpthread). +// +// All entry points are cheap no-ops until init() is given a non-empty DSN, so call sites need no +// #ifdef guards. The DSN is baked in at build time via -DSENTRY_DSN (see the Makefile); an empty +// SENTRY_DSN leaves reporting disabled with effectively zero overhead. + +namespace SentryClient +{ + +enum class Level { Debug, Info, Warning, Error, Fatal }; + + +// Configure the client. Empty dsn => disabled (every other call becomes a no-op). +// dsn : Sentry DSN, e.g. https://@host[:port]/ +// release : maps to Sentry "release" (we pass the CCCaster version code) +// environment : maps to Sentry "environment" (we pass the build type: debug/logging/release) +// dist : optional build distinguisher (revision / build time) +void init ( const std::string& dsn, + const std::string& release, + const std::string& environment, + const std::string& dist = "" ); + +bool isEnabled(); + +// Route internal diagnostics (init result, handler install, crash caught, upload status) to this +// sink — e.g. the app's logger so they land in the debug/dll log. Diagnostics are ALSO always +// emitted via OutputDebugString. Set this before init() to capture the init diagnostics too. +typedef void ( *LogSink ) ( const char *message ); +void setLogSink ( LogSink sink ); + +// A tag attached to every subsequent event (e.g. "mode", "revision", "wine"). +void setTag ( const std::string& key, const std::string& value ); + +// Append a breadcrumb to the bounded ring buffer included with future events. +void addBreadcrumb ( const std::string& message ); + +// Capture a plain message event. When async, the upload is handed to a background worker thread so +// the caller never blocks (use this for mid-match captures in the hook.dll). +void captureMessage ( Level level, const std::string& message, bool async = false ); + +// Capture an exception-style event (shows in Sentry with a distinct type + value). +void captureException ( const std::string& type, const std::string& value, bool async = false ); + +// Install process-wide crash handlers: a vectored exception handler (catches faults even when a +// downstream __except — e.g. the game's or D3D's — would otherwise swallow them), the SEH +// unhandled-exception filter (access violations, etc. that actually terminate the process), +// std::terminate (unhandled C++ exceptions) and SIGABRT (abort()/failed ASSERT). Previous handlers +// are chained so the host's normal crash behaviour is preserved. Reports the faulting context + +// a best-effort backtrace. +void installCrashHandler(); + +// Re-assert the unhandled-exception filter. For the injected hook.dll: MBAA.exe / the D3D runtime +// can install their own filter during startup and bump ours off the top. Call this once the game +// is fully loaded. Safe to call repeatedly; preserves correct chaining. +void reassertCrashHandler(); + +} // namespace SentryClient diff --git a/lib/Socket.hpp b/lib/Socket.hpp index d0e9df26..b2fbf9b0 100644 --- a/lib/Socket.hpp +++ b/lib/Socket.hpp @@ -53,7 +53,7 @@ class Socket }; // Socket protocol - ENUM ( Protocol, TCP, UDP, Smart ); + ENUM ( Protocol, TCP, UDP, Smart, Steam ); // Connection state ENUM ( State, Listening, Connecting, Connected, Disconnected ); @@ -82,6 +82,7 @@ class Socket bool isTCP() const { return ( protocol == Protocol::TCP ); } bool isUDP() const { return ( protocol == Protocol::UDP ); } bool isSmart() const { return ( protocol == Protocol::Smart ); } + bool isSteam() const { return ( protocol == Protocol::Steam ); } bool gotGoodRead() const { return _gotGoodRead; } virtual State getState() const { return _state; } virtual bool isConnecting() const { return isClient() && ( _state == State::Connecting ); } diff --git a/lib/SteamLobby.cpp b/lib/SteamLobby.cpp new file mode 100644 index 00000000..ce894831 --- /dev/null +++ b/lib/SteamLobby.cpp @@ -0,0 +1,564 @@ +#ifdef ENABLE_STEAM + +#include "SteamLobby.hpp" +#include "SteamManager.hpp" +#include "SteamSocket.hpp" // steamAddr() +#include "LobbyQueue.hpp" +#include "Logger.hpp" + +#include // Sleep +#include +#include + +using namespace std; + + +// Bounded pump loop: ~5s like the standalone steam() UI used. There is no EventManager loop +// during the lobby menu, so we drive SteamManager's manual dispatch directly here. +#define SETTLE_TRIES ( 500 ) +#define SETTLE_SLEEP_MS ( 10 ) + + +// ---- king-of-the-hill queue keys ---- +// Lobby data (owner-written) broadcasts the current match assignment; member data (self-written) +// carries each player's ready flag and reported result. +static const char *Q_GEN_KEY = "q_gen"; // match generation counter +static const char *Q_STATE_KEY = "q_state"; // "PLAYING" / "ASSEMBLING" +static const char *Q_ORDER_KEY = "q_order"; // comma-separated SteamIDs, front..back +static const char *Q_HOST_KEY = "q_host"; // current king (host) SteamID +static const char *Q_CLIENT_KEY = "q_client"; // current challenger SteamID +static const char *READY_KEY = "q_ready"; // member: "1" if readied to play +static const char *R_GEN_KEY = "q_rgen"; // member: gen this reported result is for +static const char *R_WINNER_KEY = "q_rwinner"; // member: winning SteamID for R_GEN + + +static string joinIds ( const vector& ids ) +{ + string s; + for ( size_t i = 0; i < ids.size(); ++i ) + { + if ( i ) + s += ","; + char b[32]; + snprintf ( b, sizeof ( b ), "%llu", ( unsigned long long ) ids[i] ); + s += b; + } + return s; +} + +static vector splitIds ( const string& s ) +{ + vector out; + size_t i = 0; + while ( i < s.size() ) + { + size_t j = s.find ( ',', i ); + if ( j == string::npos ) + j = s.size(); + if ( j > i ) + { + const uint64_t v = strtoull ( s.substr ( i, j - i ).c_str(), nullptr, 10 ); + if ( v ) + out.push_back ( v ); + } + i = j + 1; + } + return out; +} + + +SteamLobby::SteamLobby ( ILobbyBackend::Owner* owner ) +{ + this->owner = owner; + mode = MENU; +} + +SteamLobby::~SteamLobby() +{ + SteamManager::get().leaveLobby(); + // Release the ref taken by the MainUi backend factory. Guarded no-op if MainApp already + // shut Steam down before launching the game (the match path). + SteamManager::get().deref(); +} + +void SteamLobby::start() +{ + // Steam was already ref()'d by the MainUi factory. Nothing async to start; the UI opens at + // the top-level lobby MENU. + mode = MENU; + connectionSuccess = true; +} + +bool SteamLobby::isRunning() +{ + // Steam may be shut down mid-session by MainApp when a match launches; that ends the lobby. + return SteamManager::get().isInitialized(); +} + +void SteamLobby::stop() +{ + // Drop out of the play queue before leaving so the owner doesn't keep us in the rotation. + if ( _ready ) + setReady ( false ); + SteamManager::get().leaveLobby(); +} + + +// ---- menu data (called by MainUi while it holds entryMutex; must NOT re-lock) ---- + +vector SteamLobby::getMenu() +{ + switch ( mode ) + { + case MENU: + // No "Default Lobby" entry for Steam (that relay-only mode has no Steam analog). + return { "Public Lobbies", "Create Lobby", "Enter Lobby Code" }; + case CONCERTO_BROWSE: + return publiclobbies; + case CONCERTO_LOBBY: + return _qs.rows; + default: + return {}; + } +} + +vector SteamLobby::getIps() +{ + if ( mode == CONCERTO_LOBBY ) + return lobbyips; + return {}; +} + +vector SteamLobby::getIds() +{ + return lobbyids; +} + + +// ---- browse / create / join ---- + +void SteamLobby::fetchPublicLobby() +{ + SteamManager::get().requestPublicLobbies(); + _pending = P_BROWSE; +} + +void SteamLobby::create ( string name, string type ) +{ + SteamManager::get().setLobbyMemberName ( name ); + SteamManager::get().createLobby ( type == "Public" ); + _pending = P_CREATE; +} + +string SteamLobby::join ( string name, int selection ) +{ + SteamManager& sm = SteamManager::get(); + const vector& lobbies = sm.publicLobbies(); + + string code; + if ( selection >= 0 && selection < ( int ) lobbies.size() ) + { + sm.setLobbyMemberName ( name ); + sm.joinLobbyById ( lobbies[selection].lobbyId ); + code = lobbies[selection].code; + _pending = P_JOIN; + } + return code; +} + +void SteamLobby::join ( string name, string code ) +{ + SteamManager::get().setLobbyMemberName ( name ); + SteamManager::get().joinLobbyByCode ( code ); + _pending = P_JOIN; +} + +bool SteamLobby::checkLobbyCode ( string code ) +{ + if ( code.size() < 2 || code.size() > 8 ) + return false; + for ( size_t i = 0; i < code.size(); ++i ) + if ( ! isalnum ( ( unsigned char ) code[i] ) ) + return false; + return true; +} + + +// ---- challenge handshake ---- + +void SteamLobby::challenge ( string target, IpAddrPort port ) +{ + SteamManager& sm = SteamManager::get(); + const uint64_t targetId = strtoull ( target.c_str(), nullptr, 10 ); + const uint64_t myId = sm.getSteamID(); + + // Advertise the challenge (also publishes our SteamID as host_id). + sm.setChallenge ( targetId ); + + // Let the peer's member-data arrive, then re-read for the simultaneous-challenge tie-break. + for ( int i = 0; i < 40; ++i ) + { + sm.pump(); + Sleep ( 5 ); + } + + bool peerChallengingMe = false; + for ( const SteamManager::MemberInfo& m : sm.lobbyMembers() ) + if ( m.steamId == targetId && m.challengingTarget == myId ) + peerChallengingMe = true; + + // Tie-break: if we both challenged each other, the LOWER SteamID hosts. The higher SteamID + // backs off (clears its challenge) and reconnects as Client - the peer's host row appears on + // the next refresh, so selecting it again takes the (now non-"None") client branch. + if ( peerChallengingMe && myId > targetId ) + { + sm.clearChallenge(); + hostSuccess = false; + refresh(); + return; + } + + hostSuccess = true; +} + +void SteamLobby::preaccept ( string id ) +{ + // Client side: nothing to negotiate over Steam - the caller connects directly to the host's + // SteamID (already resolved into the address by MainUi from the member's host row). +} + +void SteamLobby::accept() +{ + // Server-backed lobby notifies the relay that the connection is up; over Steam the P2P + // connection is direct, so there is nothing to confirm. +} + +void SteamLobby::unhost() +{ + SteamManager::get().clearChallenge(); + hostSuccess = false; +} + +void SteamLobby::end() +{ + // Stay in the lobby; just make sure our challenge advertisement is withdrawn. + SteamManager::get().clearChallenge(); +} + +void SteamLobby::host ( string name, IpAddrPort port ) +{ + // DEFAULT_LOBBY (relay-only peer list) has no Steam analog and is not reachable from the + // Steam menu (getMenu() omits it). No-op. +} + + +// ---- backend seam ---- + +void SteamLobby::refresh() +{ + SteamManager& sm = SteamManager::get(); + + // Process any pending lobby-data / chat callbacks so the member cache is current. + for ( int i = 0; i < 3; ++i ) + sm.pump(); + + if ( mode == CONCERTO_LOBBY ) + { + LOCK ( entryMutex ); + readQueueState(); + } + else if ( mode == CONCERTO_BROWSE ) + { + LOCK ( entryMutex ); + + publiclobbies.clear(); + roomcodes.clear(); + + for ( const SteamManager::LobbyInfo& li : sm.publicLobbies() ) + { + roomcodes.push_back ( li.code ); + publiclobbies.push_back ( li.code + " " + to_string ( li.memberCount ) ); + } + + numEntries = ( int ) publiclobbies.size(); + } +} + +void SteamLobby::pumpUntilSettled() +{ + SteamManager& sm = SteamManager::get(); + + if ( _pending == P_NONE ) + return; + + if ( _pending == P_BROWSE ) + { + for ( int i = 0; i < SETTLE_TRIES && sm.browseState() == SteamManager::LobbyState::Working; ++i ) + { + sm.pump(); + Sleep ( SETTLE_SLEEP_MS ); + } + } + else + { + for ( int i = 0; i < SETTLE_TRIES && sm.lobbyState() == SteamManager::LobbyState::Working; ++i ) + { + sm.pump(); + Sleep ( SETTLE_SLEEP_MS ); + } + } + + finishPending(); +} + +void SteamLobby::finishPending() +{ + SteamManager& sm = SteamManager::get(); + const Pending p = _pending; + _pending = P_NONE; + + switch ( p ) + { + case P_CREATE: + case P_JOIN: + if ( sm.lobbyState() == SteamManager::LobbyState::Ready ) + { + mode = CONCERTO_LOBBY; + lobbyMsg = sm.lobbyCode(); // host's code (empty for a joiner, which is fine) + refresh(); + } + else + { + lobbyError = "Failed"; + } + break; + + case P_BROWSE: + if ( sm.browseState() == SteamManager::LobbyState::Ready ) + refresh(); + break; + + default: + break; + } +} + + +// ---- king-of-the-hill queue ---- + +void SteamLobby::setReady ( bool ready ) +{ + _ready = ready; + SteamManager::get().setMyMemberData ( READY_KEY, ready ? "1" : "0" ); +} + +QueueState SteamLobby::getQueueState() +{ + // Called by MainUi while it holds entryMutex (like getMenu()); just return the cached snapshot. + return _qs; +} + +void SteamLobby::reportResult ( uint32_t gen, uint64_t winnerId ) +{ + SteamManager& sm = SteamManager::get(); + // Write the winner before the gen, so a reader that sees the new gen always sees a winner. + sm.setMyMemberData ( R_WINNER_KEY, to_string ( winnerId ) ); + sm.setMyMemberData ( R_GEN_KEY, to_string ( gen ) ); +} + +uint64_t SteamLobby::readResult ( uint32_t gen, uint64_t host, uint64_t client ) +{ + SteamManager& sm = SteamManager::get(); + const uint64_t players[2] = { host, client }; + for ( uint64_t pid : players ) + { + if ( ! pid ) + continue; + const uint32_t rgen = ( uint32_t ) strtoul ( sm.getMemberData ( pid, R_GEN_KEY ).c_str(), nullptr, 10 ); + if ( rgen == gen ) + { + const uint64_t w = strtoull ( sm.getMemberData ( pid, R_WINNER_KEY ).c_str(), nullptr, 10 ); + if ( w ) + return w; + } + } + return 0; +} + +void SteamLobby::publishMatchOrAssemble() +{ + SteamManager& sm = SteamManager::get(); + + if ( _queueOrder.size() >= 2 ) + { + ++_gen; + sm.setLobbyData ( Q_ORDER_KEY, joinIds ( _queueOrder ) ); + sm.setLobbyData ( Q_HOST_KEY, to_string ( _queueOrder[0] ) ); + sm.setLobbyData ( Q_CLIENT_KEY, to_string ( _queueOrder[1] ) ); + sm.setLobbyData ( Q_GEN_KEY, to_string ( _gen ) ); + sm.setLobbyData ( Q_STATE_KEY, "PLAYING" ); + LOG ( "Queue gen=%u host=%llu client=%llu", _gen, + ( unsigned long long ) _queueOrder[0], ( unsigned long long ) _queueOrder[1] ); + } + else + { + // Only (re)write when something changed, to avoid SetLobbyData rate-limit spam. + const string order = joinIds ( _queueOrder ); + if ( sm.getLobbyData ( Q_STATE_KEY ) != "ASSEMBLING" ) + sm.setLobbyData ( Q_STATE_KEY, "ASSEMBLING" ); + if ( sm.getLobbyData ( Q_ORDER_KEY ) != order ) + sm.setLobbyData ( Q_ORDER_KEY, order ); + } +} + +void SteamLobby::coordinatorTick() +{ + if ( mode != CONCERTO_LOBBY ) + return; + + SteamManager& sm = SteamManager::get(); + const uint64_t myId = sm.getSteamID(); + + // Only the lobby owner coordinates. SetLobbyData from anyone else is ignored by Steam anyway, + // and ownership migrates automatically if the owner leaves, so a successor seamlessly resumes. + if ( sm.lobbyOwnerId() != myId ) + return; + + // Snapshot members once; derive the ready set and a membership test from it. + const vector members = sm.lobbyMembers(); + vector ready; + for ( const SteamManager::MemberInfo& m : members ) + if ( sm.getMemberData ( m.steamId, READY_KEY ) == "1" ) + ready.push_back ( m.steamId ); + + auto isMember = [&] ( uint64_t id ) -> bool + { + for ( const SteamManager::MemberInfo& m : members ) + if ( m.steamId == id ) + return true; + return false; + }; + + // Adopt the last-published order on first run / after an ownership migration. + if ( _queueOrder.empty() ) + { + _queueOrder = splitIds ( sm.getLobbyData ( Q_ORDER_KEY ) ); + _gen = ( uint32_t ) strtoul ( sm.getLobbyData ( Q_GEN_KEY ).c_str(), nullptr, 10 ); + } + + if ( sm.getLobbyData ( Q_STATE_KEY ) == "PLAYING" ) + { + const uint32_t gen = ( uint32_t ) strtoul ( sm.getLobbyData ( Q_GEN_KEY ).c_str(), nullptr, 10 ); + const uint64_t host = strtoull ( sm.getLobbyData ( Q_HOST_KEY ).c_str(), nullptr, 10 ); + const uint64_t client = strtoull ( sm.getLobbyData ( Q_CLIENT_KEY ).c_str(), nullptr, 10 ); + + const uint64_t winner = readResult ( gen, host, client ); + if ( winner ) + { + // Normal case: a player reported the outcome. + _queueOrder = LobbyQueue::advance ( _queueOrder, host, client, winner, ready ); + publishMatchOrAssemble(); + } + else if ( ! isMember ( host ) && ! isMember ( client ) ) + { + // Both competitors have left the lobby (a live match keeps them as members, so this + // is a hard crash, not just a slow result). Drop the pair to the very back. + LOG ( "Queue gen=%u: both players left without a result; rotating", gen ); + const vector reconciled = LobbyQueue::reconcile ( _queueOrder, ready ); + vector head, tail; + for ( uint64_t id : reconciled ) + ( ( id == host || id == client ) ? tail : head ).push_back ( id ); + head.insert ( head.end(), tail.begin(), tail.end() ); + _queueOrder = head; + publishMatchOrAssemble(); + } + // Otherwise the match is still in progress (or one player crashed and the survivor's + // self-report will arrive next tick) -> wait. + } + else + { + // Assembling / between matches: keep the order reconciled and start once 2+ are ready. + _queueOrder = LobbyQueue::reconcile ( _queueOrder, ready ); + publishMatchOrAssemble(); + } + + // Reflect whatever we just published in our own snapshot this same tick, so an owner who is + // also a player starts its match without a one-tick lag. + LOCK ( entryMutex ); + readQueueState(); +} + +void SteamLobby::readQueueState() +{ + // Assumes entryMutex is held by the caller (refresh() / coordinatorTick()). + SteamManager& sm = SteamManager::get(); + const uint64_t myId = sm.getSteamID(); + const vector members = sm.lobbyMembers(); + + QueueState qs; + qs.gen = ( uint32_t ) strtoul ( sm.getLobbyData ( Q_GEN_KEY ).c_str(), nullptr, 10 ); + qs.phase = ( sm.getLobbyData ( Q_STATE_KEY ) == "PLAYING" ) ? QueuePhase::Playing + : QueuePhase::Assembling; + qs.hostId = strtoull ( sm.getLobbyData ( Q_HOST_KEY ).c_str(), nullptr, 10 ); + qs.clientId = strtoull ( sm.getLobbyData ( Q_CLIENT_KEY ).c_str(), nullptr, 10 ); + qs.iAmReady = _ready; + if ( qs.hostId ) + qs.hostAddr = IpAddrPort ( steamAddr ( qs.hostId ), ( uint16_t ) 0 ); + + // My role in the current match. Un-readied players stay in the menu (None); readied players who + // are not one of the two competitors auto-spectate. + if ( qs.phase == QueuePhase::Playing && qs.gen ) + { + if ( qs.hostId == myId ) + qs.myRole = QueueRole::Host; + else if ( qs.clientId == myId ) + qs.myRole = QueueRole::Client; + else if ( _ready ) + qs.myRole = QueueRole::Spectator; + else + qs.myRole = QueueRole::None; + } + + auto nameOf = [&] ( uint64_t id ) -> string + { + for ( const SteamManager::MemberInfo& m : members ) + if ( m.steamId == id ) + return m.name.empty() ? to_string ( id ) : m.name; + return to_string ( id ); + }; + + // Row 0 is the ready toggle (MainUi maps selection 0 -> setReady). The rest are informational. + qs.rows.clear(); + qs.rows.push_back ( _ready ? "[*] Ready - select here to leave the queue" + : "[ ] Ready Up to join the queue" ); + + const vector order = splitIds ( sm.getLobbyData ( Q_ORDER_KEY ) ); + int pos = 1; + for ( uint64_t id : order ) + { + string tag; + if ( qs.phase == QueuePhase::Playing && id == qs.hostId ) + tag = " [KING - playing]"; + else if ( qs.phase == QueuePhase::Playing && id == qs.clientId ) + tag = " [challenger - playing]"; + const string me = ( id == myId ) ? " (you)" : ""; + qs.rows.push_back ( to_string ( pos++ ) + ". " + nameOf ( id ) + me + tag ); + } + + // Members present but not in the play queue (un-readied watchers). + for ( const SteamManager::MemberInfo& m : members ) + { + if ( LobbyQueue::contains ( order, m.steamId ) ) + continue; + const string me = ( m.steamId == myId ) ? " (you)" : ""; + qs.rows.push_back ( "- " + ( m.name.empty() ? to_string ( m.steamId ) : m.name ) + me + " (watching)" ); + } + + if ( qs.phase != QueuePhase::Playing && order.size() < 2 ) + qs.rows.push_back ( "Waiting for players to ready up..." ); + + _qs = qs; + numEntries = ( int ) qs.rows.size(); +} + +#endif // ENABLE_STEAM diff --git a/lib/SteamLobby.hpp b/lib/SteamLobby.hpp new file mode 100644 index 00000000..a3973697 --- /dev/null +++ b/lib/SteamLobby.hpp @@ -0,0 +1,90 @@ +#pragma once + +#include "ILobbyBackend.hpp" + +#include +#include +#include + + +// Steam-backed lobby (implements ILobbyBackend). Backs the existing "Server -> Lobby" UI with +// Steam lobbies via SteamManager. Unlike the relay ServerLobby it has NO background thread and NO +// socket: it is pumped from the UI thread (refresh() on the periodic tick, pumpUntilSettled() at +// each async wait point). Only meaningful when built with ENABLE_STEAM (the .cpp body is guarded). +class SteamLobby : public ILobbyBackend +{ +public: + + SteamLobby ( ILobbyBackend::Owner* owner ); + ~SteamLobby(); + + // lifecycle + void start() override; + bool isRunning() override; + void stop() override; + + // menu data (per mode) + std::vector getMenu() override; + std::vector getIps() override; + std::vector getIds() override; + + // browse / create / join + void fetchPublicLobby() override; + void create ( std::string name, std::string type ) override; + std::string join ( std::string name, int selection ) override; + void join ( std::string name, std::string code ) override; + bool checkLobbyCode ( std::string code ) override; + + // challenge handshake + void challenge ( std::string target, IpAddrPort port ) override; + void preaccept ( std::string id ) override; + void accept() override; + void unhost() override; + void end() override; + void host ( std::string name, IpAddrPort port ) override; + + // backend seam + bool isSteam() const override { return true; } + bool needsEventManager() const override { return false; } + void refresh() override; + void pumpUntilSettled() override; + + // king-of-the-hill queue + bool supportsQueue() const override { return true; } + void setReady ( bool ready ) override; + QueueState getQueueState() override; + void reportResult ( uint32_t gen, uint64_t winnerId ) override; + void coordinatorTick() override; + +private: + + // Which async Steam op is in flight, so pumpUntilSettled() knows what to wait on + apply. + enum Pending { P_NONE, P_CREATE, P_JOIN, P_BROWSE }; + Pending _pending = P_NONE; + + // ---- queue state ---- + bool _ready = false; // our own ready flag (source of truth we advertise) + QueueState _qs; // cached snapshot, rebuilt in refresh() under entryMutex + + // Owner-only authoritative queue (front..back). Seeded from the published order on first tick + // / after ownership migration, then maintained in memory. + std::vector _queueOrder; + uint32_t _gen = 0; // last gen we (as owner) published + + // Rebuild _qs from the lobby + member data (called at the end of refresh() in CONCERTO_LOBBY). + void readQueueState(); + + // Owner helpers. + uint64_t readResult ( uint32_t gen, uint64_t host, uint64_t client ); + void publishMatchOrAssemble(); + + // Per-mode menu vectors (rebuilt in refresh(); read by getMenu/getIps/getIds while MainUi + // holds entryMutex, so those readers must not re-lock). + std::vector publiclobbies; // CONCERTO_BROWSE + std::vector roomcodes; // browse-index -> code + std::vector lobbyentries; // CONCERTO_LOBBY member display names + std::vector lobbyips; // CONCERTO_LOBBY: "None" or steam: + std::vector lobbyids; // CONCERTO_LOBBY member SteamID64 strings + + void finishPending(); +}; diff --git a/lib/SteamManager.cpp b/lib/SteamManager.cpp new file mode 100644 index 00000000..c3d0fb34 --- /dev/null +++ b/lib/SteamManager.cpp @@ -0,0 +1,874 @@ +#ifdef ENABLE_STEAM + +#include "SteamManager.hpp" +#include "SteamSocket.hpp" +#include "Logger.hpp" + +#include "steam/steam_api.h" +// Flat C API for ALL Steam interface calls. On 32-bit MinGW the C++ interface vtable + +// auto-callback (STEAM_CALLBACK/CCallResult) ABI does not match the MSVC-built steam_api.dll +// The flat API is plain extern "C", and we use MANUAL callback dispatch below instead of +// letting the DLL call into our C++ vtables. +// See the steamworks-mingw-flat-api note. +#include "steam/steam_api_flat.h" + +#include // Sleep + +#include +#include +#include +#include + +using namespace std; + + +// How often to pump dispatch + drain messages +#define PUMP_INTERVAL_MS ( 2 ) + +// Bounded wait for the SDR relay network in waitRelayNetworkReady(): ~5s total (500 * 10ms), +// matching SteamMatchmaking's gate. +#define RELAY_READY_TRIES ( 500 ) +#define RELAY_READY_SLEEP_MS ( 10 ) + +// Max messages drained per connection per pump (leftovers come next pump). +#define RECV_BATCH ( 32 ) + +// Lobby-level metadata keys. +static const char *LOBBY_CODE_KEY = "cccaster_code"; // shareable join code +static const char *LOBBY_HOST_KEY = "host_id"; // lobby owner's SteamID64 +static const char *LOBBY_TYPE_KEY = "cccaster_type"; // "lobby" (browsable) / "private" / "mm" +static const char *LOBBY_REGION_KEY = "cccaster_region"; // matchmaking region +static const char *LOBBY_STATE_KEY = "cccaster_state"; // matchmaking: "waiting" / "matched" + +// Per-member metadata keys. +static const char *MEMBER_NAME_KEY = "name"; // display name +static const char *MEMBER_CHAL_KEY = "challenging"; // SteamID64 this member is challenging +static const char *MEMBER_HOST_KEY = "host_id"; // challenger's own SteamID64 (Host side) + +// Route Steam diagnostics into our log file +static void S_CALLTYPE steamNetDebugOutput ( ESteamNetworkingSocketsDebugOutputType type, const char *msg ) +{ + LOG ( "[SteamNet] %s", msg ? msg : "" ); +} + +static void S_CALLTYPE steamWarningHook ( int severity, const char *msg ) +{ + LOG ( "[SteamWarn:%d] %s", severity, msg ? msg : "" ); +} + +// Human-shareable join code +static string makeLobbyCode() +{ + static bool seeded = false; + if ( ! seeded ) + { + srand ( ( unsigned ) time ( nullptr ) ); + seeded = true; + } + static const char alphabet[] = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + string s; + for ( int i = 0; i < 6; ++i ) + s += alphabet[rand() % ( sizeof ( alphabet ) - 1 )]; + return s; +} + + +SteamManager& SteamManager::get() +{ + static SteamManager instance; + return instance; +} + +bool SteamManager::ref() +{ + if ( _refCount > 0 ) + { + ++_refCount; + return _inited; + } + + if ( ! SteamAPI_Init() ) + { + LOG ( "SteamAPI_Init() failed (Steam not running, app not owned, or no steam_appid.txt)" ); + return false; + } + + _inited = true; + _refCount = 1; + + // poll callbacks from POD structs in pump() rather than + // having steam_api.dll call back into us + SteamAPI_ManualDispatch_Init(); + _pipe = SteamAPI_GetHSteamPipe(); + + // Redirect Steam's stdout into our log + SteamAPI_ISteamNetworkingUtils_SetDebugOutputFunction ( + SteamNetworkingUtils(), k_ESteamNetworkingSocketsDebugOutputType_Warning, steamNetDebugOutput ); + SteamAPI_ISteamUtils_SetWarningMessageHook ( SteamUtils(), steamWarningHook ); + + // Disable ECN for extra Wine compatibility + SteamAPI_ISteamNetworkingUtils_SetGlobalConfigValueInt32 ( + SteamNetworkingUtils(), k_ESteamNetworkingConfig_ECN, 0 ); + + SteamAPI_ISteamNetworkingUtils_InitRelayNetworkAccess ( SteamNetworkingUtils() ); + + LOG ( "Steam initialized; user=%llu", ( unsigned long long ) getSteamID() ); + + startPump(); + return true; +} + +void SteamManager::deref() +{ + if ( _refCount <= 0 ) + return; + + --_refCount; + + if ( _refCount > 0 ) + return; + + stopPump(); + + SteamAPI_Shutdown(); + _inited = false; + _pipe = 0; + _lobbyCreateCall = _lobbyListCall = _lobbyJoinCall = _browseListCall = 0; + _mmListCall = _mmCreateCall = _mmJoinCall = 0; + + LOG ( "Steam shut down" ); +} + +uint64_t SteamManager::getSteamID() const +{ + if ( ! _inited ) + return 0; + + return SteamAPI_ISteamUser_GetSteamID ( SteamUser() ); +} + +std::string SteamManager::getPersonaName() const +{ + if ( ! _inited ) + return ""; + + const char *n = SteamAPI_ISteamFriends_GetPersonaName ( SteamFriends() ); + return n ? n : ""; +} + +bool SteamManager::isRelayNetworkReady() const +{ + if ( ! _inited ) + return false; + + return ( SteamAPI_ISteamNetworkingUtils_GetRelayNetworkStatus ( SteamNetworkingUtils(), nullptr ) + == k_ESteamNetworkingAvailability_Current ); +} + +bool SteamManager::waitRelayNetworkReady() +{ + // Mirror the matchmaking gate (SteamMatchmaking::waitConnected): ~5s of manual pumping, then + // proceed regardless. After a fresh ref() the relay config is often cached and this returns + // almost immediately; only a cold fetch takes a moment. + for ( int i = 0; i < RELAY_READY_TRIES && ! isRelayNetworkReady(); ++i ) + { + pump(); + Sleep ( RELAY_READY_SLEEP_MS ); + } + + return isRelayNetworkReady(); +} + + +// ---- transport wrappers ---- + +uint32_t SteamManager::createListenSocketP2P ( int virtualPort ) +{ + if ( ! _inited ) + return 0; + + return SteamAPI_ISteamNetworkingSockets_CreateListenSocketP2P ( + SteamNetworkingSockets(), virtualPort, 0, nullptr ); +} + +void SteamManager::closeListenSocket ( uint32_t listenHandle ) +{ + if ( _inited && listenHandle ) + SteamAPI_ISteamNetworkingSockets_CloseListenSocket ( SteamNetworkingSockets(), listenHandle ); +} + +uint32_t SteamManager::connectP2P ( uint64_t peerSteamId, int virtualPort ) +{ + if ( ! _inited ) + return 0; + + SteamNetworkingIdentity identity; + identity.SetSteamID64 ( peerSteamId ); // inline, no DLL call + + return SteamAPI_ISteamNetworkingSockets_ConnectP2P ( + SteamNetworkingSockets(), identity, virtualPort, 0, nullptr ); +} + +bool SteamManager::acceptConnection ( uint32_t conn ) +{ + if ( ! _inited || ! conn ) + return false; + + return ( SteamAPI_ISteamNetworkingSockets_AcceptConnection ( SteamNetworkingSockets(), conn ) + == k_EResultOK ); +} + +void SteamManager::closeConnection ( uint32_t conn ) +{ + if ( _inited && conn ) + SteamAPI_ISteamNetworkingSockets_CloseConnection ( SteamNetworkingSockets(), conn, 0, nullptr, false ); +} + +bool SteamManager::sendMessage ( uint32_t conn, const void *data, size_t len, bool reliable ) +{ + if ( ! _inited || ! conn ) + return false; + + const int flags = ( reliable ? k_nSteamNetworkingSend_Reliable : k_nSteamNetworkingSend_Unreliable ); + + const EResult r = SteamAPI_ISteamNetworkingSockets_SendMessageToConnection ( + SteamNetworkingSockets(), conn, data, ( uint32 ) len, flags, nullptr ); + + return ( r == k_EResultOK ); +} + +uint64_t SteamManager::getConnectionPeer ( uint32_t conn ) const +{ + if ( ! _inited || ! conn ) + return 0; + + SteamNetConnectionInfo_t info; + if ( ! SteamAPI_ISteamNetworkingSockets_GetConnectionInfo ( SteamNetworkingSockets(), conn, &info ) ) + return 0; + + return info.m_identityRemote.GetSteamID64(); // inline accessor +} + + +// ---- registries ---- + +void SteamManager::registerConnection ( uint32_t conn, SteamSocket *socket ) +{ + if ( conn ) + _connections[conn] = socket; +} + +void SteamManager::unregisterConnection ( uint32_t conn ) +{ + _connections.erase ( conn ); +} + +void SteamManager::registerListen ( uint32_t listenHandle, SteamSocket *server ) +{ + if ( listenHandle ) + _listeners[listenHandle] = server; +} + +void SteamManager::unregisterListen ( uint32_t listenHandle ) +{ + _listeners.erase ( listenHandle ); +} + + +// ---- lobby ---- + +void SteamManager::tagLobby ( uint64_t lobbyId, const char *type ) +{ + char selfId[32]; + snprintf ( selfId, sizeof ( selfId ), "%llu", ( unsigned long long ) getSteamID() ); + + SteamAPI_ISteamMatchmaking_SetLobbyData ( SteamMatchmaking(), lobbyId, LOBBY_TYPE_KEY, type ); + SteamAPI_ISteamMatchmaking_SetLobbyData ( SteamMatchmaking(), lobbyId, LOBBY_CODE_KEY, _lobbyCode.c_str() ); + SteamAPI_ISteamMatchmaking_SetLobbyData ( SteamMatchmaking(), lobbyId, LOBBY_HOST_KEY, selfId ); + + if ( ! _memberName.empty() ) + SteamAPI_ISteamMatchmaking_SetLobbyMemberData ( SteamMatchmaking(), lobbyId, MEMBER_NAME_KEY, + _memberName.c_str() ); +} + +void SteamManager::createLobby ( bool isPublic ) +{ + if ( ! _inited ) + { + _lobbyState = LobbyState::Failed; + return; + } + + _lobbyIsHost = true; + _lobbyIsPublic = isPublic; + _lobbyState = LobbyState::Working; + _lobbyCode.clear(); + _lobbyId = 0; + _myChallenge = 0; + + // Both public and "private" lobbies are Steam-level public so that exact-code RequestLobbyList + // can find them; privacy is enforced by the cccaster_type tag (browse only asks for "lobby"). + _lobbyCreateCall = SteamAPI_ISteamMatchmaking_CreateLobby ( SteamMatchmaking(), k_ELobbyTypePublic, 8 ); +} + +void SteamManager::onLobbyCreatedResult ( bool ok, uint64_t lobbyId ) +{ + if ( ! ok ) + { + LOG ( "CreateLobby failed" ); + _lobbyState = LobbyState::Failed; + return; + } + + _lobbyId = lobbyId; + + if ( _lobbyCode.empty() ) + _lobbyCode = makeLobbyCode(); + + tagLobby ( lobbyId, _lobbyIsPublic ? "lobby" : "private" ); + + _lobbyState = LobbyState::Ready; + LOG ( "Lobby ready; code=%s public=%d", _lobbyCode.c_str(), ( int ) _lobbyIsPublic ); +} + +void SteamManager::joinLobbyByCode ( const std::string& code ) +{ + if ( ! _inited ) + { + _lobbyState = LobbyState::Failed; + return; + } + + _lobbyIsHost = false; + _joinCode = code; + _lobbyPeerId = 0; + _lobbyId = 0; + _myChallenge = 0; + _lobbyState = LobbyState::Working; + + SteamAPI_ISteamMatchmaking_AddRequestLobbyListStringFilter ( + SteamMatchmaking(), LOBBY_CODE_KEY, code.c_str(), k_ELobbyComparisonEqual ); + SteamAPI_ISteamMatchmaking_AddRequestLobbyListDistanceFilter ( + SteamMatchmaking(), k_ELobbyDistanceFilterWorldwide ); + + _lobbyListCall = SteamAPI_ISteamMatchmaking_RequestLobbyList ( SteamMatchmaking() ); +} + +void SteamManager::onLobbyListResult ( bool found, uint64_t lobbyId ) +{ + if ( ! found ) + { + LOG ( "No lobby found for code '%s'", _joinCode.c_str() ); + _lobbyState = LobbyState::Failed; + return; + } + + // Found the lobby advertising the code; join it for membership (the lobby UI lists members + // and issues challenges; it does not connect P2P directly here). + joinLobbyById ( lobbyId ); +} + +void SteamManager::joinLobbyById ( uint64_t lobbyId ) +{ + if ( ! _inited || ! lobbyId ) + { + _lobbyState = LobbyState::Failed; + return; + } + + _lobbyIsHost = false; + _myChallenge = 0; + _lobbyState = LobbyState::Working; + + _lobbyJoinCall = SteamAPI_ISteamMatchmaking_JoinLobby ( SteamMatchmaking(), lobbyId ); +} + +void SteamManager::onLobbyEntered ( uint64_t lobbyId, bool ok ) +{ + if ( ! ok ) + { + LOG ( "JoinLobby failed" ); + _lobbyState = LobbyState::Failed; + return; + } + + _lobbyId = lobbyId; + + if ( ! _memberName.empty() ) + SteamAPI_ISteamMatchmaking_SetLobbyMemberData ( SteamMatchmaking(), lobbyId, MEMBER_NAME_KEY, + _memberName.c_str() ); + + _lobbyState = LobbyState::Ready; + LOG ( "Entered lobby %llu", ( unsigned long long ) lobbyId ); +} + +void SteamManager::requestPublicLobbies() +{ + if ( ! _inited ) + { + _browseState = LobbyState::Failed; + return; + } + + _browseState = LobbyState::Working; + _publicLobbies.clear(); + + SteamAPI_ISteamMatchmaking_AddRequestLobbyListStringFilter ( + SteamMatchmaking(), LOBBY_TYPE_KEY, "lobby", k_ELobbyComparisonEqual ); + SteamAPI_ISteamMatchmaking_AddRequestLobbyListDistanceFilter ( + SteamMatchmaking(), k_ELobbyDistanceFilterWorldwide ); + + _browseListCall = SteamAPI_ISteamMatchmaking_RequestLobbyList ( SteamMatchmaking() ); +} + +void SteamManager::onBrowseListResult ( int count ) +{ + _publicLobbies.clear(); + + for ( int i = 0; i < count; ++i ) + { + const uint64_t id = SteamAPI_ISteamMatchmaking_GetLobbyByIndex ( SteamMatchmaking(), i ); + const char *code = SteamAPI_ISteamMatchmaking_GetLobbyData ( SteamMatchmaking(), id, LOBBY_CODE_KEY ); + const int cnt = SteamAPI_ISteamMatchmaking_GetNumLobbyMembers ( SteamMatchmaking(), id ); + _publicLobbies.push_back ( { id, code ? code : "", cnt } ); + } + + _browseState = LobbyState::Ready; + LOG ( "Browse: %d public lobbies", ( int ) _publicLobbies.size() ); +} + +uint64_t SteamManager::lobbyOwnerId() const +{ + if ( ! _inited || ! _lobbyId ) + return 0; + + return SteamAPI_ISteamMatchmaking_GetLobbyOwner ( SteamMatchmaking(), _lobbyId ); +} + +std::vector SteamManager::lobbyMembers() +{ + std::vector out; + + if ( ! _inited || ! _lobbyId ) + return out; + + const int n = SteamAPI_ISteamMatchmaking_GetNumLobbyMembers ( SteamMatchmaking(), _lobbyId ); + + for ( int i = 0; i < n; ++i ) + { + const uint64_t mid = SteamAPI_ISteamMatchmaking_GetLobbyMemberByIndex ( SteamMatchmaking(), _lobbyId, i ); + + const char *nm = SteamAPI_ISteamMatchmaking_GetLobbyMemberData ( SteamMatchmaking(), _lobbyId, mid, MEMBER_NAME_KEY ); + const char *chl = SteamAPI_ISteamMatchmaking_GetLobbyMemberData ( SteamMatchmaking(), _lobbyId, mid, MEMBER_CHAL_KEY ); + const char *hid = SteamAPI_ISteamMatchmaking_GetLobbyMemberData ( SteamMatchmaking(), _lobbyId, mid, MEMBER_HOST_KEY ); + + std::string name = ( nm && nm[0] ) ? nm : ""; + if ( name.empty() ) + { + const char *pn = SteamAPI_ISteamFriends_GetFriendPersonaName ( SteamFriends(), mid ); + name = ( pn ? pn : "" ); + } + + const uint64_t chalTarget = ( chl && chl[0] ) ? strtoull ( chl, nullptr, 10 ) : 0; + const uint64_t hostId = ( hid && hid[0] ) ? strtoull ( hid, nullptr, 10 ) : 0; + + out.push_back ( { mid, name, chalTarget, hostId } ); + } + + return out; +} + +void SteamManager::setLobbyMemberName ( const std::string& name ) +{ + _memberName = name; + + if ( _inited && _lobbyId ) + SteamAPI_ISteamMatchmaking_SetLobbyMemberData ( SteamMatchmaking(), _lobbyId, MEMBER_NAME_KEY, name.c_str() ); +} + +void SteamManager::setLobbyData ( const std::string& key, const std::string& value ) +{ + // No-op for non-owners: Steam ignores SetLobbyData unless we own the lobby. + if ( _inited && _lobbyId ) + SteamAPI_ISteamMatchmaking_SetLobbyData ( SteamMatchmaking(), _lobbyId, key.c_str(), value.c_str() ); +} + +std::string SteamManager::getLobbyData ( const std::string& key ) +{ + if ( ! _inited || ! _lobbyId ) + return ""; + + const char *v = SteamAPI_ISteamMatchmaking_GetLobbyData ( SteamMatchmaking(), _lobbyId, key.c_str() ); + return v ? v : ""; +} + +void SteamManager::setMyMemberData ( const std::string& key, const std::string& value ) +{ + if ( _inited && _lobbyId ) + SteamAPI_ISteamMatchmaking_SetLobbyMemberData ( SteamMatchmaking(), _lobbyId, key.c_str(), value.c_str() ); +} + +std::string SteamManager::getMemberData ( uint64_t memberId, const std::string& key ) +{ + if ( ! _inited || ! _lobbyId ) + return ""; + + const char *v = SteamAPI_ISteamMatchmaking_GetLobbyMemberData ( SteamMatchmaking(), _lobbyId, memberId, key.c_str() ); + return v ? v : ""; +} + +void SteamManager::setChallenge ( uint64_t targetId ) +{ + if ( ! _inited || ! _lobbyId ) + return; + + _myChallenge = targetId; + + char target[32], self[32]; + snprintf ( target, sizeof ( target ), "%llu", ( unsigned long long ) targetId ); + snprintf ( self, sizeof ( self ), "%llu", ( unsigned long long ) getSteamID() ); + + SteamAPI_ISteamMatchmaking_SetLobbyMemberData ( SteamMatchmaking(), _lobbyId, MEMBER_CHAL_KEY, target ); + SteamAPI_ISteamMatchmaking_SetLobbyMemberData ( SteamMatchmaking(), _lobbyId, MEMBER_HOST_KEY, self ); +} + +void SteamManager::clearChallenge() +{ + _myChallenge = 0; + + if ( ! _inited || ! _lobbyId ) + return; + + SteamAPI_ISteamMatchmaking_SetLobbyMemberData ( SteamMatchmaking(), _lobbyId, MEMBER_CHAL_KEY, "" ); + SteamAPI_ISteamMatchmaking_SetLobbyMemberData ( SteamMatchmaking(), _lobbyId, MEMBER_HOST_KEY, "" ); +} + +void SteamManager::leaveLobby() +{ + if ( _inited && _lobbyId ) + SteamAPI_ISteamMatchmaking_LeaveLobby ( SteamMatchmaking(), _lobbyId ); + + _lobbyId = 0; + _lobbyPeerId = 0; + _lobbyCode.clear(); + _lobbyState = LobbyState::Idle; + _browseState = LobbyState::Idle; + _publicLobbies.clear(); + _myChallenge = 0; +} + + +// ---- region matchmaking (create-or-join) ---- + +void SteamManager::matchmakeRegion ( const std::string& region ) +{ + if ( ! _inited ) + { + _mmState = MMState::Failed; + return; + } + + _mmRegion = region; + _mmPeerId = 0; + _mmState = MMState::Working; + _lobbyId = 0; + + SteamAPI_ISteamMatchmaking_AddRequestLobbyListStringFilter ( + SteamMatchmaking(), LOBBY_TYPE_KEY, "mm", k_ELobbyComparisonEqual ); + SteamAPI_ISteamMatchmaking_AddRequestLobbyListStringFilter ( + SteamMatchmaking(), LOBBY_REGION_KEY, region.c_str(), k_ELobbyComparisonEqual ); + SteamAPI_ISteamMatchmaking_AddRequestLobbyListStringFilter ( + SteamMatchmaking(), LOBBY_STATE_KEY, "waiting", k_ELobbyComparisonEqual ); + SteamAPI_ISteamMatchmaking_AddRequestLobbyListResultCountFilter ( SteamMatchmaking(), 1 ); + + _mmListCall = SteamAPI_ISteamMatchmaking_RequestLobbyList ( SteamMatchmaking() ); +} + +void SteamManager::onMatchmakeListResult ( int count ) +{ + if ( count > 0 ) + { + // A peer is already waiting in this region; join as Client. + const uint64_t id = SteamAPI_ISteamMatchmaking_GetLobbyByIndex ( SteamMatchmaking(), 0 ); + _mmJoinCall = SteamAPI_ISteamMatchmaking_JoinLobby ( SteamMatchmaking(), id ); + } + else + { + // Nobody waiting; create a waiting lobby and become Host. + _mmCreateCall = SteamAPI_ISteamMatchmaking_CreateLobby ( SteamMatchmaking(), k_ELobbyTypePublic, 2 ); + } +} + +void SteamManager::onMatchmakeCreated ( bool ok, uint64_t lobbyId ) +{ + if ( ! ok ) + { + _mmState = MMState::Failed; + return; + } + + _lobbyId = lobbyId; + + char selfId[32]; + snprintf ( selfId, sizeof ( selfId ), "%llu", ( unsigned long long ) getSteamID() ); + + SteamAPI_ISteamMatchmaking_SetLobbyData ( SteamMatchmaking(), lobbyId, LOBBY_TYPE_KEY, "mm" ); + SteamAPI_ISteamMatchmaking_SetLobbyData ( SteamMatchmaking(), lobbyId, LOBBY_REGION_KEY, _mmRegion.c_str() ); + SteamAPI_ISteamMatchmaking_SetLobbyData ( SteamMatchmaking(), lobbyId, LOBBY_STATE_KEY, "waiting" ); + SteamAPI_ISteamMatchmaking_SetLobbyData ( SteamMatchmaking(), lobbyId, LOBBY_HOST_KEY, selfId ); + + // Stay Working; a joining peer triggers onMemberJoined -> BecameHost. + LOG ( "Matchmaking: created waiting lobby in region %s", _mmRegion.c_str() ); +} + +void SteamManager::onMatchmakeEntered ( uint64_t lobbyId, bool ok ) +{ + if ( ! ok ) + { + _mmState = MMState::Failed; + return; + } + + _lobbyId = lobbyId; + _mmPeerId = SteamAPI_ISteamMatchmaking_GetLobbyOwner ( SteamMatchmaking(), lobbyId ); + + if ( _mmPeerId == 0 ) + { + _mmState = MMState::Failed; + return; + } + + _mmState = MMState::BecameClient; + LOG ( "Matchmaking: joined region %s; host=%llu", _mmRegion.c_str(), ( unsigned long long ) _mmPeerId ); +} + +void SteamManager::onMemberJoined ( uint64_t lobbyId, uint64_t memberId ) +{ + if ( lobbyId != _lobbyId ) + return; + + // Matchmaking host: a peer joined our waiting lobby -> we are matched as Host. + if ( _mmState == MMState::Working && memberId != 0 && memberId != getSteamID() ) + { + _mmPeerId = memberId; + SteamAPI_ISteamMatchmaking_SetLobbyData ( SteamMatchmaking(), lobbyId, LOBBY_STATE_KEY, "matched" ); + _mmState = MMState::BecameHost; + LOG ( "Matchmaking: peer %llu joined; we host", ( unsigned long long ) memberId ); + } +} + +void SteamManager::cancelMatchmaking() +{ + if ( _inited && _lobbyId ) + SteamAPI_ISteamMatchmaking_LeaveLobby ( SteamMatchmaking(), _lobbyId ); + + _lobbyId = 0; + _mmState = MMState::Idle; + _mmPeerId = 0; +} + + +// ---- callback routing ---- + +void SteamManager::onConnectionStatusChanged ( uint32_t conn, uint32_t listenHandle, + int newState, uint64_t peerSteamId ) +{ + switch ( newState ) + { + case k_ESteamNetworkingConnectionState_Connecting: + { + if ( listenHandle ) + { + const auto it = _listeners.find ( listenHandle ); + if ( it != _listeners.end() ) + it->second->onIncomingConnection ( conn, peerSteamId ); + else + closeConnection ( conn ); + } + break; + } + + case k_ESteamNetworkingConnectionState_Connected: + { + const auto it = _connections.find ( conn ); + if ( it != _connections.end() ) + it->second->onConnected(); + break; + } + + case k_ESteamNetworkingConnectionState_ClosedByPeer: + case k_ESteamNetworkingConnectionState_ProblemDetectedLocally: + { + const auto it = _connections.find ( conn ); + if ( it != _connections.end() ) + it->second->onClosed(); + else + closeConnection ( conn ); + break; + } + + default: + break; + } +} + + +// ---- pump (manual dispatch) ---- + +void SteamManager::startPump() +{ + if ( ! _pumpTimer ) + _pumpTimer.reset ( new Timer ( this ) ); + _pumpTimer->start ( PUMP_INTERVAL_MS ); +} + +void SteamManager::stopPump() +{ + _pumpTimer.reset(); +} + +void SteamManager::timerExpired ( Timer *timer ) +{ + if ( timer != _pumpTimer.get() ) + return; + + pump(); + + if ( _pumpTimer ) + _pumpTimer->start ( PUMP_INTERVAL_MS ); +} + +void SteamManager::pump() +{ + if ( ! _inited ) + return; + + // --- manual callback dispatch --- + SteamAPI_ManualDispatch_RunFrame ( _pipe ); + + CallbackMsg_t cb; + while ( SteamAPI_ManualDispatch_GetNextCallback ( _pipe, &cb ) ) + { + if ( cb.m_iCallback == SteamAPICallCompleted_t::k_iCallback ) + { + // Async call result (CreateLobby / RequestLobbyList / JoinLobby), matched by call id. + const SteamAPICallCompleted_t *cc = ( const SteamAPICallCompleted_t * ) cb.m_pubParam; + void *result = malloc ( cc->m_cubParam ); + bool failed = false; + + if ( result && SteamAPI_ManualDispatch_GetAPICallResult ( + _pipe, cc->m_hAsyncCall, result, cc->m_cubParam, cc->m_iCallback, &failed ) ) + { + const uint64_t call = cc->m_hAsyncCall; + + if ( _lobbyCreateCall && call == _lobbyCreateCall ) + { + const LobbyCreated_t *r = ( const LobbyCreated_t * ) result; + _lobbyCreateCall = 0; + onLobbyCreatedResult ( ( ! failed && r->m_eResult == k_EResultOK ), r->m_ulSteamIDLobby ); + } + else if ( _lobbyListCall && call == _lobbyListCall ) + { + const LobbyMatchList_t *r = ( const LobbyMatchList_t * ) result; + _lobbyListCall = 0; + const bool found = ( ! failed && r->m_nLobbiesMatching > 0 ); + const uint64_t id = + found ? SteamAPI_ISteamMatchmaking_GetLobbyByIndex ( SteamMatchmaking(), 0 ) : 0; + onLobbyListResult ( found, id ); + } + else if ( _browseListCall && call == _browseListCall ) + { + const LobbyMatchList_t *r = ( const LobbyMatchList_t * ) result; + _browseListCall = 0; + onBrowseListResult ( failed ? 0 : ( int ) r->m_nLobbiesMatching ); + } + else if ( _lobbyJoinCall && call == _lobbyJoinCall ) + { + const LobbyEnter_t *r = ( const LobbyEnter_t * ) result; + _lobbyJoinCall = 0; + onLobbyEntered ( r->m_ulSteamIDLobby, + ( ! failed && r->m_EChatRoomEnterResponse == k_EChatRoomEnterResponseSuccess ) ); + } + else if ( _mmListCall && call == _mmListCall ) + { + const LobbyMatchList_t *r = ( const LobbyMatchList_t * ) result; + _mmListCall = 0; + onMatchmakeListResult ( failed ? 0 : ( int ) r->m_nLobbiesMatching ); + } + else if ( _mmCreateCall && call == _mmCreateCall ) + { + const LobbyCreated_t *r = ( const LobbyCreated_t * ) result; + _mmCreateCall = 0; + onMatchmakeCreated ( ( ! failed && r->m_eResult == k_EResultOK ), r->m_ulSteamIDLobby ); + } + else if ( _mmJoinCall && call == _mmJoinCall ) + { + const LobbyEnter_t *r = ( const LobbyEnter_t * ) result; + _mmJoinCall = 0; + onMatchmakeEntered ( r->m_ulSteamIDLobby, + ( ! failed && r->m_EChatRoomEnterResponse == k_EChatRoomEnterResponseSuccess ) ); + } + } + + free ( result ); + } + else if ( cb.m_iCallback == SteamNetConnectionStatusChangedCallback_t::k_iCallback ) + { + const SteamNetConnectionStatusChangedCallback_t *p = + ( const SteamNetConnectionStatusChangedCallback_t * ) cb.m_pubParam; + onConnectionStatusChanged ( p->m_hConn, p->m_info.m_hListenSocket, + ( int ) p->m_info.m_eState, + p->m_info.m_identityRemote.GetSteamID64() ); + } + else if ( cb.m_iCallback == LobbyChatUpdate_t::k_iCallback ) + { + // Member entered/left the lobby. Used to detect a peer joining a matchmaking lobby + // (host side); the lobby member list re-reads on the UI's periodic refresh anyway. + const LobbyChatUpdate_t *p = ( const LobbyChatUpdate_t * ) cb.m_pubParam; + if ( p->m_rgfChatMemberStateChange & k_EChatMemberStateChangeEntered ) + onMemberJoined ( p->m_ulSteamIDLobby, p->m_ulSteamIDUserChanged ); + } + // LobbyDataUpdate_t / LobbyChatMsg_t: Steam keeps the member-data cache current + // automatically, so the periodic lobbyMembers() refresh sees the latest values; no + // explicit handling needed. + + SteamAPI_ManualDispatch_FreeLastCallback ( _pipe ); + } + + // --- drain inbound messages --- + vector conns; + conns.reserve ( _connections.size() ); + for ( const auto& kv : _connections ) + conns.push_back ( kv.first ); + + for ( uint32_t conn : conns ) + { + if ( _connections.find ( conn ) == _connections.end() ) + continue; + + SteamNetworkingMessage_t *msgs[RECV_BATCH]; + const int n = SteamAPI_ISteamNetworkingSockets_ReceiveMessagesOnConnection ( + SteamNetworkingSockets(), conn, msgs, RECV_BATCH ); + + vector decoded; + decoded.reserve ( n > 0 ? n : 0 ); + for ( int i = 0; i < n; ++i ) + { + size_t consumed = 0; + MsgPtr msg = ::Protocol::decode ( ( const char * ) msgs[i]->m_pData, msgs[i]->m_cbSize, consumed ); + msgs[i]->Release(); // frees via the message's own fn ptr + if ( msg.get() ) + decoded.push_back ( msg ); + } + + for ( const MsgPtr& msg : decoded ) + { + const auto it = _connections.find ( conn ); + if ( it == _connections.end() ) + break; + it->second->onReceive ( msg ); + } + } +} + +#endif // ENABLE_STEAM diff --git a/lib/SteamManager.hpp b/lib/SteamManager.hpp new file mode 100644 index 00000000..6a2403c0 --- /dev/null +++ b/lib/SteamManager.hpp @@ -0,0 +1,218 @@ +#pragma once + +#ifdef ENABLE_STEAM + +#include "Timer.hpp" +#include "Protocol.hpp" + +#include +#include +#include +#include + + +class SteamSocket; + + +// Owns the Steamworks API lifecycle and is the ONLY translation unit that includes the +// Steam SDK headers. Everything Steam-related is wrapped behind plain-typed functions +// (uint32_t connection/listen handles, uint64_t SteamIDs) so the rest of CCCaster never +// pulls in Steam headers. Calls that pass/return CSteamID use the flat C API to avoid the +// MinGW<->MSVC ABI mismatch (see the steamworks-mingw-flat-api note). +// +// A self-driven repeating Timer pumps Steam's manual callback dispatch + drains incoming +// messages, so SteamSocket does not need to register with SocketManager (which select()s on an fd). +class SteamManager : private Timer::Owner +{ +public: + + static SteamManager& get(); + + // Ref-counted SteamAPI init. ref() returns false if Steam is unavailable (not running, + // app 411370 not owned, or no steam_appid.txt). Safe to call repeatedly. + bool ref(); + void deref(); + + bool isInitialized() const { return _inited; } + + // Local user's SteamID64 (0 if not initialized). + uint64_t getSteamID() const; + + // Local user's Steam persona (display) name; "" if not initialized. + std::string getPersonaName() const; + + // Whether the SDR relay network is ready (ping data current). ConnectP2P before this is + // ready tends to fail, so callers should gate on it. + bool isRelayNetworkReady() const; + + // Pump until the SDR relay network is ready (so a subsequent ConnectP2P / listen succeeds), + // or until a bounded timeout (~5s) elapses. Cheap no-op when already ready. Returns the final + // readiness. Safe to call directly in a wait loop (no EventManager needed). Do NOT call from + // inside a Steam callback (e.g. SteamSocket::onClosed) - it pumps, which would be re-entrant. + bool waitRelayNetworkReady(); + + // ---- transport wrappers (all handles are plain ints; 0 == invalid) ---- + + // Host: open a P2P listen socket on a virtual port. Returns the listen handle. + uint32_t createListenSocketP2P ( int virtualPort ); + void closeListenSocket ( uint32_t listenHandle ); + + // Client: begin a P2P connection to a peer SteamID on a virtual port. Returns conn handle. + uint32_t connectP2P ( uint64_t peerSteamId, int virtualPort ); + + bool acceptConnection ( uint32_t conn ); + void closeConnection ( uint32_t conn ); + + // Send already-encoded bytes over a connection. reliable picks the Steam send flag. + bool sendMessage ( uint32_t conn, const void *data, size_t len, bool reliable ); + + // Peer SteamID for an established connection (0 if unknown). + uint64_t getConnectionPeer ( uint32_t conn ) const; + + // ---- lobby / matchmaking (driven by the launcher UI) ---- + // Asynchronous; poll the relevant *State() until Ready/Failed. SteamManager's pump runs the + // underlying Steam callbacks, so the caller just needs to keep pumping the event loop. + enum class LobbyState { Idle, Working, Ready, Failed }; + + // Create a lobby and advertise a short join code + our SteamID. isPublic lobbies are returned + // by requestPublicLobbies(); non-public ("private") lobbies are excluded from browse but are + // still joinable by exact code. On Ready, lobbyCode() returns the code to share. + void createLobby ( bool isPublic ); + + // Client: find the lobby advertising the given code and JOIN it (membership). On Ready, + // joinedLobbyId() is valid and lobbyMembers() lists the occupants. + void joinLobbyByCode ( const std::string& code ); + + // Join a specific lobby by id (e.g. one picked from the public browse list). + void joinLobbyById ( uint64_t lobbyId ); + + void leaveLobby(); + + LobbyState lobbyState() const { return _lobbyState; } + const std::string& lobbyCode() const { return _lobbyCode; } + uint64_t lobbyPeerId() const { return _lobbyPeerId; } + uint64_t joinedLobbyId() const { return _lobbyId; } + uint64_t lobbyOwnerId() const; + + // ---- public lobby browse ---- + struct LobbyInfo { uint64_t lobbyId; std::string code; int memberCount; }; + + void requestPublicLobbies(); + LobbyState browseState() const { return _browseState; } + const std::vector& publicLobbies() const { return _publicLobbies; } + + // ---- members of the joined lobby ---- + struct MemberInfo { uint64_t steamId; std::string name; uint64_t challengingTarget; uint64_t hostId; }; + + // Re-enumerate the current lobby's members (cheap; reads Steam's cached member data). + std::vector lobbyMembers(); + + // Advertise our display name to the lobby (so peers can list us by name). + void setLobbyMemberName ( const std::string& name ); + + // ---- generic lobby / member data (used by the king-of-the-hill queue) ---- + // Lobby data is single-writer: Steam silently ignores SetLobbyData from non-owners, so the + // lobby owner is the queue's authoritative coordinator. Member data is self-writable only. + void setLobbyData ( const std::string& key, const std::string& value ); + std::string getLobbyData ( const std::string& key ); + void setMyMemberData ( const std::string& key, const std::string& value ); + std::string getMemberData ( uint64_t memberId, const std::string& key ); + + // ---- challenge handshake (member-data based, no relay) ---- + // Challenger advertises {challenging:, host_id:}; the target sees it on its next + // member refresh and connects as Client to our SteamID. clearChallenge() withdraws it. + void setChallenge ( uint64_t targetId ); + void clearChallenge(); + uint64_t myChallengeTarget() const { return _myChallenge; } + + // ---- region matchmaking (create-or-join) ---- + enum class MMState { Idle, Working, BecameHost, BecameClient, Failed }; + + void matchmakeRegion ( const std::string& region ); + void cancelMatchmaking(); + MMState mmState() const { return _mmState; } + uint64_t mmPeerId() const { return _mmPeerId; } + + // Run one manual-dispatch + message-drain cycle. Normally driven by the internal pump + // timer once the EventManager loop is running, but callers (e.g. the lobby UI) can call + // it directly in a wait loop when no EventManager loop is pumping yet. + void pump(); + + // ---- routing registries (used by SteamSocket) ---- + + void registerConnection ( uint32_t conn, SteamSocket *socket ); + void unregisterConnection ( uint32_t conn ); + + void registerListen ( uint32_t listenHandle, SteamSocket *server ); + void unregisterListen ( uint32_t listenHandle ); + + // Called by the internal Steam callback shim (defined in the .cpp). + void onConnectionStatusChanged ( uint32_t conn, uint32_t listenHandle, int newState, uint64_t peerSteamId ); + +private: + + SteamManager() {} + + int _refCount = 0; + bool _inited = false; + + // Steam pipe for manual callback dispatch (HSteamPipe; 0 == none). + int _pipe = 0; + + // Pending async call-result ids (SteamAPICall_t) matched in the manual-dispatch pump. + uint64_t _lobbyCreateCall = 0; // createLobby() + uint64_t _lobbyListCall = 0; // joinLobbyByCode() code search + uint64_t _lobbyJoinCall = 0; // JoinLobby() for the lobby path + uint64_t _browseListCall = 0; // requestPublicLobbies() + uint64_t _mmListCall = 0; // matchmakeRegion() search + uint64_t _mmCreateCall = 0; // matchmakeRegion() create (host) + uint64_t _mmJoinCall = 0; // matchmakeRegion() join (client) + + TimerPtr _pumpTimer; + + // Lobby state + LobbyState _lobbyState = LobbyState::Idle; + std::string _lobbyCode; // host's advertised code (valid when host + Ready) + std::string _joinCode; // code the client is searching for + uint64_t _lobbyId = 0; // current lobby (host or joined) + uint64_t _lobbyPeerId = 0; // resolved host SteamID (legacy 1v1; unused by the lobby path) + bool _lobbyIsHost = false; + bool _lobbyIsPublic = true; + std::string _memberName; // our display name, advertised on create/join + uint64_t _myChallenge = 0; // who we are currently challenging (0 == none) + + // Browse state + LobbyState _browseState = LobbyState::Idle; + std::vector _publicLobbies; + + // Matchmaking state + MMState _mmState = MMState::Idle; + std::string _mmRegion; + uint64_t _mmPeerId = 0; + + // conn handle -> the SteamSocket that owns it (client or accepted child) + std::unordered_map _connections; + + // listen handle -> the server SteamSocket that owns it + std::unordered_map _listeners; + + // --- internal callback handlers (called from pump's manual dispatch) --- + void onLobbyCreatedResult ( bool ok, uint64_t lobbyId ); + void onLobbyListResult ( bool found, uint64_t lobbyId ); + void onBrowseListResult ( int count ); + void onLobbyEntered ( uint64_t lobbyId, bool ok ); + void onMatchmakeListResult ( int count ); + void onMatchmakeCreated ( bool ok, uint64_t lobbyId ); + void onMatchmakeEntered ( uint64_t lobbyId, bool ok ); + void onMemberJoined ( uint64_t lobbyId, uint64_t memberId ); + + // Apply the lobby-data tags + our member name for a lobby we just created. + void tagLobby ( uint64_t lobbyId, const char *type ); + + void startPump(); + void stopPump(); + + void timerExpired ( Timer *timer ) override; +}; + +#endif // ENABLE_STEAM diff --git a/lib/SteamMatchmaking.cpp b/lib/SteamMatchmaking.cpp new file mode 100644 index 00000000..27116a18 --- /dev/null +++ b/lib/SteamMatchmaking.cpp @@ -0,0 +1,114 @@ +#ifdef ENABLE_STEAM + +#include "SteamMatchmaking.hpp" +#include "SteamManager.hpp" +#include "SteamSocket.hpp" // steamAddr() +#include "Logger.hpp" + +#include // Sleep, VK_ESCAPE + +using namespace std; + + +// Bounded relay-readiness wait (~5s). The matched-wait below is intentionally unbounded: queueing +// for an opponent can take arbitrarily long (cancel with ESC under RELEASE, like the server path). +#define READY_TRIES ( 500 ) +#define READY_SLEEP_MS ( 10 ) +#define MATCH_SLEEP_MS ( 20 ) + + +SteamMatchmaking::SteamMatchmaking ( IMatchmakingBackend::Owner* owner, string region ) + : _region ( region ) +{ + this->owner = owner; +} + +SteamMatchmaking::~SteamMatchmaking() +{ + SteamManager::get().cancelMatchmaking(); + // Release the ref taken by the MainUi backend factory. Guarded no-op if MainApp already shut + // Steam down when the match launched. + SteamManager::get().deref(); +} + +void SteamMatchmaking::start() +{ + // Steam was already ref()'d by the MainUi factory; the actual search begins in waitConnected(). + _cancel = false; +} + +bool SteamMatchmaking::isRunning() +{ + return SteamManager::get().isInitialized(); +} + +void SteamMatchmaking::stop() +{ + _cancel = true; + SteamManager::get().cancelMatchmaking(); +} + +void SteamMatchmaking::sendHostReady() +{ + // No-op for Steam: the client already resolved the host's SteamID from the lobby, so there is + // no "host is ready" round-trip to make (that exists only in the relay protocol). +} + +void SteamMatchmaking::waitConnected() +{ + SteamManager& sm = SteamManager::get(); + + // Wait for the SDR relay network so the later ConnectP2P succeeds, then kick the search. + for ( int i = 0; i < READY_TRIES && ! sm.isRelayNetworkReady(); ++i ) + { + sm.pump(); + Sleep ( READY_SLEEP_MS ); + } + + sm.matchmakeRegion ( _region ); + connectionSuccess = true; +} + +void SteamMatchmaking::waitMatched() +{ + SteamManager& sm = SteamManager::get(); + + // Queue until a peer is found (host) or we joined someone (client), or the user cancels. + while ( ! _cancel && sm.mmState() == SteamManager::MMState::Working ) + { + sm.pump(); + Sleep ( MATCH_SLEEP_MS ); + } + + const SteamManager::MMState st = sm.mmState(); + + if ( st == SteamManager::MMState::BecameHost ) + { + matchSuccess = true; + if ( owner ) + owner->setMode ( this, "Host" ); + } + else if ( st == SteamManager::MMState::BecameClient ) + { + matchSuccess = true; + if ( owner ) + { + owner->setMode ( this, "Client" ); + owner->setAddr ( this, steamAddr ( sm.mmPeerId() ) ); + } + } + else + { + // Cancelled or failed. + if ( owner ) + owner->setMode ( this, "Offline" ); + } +} + +void SteamMatchmaking::keyboardEvent ( uint32_t vkCode, uint32_t scanCode, bool isExtended, bool isDown ) +{ + if ( vkCode == VK_ESCAPE && ! ignoreKb ) + stop(); +} + +#endif // ENABLE_STEAM diff --git a/lib/SteamMatchmaking.hpp b/lib/SteamMatchmaking.hpp new file mode 100644 index 00000000..ddb1724d --- /dev/null +++ b/lib/SteamMatchmaking.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "IMatchmakingBackend.hpp" + +#include + + +// Steam-backed matchmaking (implements IMatchmakingBackend). Backs the existing "Server -> +// Matchmaking" UI with a region-filtered create-or-join over Steam lobbies via SteamManager. No +// background thread or socket: pumped from the UI thread (waitConnected/waitMatched). Only +// meaningful when built with ENABLE_STEAM (the .cpp body is guarded). +class SteamMatchmaking : public IMatchmakingBackend +{ +public: + + SteamMatchmaking ( IMatchmakingBackend::Owner* owner, std::string region ); + ~SteamMatchmaking(); + + void start() override; + bool isRunning() override; + void stop() override; + + void sendHostReady() override; + + bool isSteam() const override { return true; } + bool needsEventManager() const override { return false; } + void waitConnected() override; + void waitMatched() override; + + void keyboardEvent ( uint32_t vkCode, uint32_t scanCode, bool isExtended, bool isDown ) override; + +private: + + std::string _region; + bool _cancel = false; // set by ESC / stop() to break the matched-wait loop +}; diff --git a/lib/SteamSocket.cpp b/lib/SteamSocket.cpp new file mode 100644 index 00000000..e82729d7 --- /dev/null +++ b/lib/SteamSocket.cpp @@ -0,0 +1,307 @@ +#ifdef ENABLE_STEAM + +#include "SteamSocket.hpp" +#include "SteamManager.hpp" +#include "Logger.hpp" + +#include +#include + +using namespace std; + + +#define LOG_STEAM_SOCKET(SOCKET, FORMAT, ...) \ + LOG ( "SteamSocket=%08x; conn=%u; listen=%u; peer=%llu; state=%s; " FORMAT, \ + SOCKET, SOCKET->_conn, SOCKET->_listenHandle, \ + ( unsigned long long ) SOCKET->_peerSteamId, SOCKET->_state, ## __VA_ARGS__ ) + +// How many times a client ConnectP2P that closed before connecting is re-attempted (see onClosed). +#define STEAM_CONNECT_RETRIES ( 5 ) + + +// Encode a SteamID into the synthetic address string used everywhere CCCaster expects an +// IpAddrPort. Keeping addr non-empty also makes the base Socket's isClient()/isServer() +// (which key off address.addr.empty()) behave correctly. Port is unused for Steam. +std::string steamAddr ( uint64_t steamId ) +{ + return "steam:" + to_string ( steamId ); +} + +uint64_t steamIdFromAddr ( const IpAddrPort& address ) +{ + const std::string& a = address.addr; + if ( a.compare ( 0, 6, "steam:" ) != 0 ) + return 0; + return strtoull ( a.c_str() + 6, nullptr, 10 ); +} + +static SteamSocket *asSteam ( const SocketPtr& ptr ) +{ + return static_cast ( ptr.get() ); +} + + +// ---- constructors ---- + +SteamSocket::SteamSocket ( Socket::Owner *owner, int virtualPort ) + : Socket ( owner, IpAddrPort ( "", 0 ), Protocol::Steam, false ) + , _virtualPort ( virtualPort ) +{ + freeBuffer(); + _state = State::Listening; + + _listenHandle = SteamManager::get().createListenSocketP2P ( virtualPort ); + + if ( _listenHandle ) + SteamManager::get().registerListen ( _listenHandle, this ); + else + LOG_STEAM_SOCKET ( this, "createListenSocketP2P failed" ); +} + +SteamSocket::SteamSocket ( Socket::Owner *owner, uint64_t peerSteamId, int virtualPort ) + : Socket ( owner, IpAddrPort ( steamAddr ( peerSteamId ), 0 ), Protocol::Steam, false ) + , _virtualPort ( virtualPort ) + , _peerSteamId ( peerSteamId ) +{ + freeBuffer(); + _state = State::Connecting; + + _conn = SteamManager::get().connectP2P ( peerSteamId, virtualPort ); + + if ( _conn ) + SteamManager::get().registerConnection ( _conn, this ); + else + LOG_STEAM_SOCKET ( this, "connectP2P failed" ); +} + +SteamSocket::SteamSocket ( ChildSocketEnum, Socket::Owner *owner, uint32_t conn, + uint64_t peerSteamId, int virtualPort ) + : Socket ( owner, IpAddrPort ( steamAddr ( peerSteamId ), 0 ), Protocol::Steam, false ) + , _virtualPort ( virtualPort ) + , _conn ( conn ) + , _peerSteamId ( peerSteamId ) +{ + freeBuffer(); + _state = State::Connecting; +} + +SteamSocket::~SteamSocket() +{ + disconnect(); +} + +void SteamSocket::disconnect() +{ + SteamManager& sm = SteamManager::get(); + + const uint32_t conn = _conn; + const uint32_t listen = _listenHandle; + + // Detach our children FIRST so their dtors don't try to reach back into us. + for ( auto& kv : _childSockets ) + asSteam ( kv.second )->_parentSocket = 0; + _childSockets.clear(); + _acceptedSocket.reset(); + + // Remove self from parent's child map. Hold a ref across the erase in case the parent + // map held the only reference to us (otherwise the rest of this function would run on a + // freed object). selfKeepAlive lives to the end of disconnect(). + SocketPtr selfKeepAlive; + if ( _parentSocket ) + { + const auto it = _parentSocket->_childSockets.find ( conn ); + if ( it != _parentSocket->_childSockets.end() ) + { + selfKeepAlive = it->second; + _parentSocket->_childSockets.erase ( it ); + } + _parentSocket = 0; + } + + if ( conn ) + { + sm.unregisterConnection ( conn ); + sm.closeConnection ( conn ); + _conn = 0; + } + + if ( listen ) + { + sm.unregisterListen ( listen ); + sm.closeListenSocket ( listen ); + _listenHandle = 0; + } + + Socket::disconnect(); +} + + +// ---- factories ---- + +SocketPtr SteamSocket::listen ( Socket::Owner *owner, int virtualPort ) +{ + return SocketPtr ( new SteamSocket ( owner, virtualPort ) ); +} + +SocketPtr SteamSocket::connect ( Socket::Owner *owner, uint64_t peerSteamId, int virtualPort ) +{ + return SocketPtr ( new SteamSocket ( owner, peerSteamId, virtualPort ) ); +} + +SocketPtr SteamSocket::accept ( Socket::Owner *owner ) +{ + if ( ! _acceptedSocket ) + return 0; + + _acceptedSocket->owner = owner; + + SocketPtr ret; + _acceptedSocket.swap ( ret ); + return ret; +} + + +// ---- send ---- + +bool SteamSocket::sendEncoded ( const MsgPtr& msg, bool reliable ) +{ + if ( _conn == 0 || isDisconnected() ) + { + LOG_STEAM_SOCKET ( this, "Cannot send over disconnected socket" ); + return false; + } + + const string buffer = ::Protocol::encode ( msg ); + + if ( buffer.empty() ) + return false; + + return SteamManager::get().sendMessage ( _conn, &buffer[0], buffer.size(), reliable ); +} + +bool SteamSocket::send ( const char *buffer, size_t len ) +{ + // Raw byte sends are unused by the netplay path over Steam. + return false; +} + +bool SteamSocket::send ( const char *buffer, size_t len, const IpAddrPort& address ) +{ + return false; +} + +bool SteamSocket::send ( SerializableMessage *message, const IpAddrPort& address ) +{ + // Unreliable, like the UDP data path. + return sendEncoded ( MsgPtr ( message ), false ); +} + +bool SteamSocket::send ( SerializableSequence *message, const IpAddrPort& address ) +{ + // Reliable + ordered. + return sendEncoded ( MsgPtr ( message ), true ); +} + +bool SteamSocket::send ( const MsgPtr& msg, const IpAddrPort& address ) +{ + if ( ! msg.get() ) + return false; + + const bool reliable = ( msg->getBaseType().value == BaseType::SerializableSequence ); + return sendEncoded ( msg, reliable ); +} + + +// ---- routing callbacks from SteamManager ---- + +void SteamSocket::onIncomingConnection ( uint32_t conn, uint64_t peerSteamId ) +{ + LOG_STEAM_SOCKET ( this, "incoming conn=%u from peer=%llu", conn, ( unsigned long long ) peerSteamId ); + + if ( ! SteamManager::get().acceptConnection ( conn ) ) + { + LOG_STEAM_SOCKET ( this, "acceptConnection(%u) failed", conn ); + SteamManager::get().closeConnection ( conn ); + return; + } + + SteamSocket *child = new SteamSocket ( ChildSocket, 0, conn, peerSteamId, _virtualPort ); + child->_parentSocket = this; + + _childSockets[conn] = SocketPtr ( child ); + SteamManager::get().registerConnection ( conn, child ); + + // socketAccepted is fired once the child reaches the Connected state (onConnected). +} + +void SteamSocket::onConnected() +{ + _state = State::Connected; + _gotGoodRead = true; + + if ( _parentSocket ) + { + // Child (host side): surface to the server owner as a newly accepted socket. + const auto it = _parentSocket->_childSockets.find ( _conn ); + if ( it != _parentSocket->_childSockets.end() ) + _parentSocket->_acceptedSocket = it->second; + + LOG_STEAM_SOCKET ( this, "socketAccepted" ); + + if ( _parentSocket->owner ) + _parentSocket->owner->socketAccepted ( _parentSocket ); + } + else + { + // Client: connection established. + LOG_STEAM_SOCKET ( this, "socketConnected" ); + + if ( owner ) + owner->socketConnected ( this ); + } +} + +void SteamSocket::onClosed() +{ + LOG_STEAM_SOCKET ( this, "socketDisconnected" ); + + // A client connection that dropped before ever reaching Connected may have just raced the SDR + // relay coming up (or a one-off NAT-punch failure). Re-attempt a bounded number of times + // before surfacing the disconnect. Server/child sockets, and sockets that DID connect (a real + // mid-match drop, state == Connected), fall through and disconnect as before. + if ( _peerSteamId && _parentSocket == 0 && _state == State::Connecting && _conn + && _connectRetries < STEAM_CONNECT_RETRIES ) + { + ++_connectRetries; + + SteamManager& sm = SteamManager::get(); + sm.unregisterConnection ( _conn ); + sm.closeConnection ( _conn ); + _conn = sm.connectP2P ( _peerSteamId, _virtualPort ); + + if ( _conn ) + { + sm.registerConnection ( _conn, this ); + LOG_STEAM_SOCKET ( this, "retrying connectP2P (%d)", _connectRetries ); + return; + } + // connectP2P failed outright; fall through to a normal disconnect. + } + + Socket::Owner *const ownerCopy = this->owner; + + disconnect(); + + if ( ownerCopy ) + ownerCopy->socketDisconnected ( this ); +} + +void SteamSocket::onReceive ( const MsgPtr& msg ) +{ + _gotGoodRead = true; + + if ( owner ) + owner->socketRead ( this, msg, getRemoteAddress() ); +} + +#endif // ENABLE_STEAM diff --git a/lib/SteamSocket.hpp b/lib/SteamSocket.hpp new file mode 100644 index 00000000..489185d9 --- /dev/null +++ b/lib/SteamSocket.hpp @@ -0,0 +1,121 @@ +#pragma once + +#ifdef ENABLE_STEAM + +#include "Socket.hpp" + +#include +#include +#include + + +// Virtual ports for the two logical channels, mirroring CCCaster's TCP-control + UDP-data +// split: each is a separate Steam P2P connection. +#define STEAM_CTRL_VPORT ( 0 ) +#define STEAM_DATA_VPORT ( 1 ) + +// A Steam match is addressed by the peer's SteamID, but CCCaster threads an IpAddrPort +// "address" through its UI, launcher, and IPC. We encode the SteamID into that address as +// "steam:" so Steam matches reuse the same plumbing as IP matches. +std::string steamAddr ( uint64_t steamId ); // -> IpAddrPort addr string "steam:" +uint64_t steamIdFromAddr ( const IpAddrPort& address ); // parse "steam:"; 0 if not a steam addr + + +// A Socket implementation that tunnels CCCaster's protocol over a Steam Datagram Relay +// (SDR) P2P connection, addressed by SteamID instead of IP:port. This sidesteps CGNAT by +// routing through Valve's relay backbone. +// +// Structure mirrors UdpSocket's server/child/accepted model: +// - A "listen" SteamSocket (isServer) owns a Steam listen socket on a virtual port. +// When a peer connects, SteamManager routes the incoming connection here; we accept it, +// wrap it in a child SteamSocket, stash it as _acceptedSocket, and fire socketAccepted. +// - A "client" SteamSocket owns one outbound connection (ConnectP2P by SteamID). +// - A "child" SteamSocket wraps one accepted inbound connection on the host. +// +// No fd, so this never registers with SocketManager; SteamManager's pump timer drives it. +// All Steam SDK calls live in SteamManager (this file never includes Steam headers). +class SteamSocket : public Socket +{ +public: + + // Host: listen for P2P connections on the given virtual port. + static SocketPtr listen ( Socket::Owner *owner, int virtualPort ); + + // Client: connect to a host by SteamID64 on the given virtual port. + static SocketPtr connect ( Socket::Owner *owner, uint64_t peerSteamId, int virtualPort ); + + ~SteamSocket() override; + + void disconnect() override; + + SocketPtr accept ( Socket::Owner *owner ) override; + + // The remote peer's SteamID64 (0 for a server socket). + uint64_t getPeerSteamId() const { return _peerSteamId; } + + // Raw byte sends are not used by the netplay path, but override to avoid the base + // winsock implementation (which would touch the null fd). + bool send ( const char *buffer, size_t len ); + bool send ( const char *buffer, size_t len, const IpAddrPort& address ); + + bool send ( SerializableMessage *message, const IpAddrPort& address = NullAddress ) override; + bool send ( SerializableSequence *message, const IpAddrPort& address = NullAddress ) override; + bool send ( const MsgPtr& message, const IpAddrPort& address = NullAddress ) override; + + // ---- called by SteamManager's routing/pump ---- + + // Incoming connection arrived on this server socket's listen handle. + void onIncomingConnection ( uint32_t conn, uint64_t peerSteamId ); + + // This connection reached the Connected state. + void onConnected(); + + // This connection closed or failed. + void onClosed(); + + // A decoded message was received on this connection. + void onReceive ( const MsgPtr& msg ); + + friend class SteamManager; + +private: + + enum ChildSocketEnum { ChildSocket }; + + // Virtual port (host listen port / client target port). + int _virtualPort = 0; + + // Steam listen handle (server) or connection handle (client/child); 0 == invalid. + uint32_t _listenHandle = 0; + uint32_t _conn = 0; + + // Remote peer SteamID64 (client/child only). + uint64_t _peerSteamId = 0; + + // Bounded re-attempts of a client ConnectP2P that closed before ever reaching Connected (e.g. + // the SDR relay momentarily flaked). Pairs with SteamManager::waitRelayNetworkReady(). + int _connectRetries = 0; + + // Server: the just-accepted child waiting to be accept()'d out, and live children. + SocketPtr _acceptedSocket; + std::unordered_map _childSockets; + + // Child: the server socket that accepted this connection (0 for server/client). + SteamSocket *_parentSocket = 0; + + // Server constructor (listen on a virtual port). + SteamSocket ( Socket::Owner *owner, int virtualPort ); + + // Client constructor (connect to peer SteamID). + SteamSocket ( Socket::Owner *owner, uint64_t peerSteamId, int virtualPort ); + + // Child constructor (one accepted inbound connection on the host). + SteamSocket ( ChildSocketEnum, Socket::Owner *owner, uint32_t conn, uint64_t peerSteamId, int virtualPort ); + + bool sendEncoded ( const MsgPtr& msg, bool reliable ); + + // Unused base callback (Steam delivers whole messages via onReceive). + void socketRead ( const MsgPtr& msg, const IpAddrPort& address ) override {} +}; + +#endif // ENABLE_STEAM diff --git a/netplay/Messages.hpp b/netplay/Messages.hpp index 12c8ab7f..0949ff7e 100644 --- a/netplay/Messages.hpp +++ b/netplay/Messages.hpp @@ -33,7 +33,7 @@ struct ClientMode : public SerializableSequence { ENUM_BOILERPLATE ( ClientMode, Host, Client, SpectateNetplay, SpectateBroadcast, Broadcast, Offline ) - enum { Training = 0x01, GameStarted = 0x02, UdpTunnel = 0x04, IsWine = 0x08, VersusCPU = 0x10, Replay = 0x20, Trial = 0x40 }; + enum { Training = 0x01, GameStarted = 0x02, UdpTunnel = 0x04, IsWine = 0x08, VersusCPU = 0x10, Replay = 0x20, Trial = 0x40, IsSteam = 0x80 }; uint8_t flags = 0; @@ -62,6 +62,7 @@ struct ClientMode : public SerializableSequence bool isTrial() const { return ( flags & Trial ); } bool isGameStarted() const { return ( flags & GameStarted ); } bool isUdpTunnel() const { return ( flags & UdpTunnel ); } + bool isSteam() const { return ( flags & IsSteam ); } bool isWine() const { return ( flags & IsWine ); } bool isSinglePlayer() const { return ( isNetplay() || isVersusCPU() ); } @@ -81,6 +82,9 @@ struct ClientMode : public SerializableSequence if ( flags & UdpTunnel ) str += std::string ( str.empty() ? "" : ", " ) + "UdpTunnel"; + if ( flags & IsSteam ) + str += std::string ( str.empty() ? "" : ", " ) + "IsSteam"; + if ( flags & IsWine ) str += std::string ( str.empty() ? "" : ", " ) + "IsWine"; @@ -285,6 +289,28 @@ struct ConfirmConfig : public SerializableSequence }; +// Sent over IPC from the DLL to the standalone when a match ends (entering RetryMenu). The +// standalone side otherwise only learns the result via results.csv; the king-of-the-hill lobby +// queue needs it directly. hostWon is derived from the synced win counts + hostPlayer, so it is +// computed identically on both the host and the client. +struct MatchResult : public SerializableSequence +{ + uint8_t hostWon = 0; + uint8_t p1Wins = 0; + uint8_t p2Wins = 0; + + MatchResult ( uint8_t hostWon, uint8_t p1Wins, uint8_t p2Wins ) + : hostWon ( hostWon ), p1Wins ( p1Wins ), p2Wins ( p2Wins ) {} + + std::string str() const override + { + return format ( "MatchResult[hostWon=%u;%u-%u]", hostWon, p1Wins, p2Wins ); + } + + PROTOCOL_MESSAGE_BOILERPLATE ( MatchResult, hostWon, p1Wins, p2Wins ) +}; + + struct RngState : public SerializableSequence { uint32_t index = 0; diff --git a/scripts/make_version b/scripts/make_version index 17bdb649..810c9bb6 100755 --- a/scripts/make_version +++ b/scripts/make_version @@ -13,3 +13,13 @@ printf " \"$COMMIT_ID\",\n" fi printf " \"`date`\"\n" printf ");\n" + +# Also embed the version code as a plain string literal. LocalVersion.code is a std::string the +# optimizer builds at runtime via immediate-mov instructions (e.g. "4.1." as a dword then '2','\0' +# as byte stores), so the version never appears as a contiguous run in the optimized binary. The +# exe's hook.dll compatibility check (targets/Main.cpp) searches the dll *file bytes* for that +# version string, so without a literal copy a release build always reports "Incompatible hook.dll". +# A const char[] is initialized data: its bytes land contiguously in .rodata and survive -Ofast and +# strip (the link has no --gc-sections). extern + __attribute__((used)) keeps it from being elided. +printf "extern const char LocalVersionCode[];\n" +printf "const char LocalVersionCode[] __attribute__ ( ( used ) ) = \"$CODE\";\n" diff --git a/scripts/symbolicate.sh b/scripts/symbolicate.sh new file mode 100644 index 00000000..80765e4f --- /dev/null +++ b/scripts/symbolicate.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# +# Resolve Sentry/Glitchtip crash-frame addresses to function + source:line for a CCCaster build. +# +# A native crash report from lib/SentryClient carries, per frame, an absolute `instruction_addr` +# and the owning module's runtime `image_addr`. Because of ASLR the runtime load address differs +# from the binary's linked ImageBase, so we map each frame back to a file virtual address: +# +# file_VA = + (instruction_addr - image_addr) +# +# and feed that to addr2line. This only yields source lines for an UNSTRIPPED build (debug, or +# logging — release is stripped with -s, so keep a copy of the unstripped binary if you need this). +# +# Usage: +# scripts/symbolicate.sh [ ...] +# +# binary cccaster/hook.dll (the SAME build that crashed) or cccaster.v*.exe +# image_addr the frame's image_addr (module runtime base) from the event +# instruction_addr one or more frame instruction_addr values from the event +# +# Example (fault + a couple of caller frames, all in hook.dll): +# scripts/symbolicate.sh cccaster/hook.dll 0x72900000 0x72901dca 0x72903f51 +# +# MINGW32_BIN may point at the 32-bit binutils dir (default: /c/msys64/mingw32/bin). + +set -euo pipefail + +if [ "$#" -lt 3 ]; then + sed -n '2,30p' "$0" + exit 2 +fi + +BIN="$1"; IMAGE_ADDR="$2"; shift 2 + +MINGW32_BIN="${MINGW32_BIN:-/c/msys64/mingw32/bin}" +ADDR2LINE="$MINGW32_BIN/addr2line" +OBJDUMP="$MINGW32_BIN/objdump" +# Use the 32-bit binutils from MINGW32_BIN; only fall back to PATH if they're genuinely absent. +# A 64-bit addr2line/objdump on PATH silently returns "??" for a 32-bit DWARF binary, so the +# (extensionless) full path must be preferred — `command -v` on it fails for the .exe, so test +# the file directly instead. +[ -x "$ADDR2LINE" ] || [ -x "$ADDR2LINE.exe" ] || ADDR2LINE="addr2line" +[ -x "$OBJDUMP" ] || [ -x "$OBJDUMP.exe" ] || OBJDUMP="objdump" + +[ -f "$BIN" ] || { echo "ERROR: binary not found: $BIN" >&2; exit 1; } + +# Linked ImageBase from the PE header (printed in hex without a 0x prefix). +IMAGE_BASE=$("$OBJDUMP" -p "$BIN" | awk '/ImageBase/{print "0x"$2}') +[ -n "$IMAGE_BASE" ] || { echo "ERROR: could not read ImageBase from $BIN" >&2; exit 1; } + +if ! "$OBJDUMP" -h "$BIN" | grep -q '\.debug_info'; then + echo "WARNING: $BIN has no .debug_info (stripped?) — addresses won't resolve to source lines." >&2 +fi + +echo "binary : $BIN" +echo "ImageBase : $IMAGE_BASE" +echo "image_addr : $IMAGE_ADDR" +echo + +for INSTR in "$@"; do + RVA=$(( INSTR - IMAGE_ADDR )) + FILE_VA=$(( IMAGE_BASE + RVA )) + printf '%s (rva=0x%x)\n' "$INSTR" "$RVA" + "$ADDR2LINE" -f -C -i -e "$BIN" "$(printf '0x%x' "$FILE_VA")" | sed 's/^/ /' + echo +done diff --git a/targets/DllHacks.cpp b/targets/DllHacks.cpp index 8a86c34f..3e6b9ba6 100644 --- a/targets/DllHacks.cpp +++ b/targets/DllHacks.cpp @@ -298,7 +298,7 @@ SyncHash::SyncHash ( IndexedFrame indexedFrame ) chara[N-1].seqState = *CC_P ## N ## _SEQ_STATE_ADDR; \ chara[N-1].health = *CC_P ## N ## _HEALTH_ADDR; \ chara[N-1].redHealth = *CC_P ## N ## _RED_HEALTH_ADDR; \ - chara[N-1].meter = *CC_P ## N ## _METER_ADDR; \ + chara[N-1].meter = ( *CC_INTRO_STATE_ADDR ? 0 : *CC_P ## N ## _METER_ADDR ); \ chara[N-1].heat = *CC_P ## N ## _HEAT_ADDR; \ chara[N-1].guardBar = ( *CC_INTRO_STATE_ADDR ? 0 : *CC_P ## N ## _GUARD_BAR_ADDR ); \ chara[N-1].guardQuality = *CC_P ## N ## _GUARD_QUALITY_ADDR; \ diff --git a/targets/DllMain.cpp b/targets/DllMain.cpp index 623deb2a..10ab8a16 100644 --- a/targets/DllMain.cpp +++ b/targets/DllMain.cpp @@ -6,7 +6,12 @@ #include "ChangeMonitor.hpp" #include "SmartSocket.hpp" #include "UdpSocket.hpp" +#ifdef ENABLE_STEAM +#include "SteamSocket.hpp" +#include "SteamManager.hpp" +#endif #include "Exceptions.hpp" +#include "SentryClient.hpp" #include "Enum.hpp" #include "ErrorStringsExt.hpp" #include "KeyboardState.hpp" @@ -31,6 +36,24 @@ using namespace std; // The main log file path #define LOG_FILE FOLDER "dll.log" +// Build type reported to Sentry as the "environment". +static const char *sentryEnvironment() +{ +#if defined(RELEASE) + return "release"; +#elif defined(LOGGING) + return "logging"; +#else + return "debug"; +#endif +} + +// Route SentryClient diagnostics into dll.log. +static void sentryLogSink ( const char *message ) +{ + LOG ( "[sentry] %s", message ); +} + // The number of milliseconds to poll for events each frame #define POLL_TIMEOUT ( 3 ) @@ -1088,6 +1111,18 @@ struct DllMain // Reset retry menu index flag localRetryMenuIndexSent = false; + + // Report the match outcome to the standalone (for the lobby queue). hostWon is derived + // from the synced win counts + hostPlayer, so it is identical on host and client. + if ( clientMode.isNetplay() + && ( netMan.config.hostPlayer == 1 || netMan.config.hostPlayer == 2 ) ) + { + const uint8_t p1w = ( uint8_t ) *CC_P1_WINS_ADDR; + const uint8_t p2w = ( uint8_t ) *CC_P2_WINS_ADDR; + const uint8_t winnerPlayer = ( p1w >= p2w ) ? 1 : 2; + const uint8_t hostWon = ( winnerPlayer == netMan.config.hostPlayer ) ? 1 : 0; + procMan.ipcSend ( new MatchResult ( hostWon, p1w, p2w ) ); + } } else if ( lazyDisconnect ) { @@ -1823,20 +1858,53 @@ struct DllMain netMan.setRemotePlayer ( remotePlayer ); +#ifdef ENABLE_STEAM + // Launcher shut down its Steam instance before spawning the game; bring + // Steam up in-process so we can re-establish the P2P connection by SteamID. + // This ref() is a cold init, so wait (bounded) for the SDR relay network to + // come up before listen/connect below - connecting too early tends to fail. + // Runs on the DLL's network thread, so this does not stall the game's render. + if ( clientMode.isSteam() ) + { + SteamManager::get().ref(); + SteamManager::get().waitRelayNetworkReady(); + } +#endif + if ( clientMode.isHost() ) { - serverCtrlSocket = SmartSocket::listenTCP ( this, address.port ); - LOG ( "serverCtrlSocket=%08x", serverCtrlSocket.get() ); +#ifdef ENABLE_STEAM + if ( clientMode.isSteam() ) + { + serverCtrlSocket = SteamSocket::listen ( this, STEAM_CTRL_VPORT ); + serverDataSocket = SteamSocket::listen ( this, STEAM_DATA_VPORT ); + } + else +#endif + { + serverCtrlSocket = SmartSocket::listenTCP ( this, address.port ); + serverDataSocket = SmartSocket::listenUDP ( this, address.port ); + } - serverDataSocket = SmartSocket::listenUDP ( this, address.port ); + LOG ( "serverCtrlSocket=%08x", serverCtrlSocket.get() ); LOG ( "serverDataSocket=%08x", serverDataSocket.get() ); } else if ( clientMode.isClient() ) { - serverCtrlSocket = SmartSocket::listenTCP ( this, 0 ); - LOG ( "serverCtrlSocket=%08x", serverCtrlSocket.get() ); +#ifdef ENABLE_STEAM + if ( clientMode.isSteam() ) + { + serverCtrlSocket = SteamSocket::listen ( this, STEAM_CTRL_VPORT ); + dataSocket = SteamSocket::connect ( this, steamIdFromAddr ( address ), STEAM_DATA_VPORT ); + } + else +#endif + { + serverCtrlSocket = SmartSocket::listenTCP ( this, 0 ); + dataSocket = SmartSocket::connectUDP ( this, address, clientMode.isUdpTunnel() ); + } - dataSocket = SmartSocket::connectUDP ( this, address, clientMode.isUdpTunnel() ); + LOG ( "serverCtrlSocket=%08x", serverCtrlSocket.get() ); LOG ( "dataSocket=%08x", dataSocket.get() ); } @@ -2119,6 +2187,20 @@ extern "C" BOOL APIENTRY DllMain ( HMODULE, DWORD reason, LPVOID ) LOG ( "DLL_PROCESS_ATTACH" ); LOG ( "gameDir='%s'", ProcessManager::gameDir ); + // Initialize crash/error reporting from inside MBAA.exe. No-op unless a DSN was baked + // in at build time. A vectored handler + the unhandled-exception filter catch ALL + // crashes in the game process; fatal crashes upload synchronously (the process is + // dying), while non-fatal captures during a match are uploaded off-thread (see + // AsmHacks::callback) so gameplay never stalls. + SentryClient::setLogSink ( sentryLogSink ); + SentryClient::init ( SENTRY_DSN, LocalVersion.code, sentryEnvironment(), LocalVersion.revision ); + SentryClient::setTag ( "component", "hook-dll" ); + SentryClient::setTag ( "revision", LocalVersion.revision ); + SentryClient::setTag ( "build_time", LocalVersion.buildTime ); + if ( ProcessManager::isWine() ) + SentryClient::setTag ( "wine", "1" ); + SentryClient::installCrashHandler(); + // We want the DLL to be able to rebind any previously bound ports Socket::forceReusePort ( true ); @@ -2194,6 +2276,10 @@ extern "C" void callback() if ( appState == AppState::Deinitialized ) return; + // Captured before the frame runs: decides whether a non-fatal capture below uploads on a + // background thread (mid-match, must not stall gameplay) or inline (out of match). + const bool inMatch = ( mainApp && mainApp->netMan.isInGame() ); + try { if ( appState == AppState::Uninitialized ) @@ -2209,6 +2295,10 @@ extern "C" void callback() // Start polling now EventManager::get().startPolling(); appState = AppState::Polling; + + // The game / D3D runtime installs its own handlers during startup; reclaim the + // top-level unhandled-exception filter now that the game is fully loaded. + SentryClient::reassertCrashHandler(); } ASSERT ( mainApp.get() != 0 ); @@ -2218,17 +2308,21 @@ extern "C" void callback() catch ( const Exception& exc ) { LOG ( "Stopping due to exception: %s", exc ); + // Upload off-thread while mid-match so the game loop never blocks on the network. + SentryClient::captureException ( "Exception", exc.str(), inMatch ); stopDllMain ( exc.user ); } #ifdef NDEBUG catch ( const std::exception& exc ) { LOG ( "Stopping due to std::exception: %s", exc.what() ); + SentryClient::captureException ( "std::exception", exc.what(), inMatch ); stopDllMain ( string ( "Error: " ) + exc.what() ); } catch ( ... ) { LOG ( "Stopping due to unknown exception!" ); + SentryClient::captureMessage ( SentryClient::Level::Fatal, "Unknown exception in DLL callback", inMatch ); stopDllMain ( "Unknown error!" ); } #endif // NDEBUG diff --git a/targets/DllOverlayUi.cpp b/targets/DllOverlayUi.cpp index d0cb7f1e..14c565b8 100644 --- a/targets/DllOverlayUi.cpp +++ b/targets/DllOverlayUi.cpp @@ -58,6 +58,13 @@ void InvalidateDeviceObjects() initalizedDirectX = false; invalidateOverlayText(); +#ifdef LOGGING + // Release ImGui's D3DPOOL_DEFAULT resources (vertex/index buffers, font texture, state block) + // BEFORE the game resets the device (e.g. alt+enter fullscreen toggle). Otherwise Reset() fails + // on the still-live default-pool resources and ImGui then renders against freed memory, which + // faults inside hook.dll. The next ImGui_ImplDX9_NewFrame() recreates them automatically. + ImGui_ImplDX9_InvalidateDeviceObjects(); +#endif } // Note: this is called on the SAME thread as the main application thread diff --git a/targets/DllOverlayUiImGui.cpp b/targets/DllOverlayUiImGui.cpp index 75740049..c489b9c8 100644 --- a/targets/DllOverlayUiImGui.cpp +++ b/targets/DllOverlayUiImGui.cpp @@ -18,6 +18,13 @@ extern bool doEndScene; extern bool initalizedDirectX; void initImGui( IDirect3DDevice9 *device ) { + // Initialize the ImGui context + backends only once. On a device reset the device pointer is + // unchanged (the game calls IDirect3DDevice9::Reset, not a recreate) and only its resources are + // lost, so we must NOT recreate the context here -- that would leak it and re-bind the backend. + // The device objects are recreated lazily by ImGui_ImplDX9_NewFrame() after invalidation. + if ( context ) + return; + IMGUI_CHECKVERSION(); context = ImGui::CreateContext(); void* windowHandle = ProcessManager::findWindow ( CC_TITLE ); diff --git a/targets/Main.cpp b/targets/Main.cpp index da44c59b..aa78d892 100644 --- a/targets/Main.cpp +++ b/targets/Main.cpp @@ -5,6 +5,7 @@ #include "StringUtils.hpp" #include "ConsoleUi.hpp" #include "Version.hpp" +#include "SentryClient.hpp" #include #include @@ -26,6 +27,25 @@ MainUi ui; string lastError; +// Build type reported to Sentry as the "environment". +static const char *sentryEnvironment() +{ +#if defined(RELEASE) + return "release"; +#elif defined(LOGGING) + return "logging"; +#else + return "debug"; +#endif +} + +// Route SentryClient diagnostics into the debug log. +static void sentryLogSink ( const char *message ) +{ + LOG ( "[sentry] %s", message ); +} + + void runMain ( const IpAddrPort& address, const Serializable& config ); void runFake ( const IpAddrPort& address, const Serializable& config ); void stopMain(); @@ -405,6 +425,18 @@ int main ( int argc, char *argv[] ) LOG ( "Running from: %s", ProcessManager::appDir ); + // Initialize crash/error reporting now that logging is up (so its diagnostics are logged). + // No-op unless a DSN was baked in at build time. Installed after the signal handlers above so + // its SIGABRT hook chains back to signalHandler. + SentryClient::setLogSink ( sentryLogSink ); + SentryClient::init ( SENTRY_DSN, LocalVersion.code, sentryEnvironment(), LocalVersion.revision ); + SentryClient::setTag ( "component", "standalone" ); + SentryClient::setTag ( "revision", LocalVersion.revision ); + SentryClient::setTag ( "build_time", LocalVersion.buildTime ); + if ( ProcessManager::isWine() ) + SentryClient::setTag ( "wine", "1" ); + SentryClient::installCrashHandler(); + // Log parsed command line opt for ( size_t i = 0; i < opt.size(); ++i ) { @@ -562,15 +594,18 @@ int main ( int argc, char *argv[] ) } catch ( const Exception& exc ) { + SentryClient::captureException ( "Exception", exc.str() ); PRINT ( "%s", exc.user ); } #ifdef NDEBUG catch ( const std::exception& exc ) { + SentryClient::captureException ( "std::exception", exc.what() ); PRINT ( "Error: %s", exc.what() ); } catch ( ... ) { + SentryClient::captureMessage ( SentryClient::Level::Fatal, "Unknown exception in ui.main" ); PRINT ( "Unknown error!" ); } #endif // NDEBUG diff --git a/targets/MainApp.cpp b/targets/MainApp.cpp index 34410679..9b68b42d 100644 --- a/targets/MainApp.cpp +++ b/targets/MainApp.cpp @@ -4,6 +4,10 @@ #include "ExternalIpAddress.hpp" #include "SmartSocket.hpp" #include "UdpSocket.hpp" +#ifdef ENABLE_STEAM +#include "SteamSocket.hpp" +#include "SteamManager.hpp" +#endif #include "Constants.hpp" #include "Exceptions.hpp" #include "Algorithms.hpp" @@ -248,6 +252,13 @@ struct MainApp _.doDeinit = !EventManager::get().isRunning(); +#ifdef ENABLE_STEAM + // The SDR relay network must be ready before ConnectP2P / listen, or the connection tends + // to fail. It is usually already warm from the lobby; wait (bounded) just in case. + if ( clientMode.isSteam() ) + SteamManager::get().waitRelayNetworkReady(); +#endif + if ( clientMode.isHost() ) { if ( !ui.isServer() ) { @@ -274,15 +285,30 @@ struct MainApp if ( clientMode.isHost() ) { - serverCtrlSocket = SmartSocket::listenTCP ( this, address.port ); - address.port = serverCtrlSocket->address.port; // Update port in case it was initially 0 - address.invalidate(); +#ifdef ENABLE_STEAM + if ( clientMode.isSteam() ) + { + serverCtrlSocket = SteamSocket::listen ( this, STEAM_CTRL_VPORT ); + } + else +#endif + { + serverCtrlSocket = SmartSocket::listenTCP ( this, address.port ); + address.port = serverCtrlSocket->address.port; // Update port in case it was initially 0 + address.invalidate(); + } LOG ( "serverCtrlSocket=%08x", serverCtrlSocket.get() ); } else { - ctrlSocket = SmartSocket::connectTCP ( this, address, options[Options::Tunnel] ); +#ifdef ENABLE_STEAM + if ( clientMode.isSteam() ) + ctrlSocket = SteamSocket::connect ( this, steamIdFromAddr ( address ), STEAM_CTRL_VPORT ); + else +#endif + ctrlSocket = SmartSocket::connectTCP ( this, address, options[Options::Tunnel] ); + LOG ( "ctrlSocket=%08x", ctrlSocket.get() ); stopTimer.reset ( new Timer ( this ) ); @@ -436,10 +462,20 @@ struct MainApp ASSERT ( ctrlSocket.get() != 0 ); ASSERT ( ctrlSocket->isConnected() == true ); - try { serverDataSocket = SmartSocket::listenUDP ( this, address.port ); } - catch ( ... ) { serverDataSocket = SmartSocket::listenUDP ( this, 0 ); } +#ifdef ENABLE_STEAM + if ( clientMode.isSteam() ) + { + serverDataSocket = SteamSocket::listen ( this, STEAM_DATA_VPORT ); + initialConfig.dataPort = 0; // unused for Steam (vports, not OS ports) + } + else +#endif + { + try { serverDataSocket = SmartSocket::listenUDP ( this, address.port ); } + catch ( ... ) { serverDataSocket = SmartSocket::listenUDP ( this, 0 ); } - initialConfig.dataPort = serverDataSocket->address.port; + initialConfig.dataPort = serverDataSocket->address.port; + } LOG ( "serverDataSocket=%08x", serverDataSocket.get() ); } @@ -484,8 +520,13 @@ struct MainApp ASSERT ( ctrlSocket.get() != 0 ); ASSERT ( ctrlSocket->isConnected() == true ); - dataSocket = SmartSocket::connectUDP ( this, { address.addr, this->initialConfig.dataPort }, - ctrlSocket->getAsSmart().isTunnel() ); +#ifdef ENABLE_STEAM + if ( clientMode.isSteam() ) + dataSocket = SteamSocket::connect ( this, steamIdFromAddr ( address ), STEAM_DATA_VPORT ); + else +#endif + dataSocket = SmartSocket::connectUDP ( this, { address.addr, this->initialConfig.dataPort }, + ctrlSocket->getAsSmart().isTunnel() ); LOG ( "dataSocket=%08x", dataSocket.get() ); ui.display ( @@ -909,7 +950,12 @@ struct MainApp // Only connect the dataSocket if isClient if ( clientMode.isClient() ) { - dataSocket = SmartSocket::connectUDP ( this, address, ctrlSocket->getAsSmart().isTunnel() ); +#ifdef ENABLE_STEAM + if ( clientMode.isSteam() ) + dataSocket = SteamSocket::connect ( this, steamIdFromAddr ( address ), STEAM_DATA_VPORT ); + else +#endif + dataSocket = SmartSocket::connectUDP ( this, address, ctrlSocket->getAsSmart().isTunnel() ); LOG ( "dataSocket=%08x", dataSocket.get() ); } @@ -1178,7 +1224,12 @@ struct MainApp procMan.ipcSend ( options ); procMan.ipcSend ( ControllerManager::get().getMappings() ); procMan.ipcSend ( clientMode ); - procMan.ipcSend ( new IpAddrPort ( address.getAddrInfo()->ai_addr ) ); +#ifdef ENABLE_STEAM + if ( clientMode.isSteam() ) + procMan.ipcSend ( new IpAddrPort ( address ) ); // raw "steam:"; do NOT DNS-resolve + else +#endif + procMan.ipcSend ( new IpAddrPort ( address.getAddrInfo()->ai_addr ) ); if ( clientMode.isSpectate() ) { @@ -1220,6 +1271,13 @@ struct MainApp updateStatusMessage(); return; + case MsgType::MatchResult: + ui.lastMatchResult.valid = true; + ui.lastMatchResult.hostWon = msg->getAs().hostWon; + ui.lastMatchResult.p1Wins = msg->getAs().p1Wins; + ui.lastMatchResult.p2Wins = msg->getAs().p2Wins; + return; + case MsgType::IpAddrPort: if ( ctrlSocket && ctrlSocket->isConnected() ) ctrlSocket->send ( msg ); @@ -1272,6 +1330,15 @@ struct MainApp serverDataSocket.reset(); ctrlSocket.reset(); serverCtrlSocket.reset(); + +#ifdef ENABLE_STEAM + // Shut Steam down in the launcher BEFORE spawning the game, so the injected + // DLL can SteamAPI_Init under App ID 411370 without two processes holding it + // at once. Releases the ref taken by MainUi::steam(); the in-game leg + // re-establishes the P2P connection by SteamID. + if ( clientMode.isSteam() ) + SteamManager::get().deref(); +#endif } DWORD val = GetFileAttributes ( ( ProcessManager::appDir + "framestep.dll" ).c_str() ); @@ -1448,6 +1515,21 @@ struct MainApp if ( clientMode.isBroadcast() && !isBroadcastPortReady ) return; +#ifdef ENABLE_STEAM + // Steam matches have no IP/port to share - show the lobby join code instead. + if ( clientMode.isSteam() ) + { + if ( clientMode.isHost() ) + { + ui.display ( format ( "Hosting via Steam%s\n\nJoin code: %s\n\nWaiting for opponent...", + ( clientMode.isTraining() ? " (training mode)" : "" ), + SteamManager::get().lobbyCode().c_str() ) ); + ui.hostReady(); + } + return; + } +#endif + const uint16_t port = ( clientMode.isBroadcast() ? netplayConfig.broadcastPort : address.port ); if ( ui.isServer() ) { ui.display ( format ( "%s at server%s\n", diff --git a/targets/MainUi.cpp b/targets/MainUi.cpp index 1b1440a9..5952b4d0 100644 --- a/targets/MainUi.cpp +++ b/targets/MainUi.cpp @@ -7,9 +7,19 @@ #include "CharacterSelect.hpp" #include "StringUtils.hpp" #include "NetplayStates.hpp" +#include "EventManager.hpp" +#include "Lobby.hpp" +#include "MatchmakingManager.hpp" +#ifdef ENABLE_STEAM +#include "SteamManager.hpp" +#include "SteamSocket.hpp" +#include "SteamLobby.hpp" +#include "SteamMatchmaking.hpp" +#endif #include #include +#include #include #include @@ -166,6 +176,13 @@ void MainUi::server ( RunFuncPtr run ) } void MainUi::wait() { +#ifdef ENABLE_STEAM + // The Steam lobby has no background thread to signal uiCondVar; it is pumped on this thread. + if ( _lobby && _lobby->isSteam() ) { + _lobby->pumpUntilSettled(); + return; + } +#endif while ( uiCondVar.wait ( uiMutex, 2500 ) ) { if ( ! EventManager::get().isRunning() ) { break; @@ -175,18 +192,31 @@ void MainUi::wait() { void MainUi::lobby( RunFuncPtr run ) { - _lobby.reset( new Lobby( this ) ); ifstream infile; - _lobby->_address = *serverList.cbegin(); + // AutoManager (managers initialized) BEFORE SteamManager::ref(), so the pump timer created + // inside ref() lives in an initialized TimerManager. AutoManager _; +#ifdef ENABLE_STEAM + // Prefer Steam lobbies when enabled + available; otherwise fall back to the relay server. + if ( _config.getInteger ( "useSteamLobby" ) && SteamManager::get().ref() ) + { + _lobby.reset ( new SteamLobby ( this ) ); + } + else +#endif + { + _lobby.reset ( new Lobby ( this ) ); + _lobby->_address = *serverList.cbegin(); + } + _lobby->start(); display ( "Connecting to server..." ); LOCK ( uiMutex ); wait(); _ui->pop(); - if ( ! EventManager::get().isRunning() ) { + if ( _lobby->needsEventManager() && ! EventManager::get().isRunning() ) { sessionError = "Failed to connect"; return; } @@ -206,13 +236,19 @@ void MainUi::lobby( RunFuncPtr run ) string lobbyBase = "Lobby"; string lobbyDisplay = lobbyBase; string name = _config.getString ( "displayName" ); + uint32_t myLastGen = 0; // last king-of-the-hill match generation we acted on (Steam queue) + QueueState qs; // current queue snapshot (Steam queue) for ( ;; ) { - if ( ! _lobby->isRunning() || ! EventManager::get().isRunning() ) { + if ( ! _lobby->isRunning() || ( _lobby->needsEventManager() && ! EventManager::get().isRunning() ) ) { LOG( "Lobby stopped" ); sessionError = "Disconnected!"; break; } + // Steam backend has no background thread; pull fresh lobby/member state on this thread. + _lobby->refresh(); + // If we own a king-of-the-hill lobby, advance the queue + publish the next assignment. + _lobby->coordinatorTick(); // Update ui LOG("Getting lobby mutex"); _lobby->entryMutex.lock(); @@ -220,8 +256,68 @@ void MainUi::lobby( RunFuncPtr run ) numEntries =_lobby->numEntries; lobbyIps =_lobby->getIps(); lobbyIds =_lobby->getIds(); + qs =_lobby->getQueueState(); _lobby->entryMutex.unlock(); LOG("releasing lobby mutex"); + +#ifdef ENABLE_STEAM + // King-of-the-hill auto-start: when the queue assigns us a role for a new match, launch it + // directly (no player selection). Un-readied players have role None and stay in the menu. + if ( _lobby->supportsQueue() && _lobby->mode == CONCERTO_LOBBY + && qs.phase == QueuePhase::Playing && qs.gen > myLastGen && qs.myRole != QueueRole::None ) + { + myLastGen = qs.gen; + lastMatchResult.valid = false; + initialConfig.mode.flags |= ClientMode::IsSteam; + + if ( qs.myRole == QueueRole::Host ) + { + initialConfig.mode.value = ClientMode::Host; + _address = IpAddrPort ( steamAddr ( SteamManager::get().getSteamID() ), ( uint16_t ) 0 ); + } + else if ( qs.myRole == QueueRole::Client ) + { + initialConfig.mode.value = ClientMode::Client; + _netplayConfig.clear(); + _address = qs.hostAddr; + } + else // Spectator + { + initialConfig.mode.value = ClientMode::SpectateNetplay; + _address = qs.hostAddr; + } + + addressSelected = true; + LOG ( "Queue gen=%u role=%d prerun", qs.gen, ( int ) qs.myRole ); + RUN ( _address, initialConfig ); + LOG ( "Queue gen=%u postrun", qs.gen ); + _ui->popNonUserInput(); + + // Players report the outcome so the owner can advance the queue. Always report a + // winner so the queue can never stall: a clean finish gives the real winner (both + // sides agree); a disconnect makes the surviving reporter the winner; bailing out + // mid-match with no result is a forfeit (the opponent wins). + if ( qs.myRole == QueueRole::Host || qs.myRole == QueueRole::Client ) + { + const uint64_t myId = SteamManager::get().getSteamID(); + const uint64_t oppId = ( qs.myRole == QueueRole::Host ) ? qs.clientId : qs.hostId; + uint64_t winner; + if ( lastMatchResult.valid ) + winner = lastMatchResult.hostWon ? qs.hostId : qs.clientId; + else if ( ! sessionError.empty() ) + winner = myId; // opponent dropped; I survived + else + winner = oppId; // I left mid-match without a result -> forfeit + if ( winner ) + _lobby->reportResult ( qs.gen, winner ); + } + + // A spectate session ending (the host tore down) surfaces a benign disconnect. + sessionError.clear(); + continue; + } +#endif + int oldPos = _ui->top()->getPosition(); _ui->pop(); _ui->pushInFront ( new ConsoleUi::Menu ( lobbyDisplay, @@ -301,7 +397,14 @@ void MainUi::lobby( RunFuncPtr run ) lobbyDisplay = " | Room | Players | Error: " + _lobby->lobbyError; } } else if ( _lobby->mode == CONCERTO_LOBBY ) { - if ( mode < 0 || mode >= numEntries ) { + if ( _lobby->supportsQueue() ) { + // King-of-the-hill queue UI: row 0 toggles our ready flag; Exit (negative) leaves. + // Matches auto-start above; informational rows do nothing. + if ( mode == 0 ) + _lobby->setReady ( ! qs.iAmReady ); + else if ( mode < 0 ) + break; + } else if ( mode < 0 || mode >= numEntries ) { break; } else { if ( lobbyIps[ mode ] == "None" ) { @@ -316,6 +419,12 @@ void MainUi::lobby( RunFuncPtr run ) wait(); if ( _lobby->hostSuccess ) { initialConfig.mode.value = ClientMode::Host; +#ifdef ENABLE_STEAM + if ( _lobby->isSteam() ) { + initialConfig.mode.flags |= ClientMode::IsSteam; + _address = IpAddrPort ( steamAddr ( SteamManager::get().getSteamID() ), ( uint16_t ) 0 ); + } +#endif addressSelected = true; LOG( "Lobby host Prerun"); RUN ( _address, initialConfig ); @@ -340,7 +449,14 @@ void MainUi::lobby( RunFuncPtr run ) _lobby->preaccept( lobbyIds[mode] ); initialConfig.mode.value = ClientMode::Client; _netplayConfig.clear(); - _address = lobbyIps[ mode ]; +#ifdef ENABLE_STEAM + if ( _lobby->isSteam() ) { + initialConfig.mode.flags |= ClientMode::IsSteam; + // lobbyIps[mode] is a "steam:" string; store it verbatim. + _address = IpAddrPort ( lobbyIps[ mode ], ( uint16_t ) 0 ); + } else +#endif + _address = lobbyIps[ mode ]; addressSelected = true; LOG( "Lobby join Prerun"); RUN ( _address, initialConfig ); @@ -400,16 +516,26 @@ void MainUi::lobby( RunFuncPtr run ) } } } - LOG( "Lobby Stopping EM"); - EventManager::get().stop(); - LOG( "Lobby after Stopping EM"); - if ( !addressSelected ) { - display ( "Disconnecting from server..." ); +#ifdef ENABLE_STEAM + if ( _lobby->isSteam() ) { + // No EventManager loop / background thread to wind down for the Steam backend. + if ( !addressSelected ) { + display ( "Leaving lobby..." ); + } + } else +#endif + { + LOG( "Lobby Stopping EM"); + EventManager::get().stop(); + LOG( "Lobby after Stopping EM"); + if ( !addressSelected ) { + display ( "Disconnecting from server..." ); + } + //LOCK ( uiMutex ); + LOG( "Lobby wait mutex"); + //if ( lo) + uiCondVar.wait ( uiMutex ); } - //LOCK ( uiMutex ); - LOG( "Lobby wait mutex"); - //if ( lo) - uiCondVar.wait ( uiMutex ); LOG( "Lobby mutex signaled"); _ui->pop(); LOG( "Lobby clear ui"); @@ -427,7 +553,17 @@ void MainUi::matchmaking( RunFuncPtr run ) isMatchmaking = true; ifstream infile; string region = _config.getString ( "matchmakingRegion" ); - _mmm.reset( new MatchmakingManager( this, *serverList.cbegin(), region ) ); + +#ifdef ENABLE_STEAM + // Prefer Steam matchmaking when enabled + available; otherwise fall back to the relay server. + // ref() here creates the pump timer; TimerManager::initialize() (run by AutoManager below) + // preserves already-allocated timers, so the pre-AutoManager order is fine. + if ( _config.getInteger ( "useSteamLobby" ) && SteamManager::get().ref() ) + _mmm.reset ( new SteamMatchmaking ( this, region ) ); + else +#endif + _mmm.reset( new MatchmakingManager( this, *serverList.cbegin(), region ) ); + AutoManager _ ( _mmm.get(), getConsoleWindow() ); _mmm->start(); @@ -435,26 +571,41 @@ void MainUi::matchmaking( RunFuncPtr run ) display ( "Connecting to server..." ); LOCK ( uiMutex ); LOG( "lockConnectionMutex"); - uiCondVar.wait ( uiMutex ); +#ifdef ENABLE_STEAM + if ( _mmm->isSteam() ) + _mmm->waitConnected(); + else +#endif + uiCondVar.wait ( uiMutex ); LOG( "unlockConnectionMutex"); _ui->pop(); - if ( ! EventManager::get().isRunning() || !_mmm->isRunning() ) { + if ( ( _mmm->needsEventManager() && ! EventManager::get().isRunning() ) || !_mmm->isRunning() ) { sessionError = "Failed to connect"; return; } display ( "Waiting for opponent..." ); LOG( "lockWaitingMutex"); - uiCondVar.wait ( uiMutex ); +#ifdef ENABLE_STEAM + if ( _mmm->isSteam() ) + _mmm->waitMatched(); + else +#endif + uiCondVar.wait ( uiMutex ); LOG( "unlockWaitingMutex"); _ui->pop(); - if ( ! EventManager::get().isRunning() ) { + if ( _mmm->needsEventManager() && ! EventManager::get().isRunning() ) { sessionError = "Disconnected"; return; } if ( initialConfig.mode.value == ClientMode::Client ) { _netplayConfig.clear(); +#ifdef ENABLE_STEAM + // _address was already set to steam: by setAddr(); just tag the session. + if ( _mmm->isSteam() ) + initialConfig.mode.flags |= ClientMode::IsSteam; +#endif _mmm->ignoreKb = true; LOG( "MMM preRun"); RUN ( _address, initialConfig ); @@ -462,7 +613,13 @@ void MainUi::matchmaking( RunFuncPtr run ) _ui->popNonUserInput(); } else if ( initialConfig.mode.value == ClientMode::Host ) { _netplayConfig.clear(); - _address = "46318"; +#ifdef ENABLE_STEAM + if ( _mmm->isSteam() ) { + initialConfig.mode.flags |= ClientMode::IsSteam; + _address = IpAddrPort ( steamAddr ( SteamManager::get().getSteamID() ), ( uint16_t ) 0 ); + } else +#endif + _address = "46318"; _mmm->ignoreKb = true; LOG( "MMM preRun"); RUN ( _address, initialConfig ); @@ -472,12 +629,19 @@ void MainUi::matchmaking( RunFuncPtr run ) sessionError = "Session Closed"; } - LOG( "MMM stopping EM"); - EventManager::get().stop(); - LOG( "MMM done" ); - display ( "Disconnecting from server..." ); - uiCondVar.wait ( uiMutex ); - _ui->pop(); +#ifdef ENABLE_STEAM + if ( _mmm->isSteam() ) { + // No EventManager loop / background thread to wind down for the Steam backend. + } else +#endif + { + LOG( "MMM stopping EM"); + EventManager::get().stop(); + LOG( "MMM done" ); + display ( "Disconnecting from server..." ); + uiCondVar.wait ( uiMutex ); + _ui->pop(); + } isMatchmaking = false; } @@ -979,6 +1143,9 @@ void MainUi::settings() "Trial Input Guide Settings", "Experimental Settings", "About", +#ifdef ENABLE_STEAM + "Lobby backend", +#endif }; _ui->pushRight ( new ConsoleUi::Menu ( "Settings", options, "Back" ) ); @@ -1413,6 +1580,25 @@ void MainUi::settings() system ( "@pause > nul" ); break; +#ifdef ENABLE_STEAM + case 14: + _ui->pushInFront ( new ConsoleUi::Menu ( "Lobby / Matchmaking backend", + { "Steam", "Relay server" }, "Cancel" ), + { 0, 0 }, true ); // Don't expand but DO clear top + + _ui->top()->setPosition ( _config.getInteger ( "useSteamLobby" ) ? 0 : 1 ); + _ui->popUntilUserInput(); + + if ( _ui->top()->resultInt >= 0 && _ui->top()->resultInt <= 1 ) + { + _config.setInteger ( "useSteamLobby", _ui->top()->resultInt == 0 ? 1 : 0 ); + saveConfig(); + } + + _ui->pop(); + break; +#endif + default: break; } @@ -1531,6 +1717,9 @@ void MainUi::initialize() _config.setInteger ( "frameLimiter", 0 ); _config.setInteger ( "stageAnimations", 1 ); _config.setString ( "matchmakingRegion", "NA West" ); + // Back the Server -> Lobby/Matchmaking menus with Steam when available (only meaningful in + // ENABLE_STEAM builds; falls back to the relay server if Steam can't initialize). + _config.setInteger ( "useSteamLobby", 1 ); _config.setDouble ( "heldStartDuration", 1.5 ); _config.setInteger ( "updateChannel", static_cast(MainUpdater::Channel::Stable ) ); _config.setInteger ( "trialScreenFlashColor", 0xff0000ff ); @@ -2014,7 +2203,9 @@ string MainUi::getUpdate ( bool isStartup ) return "Cannot fetch info for " + _updater.getTargetDescName() + " version"; } - if ( LocalVersion.isSimilar ( _updater.getTargetVersion(), ( _config.getInteger("updateChannel") - 1 ) ? 3 : 2 ) ) + // Tag-only comparison: GitHub release tags carry no revision/buildTime, so both channels + // compare at suffix level (2). The channel still drives prerelease filtering in MainUpdater. + if ( LocalVersion.isSimilar ( _updater.getTargetVersion(), 2 ) ) { _upToDate = true; if ( ! isStartup ) @@ -2261,7 +2452,7 @@ void MainUi::fetchProgress ( MainUpdater *updater, const MainUpdater::Type& type bar->update ( bar->length * progress ); } -void MainUi::connectionFailed ( Lobby *lobby ) +void MainUi::connectionFailed ( ILobbyBackend *lobby ) { LOG( "mainUI Lobby Connection Failed" ); EventManager::get().stop(); @@ -2269,14 +2460,14 @@ void MainUi::connectionFailed ( Lobby *lobby ) unlock( lobby ); } -void MainUi::unlock ( Lobby *lobby ) +void MainUi::unlock ( ILobbyBackend *lobby ) { LOG( "mainUI lobby unlock" ); LOCK ( uiMutex ); uiCondVar.signal(); } -void MainUi::connectionFailed ( MatchmakingManager *mmm ) +void MainUi::connectionFailed ( IMatchmakingBackend *mmm ) { LOG( "mainUI mmm Connection Failed" ); EventManager::get().stop(); @@ -2289,7 +2480,7 @@ void MainUi::connectionFailed ( MatchmakingManager *mmm ) } } -void MainUi::unlock ( MatchmakingManager *mmm ) +void MainUi::unlock ( IMatchmakingBackend *mmm ) { LOG( "mainUI mmm unlock" ); LOCK ( uiMutex ); @@ -2297,13 +2488,18 @@ void MainUi::unlock ( MatchmakingManager *mmm ) } -void MainUi::setAddr ( MatchmakingManager *mmm, string addr ) +void MainUi::setAddr ( IMatchmakingBackend *mmm, string addr ) { - _address = addr; + // A steam: address must be stored verbatim (the ':' would otherwise be parsed as a port); + // build the IpAddrPort directly like the Steam socket path does. + if ( addr.compare ( 0, 6, "steam:" ) == 0 ) + _address = IpAddrPort ( addr, ( uint16_t ) 0 ); + else + _address = addr; } -void MainUi::setMode ( MatchmakingManager *mmm, string mode ) +void MainUi::setMode ( IMatchmakingBackend *mmm, string mode ) { if ( mode == "Host" ) { initialConfig.mode.value = ClientMode::Host; diff --git a/targets/MainUi.hpp b/targets/MainUi.hpp index 85c1a514..86f881f3 100644 --- a/targets/MainUi.hpp +++ b/targets/MainUi.hpp @@ -6,8 +6,8 @@ #include "ControllerManager.hpp" #include "KeyValueStore.hpp" #include "MainUpdater.hpp" -#include "Lobby.hpp" -#include "MatchmakingManager.hpp" +#include "ILobbyBackend.hpp" +#include "IMatchmakingBackend.hpp" #include #include @@ -24,14 +24,26 @@ inline int computeDelay ( double latency ) } +// Outcome of the most recent match, populated from the DLL's MatchResult IPC message (see +// MainApp::ipcRead). The lobby queue reads this after a match's RUN() returns to advance the +// king-of-the-hill order. `valid` is cleared before each match and set when a result arrives. +struct MatchResultInfo +{ + bool valid = false; + bool hostWon = false; + uint8_t p1Wins = 0; + uint8_t p2Wins = 0; +}; + + class ConsoleUi; class MainUi : private Controller::Owner , private ControllerManager::Owner , private MainUpdater::Owner - , private Lobby::Owner - , private MatchmakingManager::Owner + , private ILobbyBackend::Owner + , private IMatchmakingBackend::Owner { public: @@ -45,6 +57,10 @@ class MainUi std::vector lobbyIps; std::vector lobbyIds; + // Most recent match outcome, written by MainApp from the DLL's MatchResult IPC message and + // read by the lobby queue after a match (mirrors how sessionError is plumbed from MainApp). + MatchResultInfo lastMatchResult; + MainUi(); void initialize(); @@ -81,9 +97,9 @@ class MainUi std::shared_ptr _ui; - std::shared_ptr _lobby; + std::shared_ptr _lobby; - std::shared_ptr _mmm; + std::shared_ptr _mmm; MainUpdater _updater; @@ -150,12 +166,12 @@ class MainUi void fetchProgress ( MainUpdater *updater, const MainUpdater::Type& type, double progress ) override; - void connectionFailed ( Lobby *lobby ); - void unlock ( Lobby *lobby ); + void connectionFailed ( ILobbyBackend *lobby ) override; + void unlock ( ILobbyBackend *lobby ) override; - void connectionFailed( MatchmakingManager* lobby ); - void setAddr( MatchmakingManager* lobby, std::string addr ); - void setMode( MatchmakingManager* lobby, std::string mode ); - void unlock( MatchmakingManager* lobby ); + void connectionFailed( IMatchmakingBackend* lobby ) override; + void setAddr( IMatchmakingBackend* lobby, std::string addr ) override; + void setMode( IMatchmakingBackend* lobby, std::string mode ) override; + void unlock( IMatchmakingBackend* lobby ) override; }; diff --git a/targets/MainUpdater.cpp b/targets/MainUpdater.cpp index 13933018..9f04d95d 100644 --- a/targets/MainUpdater.cpp +++ b/targets/MainUpdater.cpp @@ -2,10 +2,17 @@ #include "Logger.hpp" #include "ProcessManager.hpp" +// rapidjson (vendored by cereal) is included before so the windows min/max macros +// don't clash with its templates. +#include + #include #include +#include +#include #include +#include using namespace std; @@ -13,14 +20,165 @@ using namespace std; // Main update archive file name #define UPDATE_ARCHIVE_FILE "update.zip" -// Timeout for update version check -#define VERSION_CHECK_TIMEOUT ( 1000 ) +// GitHub repository the updater pulls releases from. +#define GITHUB_OWNER "SkyLeite" +#define GITHUB_REPO "CCCaster" +#define GITHUB_RELEASES_URL "https://api.github.com/repos/" GITHUB_OWNER "/" GITHUB_REPO "/releases?per_page=30" + +// Reported to GitHub; the API rejects requests without a User-Agent. +#define UPDATER_USER_AGENT "CCCaster-Updater" + +// Timeouts (ms): the version check is small, the archive can be several MB. +#define VERSION_CHECK_TIMEOUT ( 5000 ) +#define ARCHIVE_TIMEOUT ( 30000 ) + +// How often the event-loop timer polls the worker thread. +#define POLL_INTERVAL_MS ( 50 ) + + +namespace +{ +// Strip a leading 'v'/'V' from a release tag (e.g. "v4.0" -> "4.0") to get a Version code. +string stripTagPrefix ( const string& tag ) +{ + if ( ! tag.empty() && ( tag[0] == 'v' || tag[0] == 'V' ) ) + return tag.substr ( 1 ); + return tag; +} -static const vector updateServers = +// Blocking HTTPS GET via WinINet (handles TLS + redirects). Returns true on HTTP 200, with the +// body in outBody. Mirrors the WinINet usage in lib/SentryClient.cpp. +bool httpsGet ( const string& url, const string& headers, string& outBody, int& statusCode, uint32_t timeoutMs ) { - "http://150.230.44.244/" -}; + outBody.clear(); + statusCode = 0; + + HINTERNET hInet = InternetOpenA ( UPDATER_USER_AGENT, INTERNET_OPEN_TYPE_PRECONFIG, 0, 0, 0 ); + if ( ! hInet ) + { + LOG ( "InternetOpen failed err=%lu", GetLastError() ); + return false; + } + + DWORD t = timeoutMs; + InternetSetOption ( hInet, INTERNET_OPTION_CONNECT_TIMEOUT, &t, sizeof ( t ) ); + InternetSetOption ( hInet, INTERNET_OPTION_SEND_TIMEOUT, &t, sizeof ( t ) ); + InternetSetOption ( hInet, INTERNET_OPTION_RECEIVE_TIMEOUT, &t, sizeof ( t ) ); + + const DWORD flags = INTERNET_FLAG_SECURE | INTERNET_FLAG_RELOAD | INTERNET_FLAG_NO_CACHE_WRITE + | INTERNET_FLAG_NO_UI | INTERNET_FLAG_KEEP_CONNECTION; + + HINTERNET hUrl = InternetOpenUrlA ( hInet, url.c_str(), + headers.empty() ? 0 : headers.c_str(), + headers.empty() ? 0 : ( DWORD ) headers.size(), + flags, 0 ); + if ( ! hUrl ) + { + LOG ( "InternetOpenUrl failed err=%lu url=%s", GetLastError(), url ); + InternetCloseHandle ( hInet ); + return false; + } + + DWORD status = 0, len = sizeof ( status ); + HttpQueryInfoA ( hUrl, HTTP_QUERY_STATUS_CODE | HTTP_QUERY_FLAG_NUMBER, &status, &len, 0 ); + statusCode = ( int ) status; + + char buf[8192]; + DWORD read = 0; + while ( InternetReadFile ( hUrl, buf, sizeof ( buf ), &read ) && read > 0 ) + outBody.append ( buf, read ); + + InternetCloseHandle ( hUrl ); + InternetCloseHandle ( hInet ); + + return ( statusCode == 200 ); +} + +// Blocking HTTPS download to a file, reporting progress via onProgress(downloaded, total). +bool httpsDownload ( const string& url, const string& filePath, uint32_t timeoutMs, + const function& onProgress ) +{ + HINTERNET hInet = InternetOpenA ( UPDATER_USER_AGENT, INTERNET_OPEN_TYPE_PRECONFIG, 0, 0, 0 ); + if ( ! hInet ) + { + LOG ( "InternetOpen failed err=%lu", GetLastError() ); + return false; + } + + DWORD t = timeoutMs; + InternetSetOption ( hInet, INTERNET_OPTION_CONNECT_TIMEOUT, &t, sizeof ( t ) ); + InternetSetOption ( hInet, INTERNET_OPTION_SEND_TIMEOUT, &t, sizeof ( t ) ); + InternetSetOption ( hInet, INTERNET_OPTION_RECEIVE_TIMEOUT, &t, sizeof ( t ) ); + + const DWORD flags = INTERNET_FLAG_SECURE | INTERNET_FLAG_RELOAD | INTERNET_FLAG_NO_CACHE_WRITE + | INTERNET_FLAG_NO_UI | INTERNET_FLAG_KEEP_CONNECTION; + + HINTERNET hUrl = InternetOpenUrlA ( hInet, url.c_str(), 0, 0, flags, 0 ); + if ( ! hUrl ) + { + LOG ( "InternetOpenUrl failed err=%lu url=%s", GetLastError(), url ); + InternetCloseHandle ( hInet ); + return false; + } + + DWORD status = 0, len = sizeof ( status ); + HttpQueryInfoA ( hUrl, HTTP_QUERY_STATUS_CODE | HTTP_QUERY_FLAG_NUMBER, &status, &len, 0 ); + + if ( status != 200 ) + { + LOG ( "Download HTTP %lu for %s", status, url ); + InternetCloseHandle ( hUrl ); + InternetCloseHandle ( hInet ); + return false; + } + + DWORD total = 0; + len = sizeof ( total ); + HttpQueryInfoA ( hUrl, HTTP_QUERY_CONTENT_LENGTH | HTTP_QUERY_FLAG_NUMBER, &total, &len, 0 ); + + ofstream out ( filePath.c_str(), ios::binary ); + if ( ! out ) + { + LOG ( "Could not open for write: %s", filePath ); + InternetCloseHandle ( hUrl ); + InternetCloseHandle ( hInet ); + return false; + } + + char buf[16384]; + DWORD read = 0; + uint32_t done = 0; + bool ok = true; + + for ( ;; ) + { + if ( ! InternetReadFile ( hUrl, buf, sizeof ( buf ), &read ) ) + { + LOG ( "InternetReadFile failed err=%lu", GetLastError() ); + ok = false; + break; + } + + if ( read == 0 ) + break; + + out.write ( buf, read ); + done += read; + + if ( onProgress ) + onProgress ( done, total ); + } + + out.close(); + + InternetCloseHandle ( hUrl ); + InternetCloseHandle ( hInet ); + + return ok; +} + +} // anonymous namespace MainUpdater::MainUpdater ( Owner *owner ) : owner ( owner ) @@ -43,50 +201,250 @@ void MainUpdater::fetch ( const Type& type ) { _type = type; - _currentServerIdx = 0; + { + LOCK ( _mutex ); + _done = false; + _success = false; + _progDone = 0; + _progTotal = 0; + } - doFetch ( type ); + if ( type == Type::Version ) + { + _targetVersion.clear(); + _changelogBody.clear(); + _assetUrl.clear(); + } + + // Run the blocking WinINet work on a worker thread, then poll it from the event loop so the + // owner callbacks (and progress bar) stay on the main thread. + _worker.reset ( new FetchThread ( *this, type ) ); + _worker->start(); + + _pollTimer.reset ( new Timer ( this ) ); + _pollTimer->start ( POLL_INTERVAL_MS ); } -void MainUpdater::doFetch ( const Type& type ) +void MainUpdater::timerExpired ( Timer *timer ) { - string url = updateServers[_currentServerIdx]; + ASSERT ( _pollTimer.get() == timer ); + + bool done, success; + uint32_t progDone, progTotal; + + { + LOCK ( _mutex ); + done = _done; + success = _success; + progDone = _progDone; + progTotal = _progTotal; + } + + if ( ! done ) + { + if ( owner && progTotal > 0 ) + owner->fetchProgress ( this, _type, double ( progDone ) / progTotal ); + + _pollTimer->start ( POLL_INTERVAL_MS ); + return; + } + + _pollTimer.reset(); + + if ( _worker ) + { + _worker->join(); + _worker.reset(); + } + + if ( ! owner ) + return; + + if ( success ) + owner->fetchCompleted ( this, _type ); + else + owner->fetchFailed ( this, _type ); +} + +void MainUpdater::runFetch ( const Type& type ) +{ + bool ok = false; switch ( type.value ) { case Type::Version: - _targetVersion.code = ""; - url += getVersionFilePath(); - _httpGet.reset ( new HttpGet ( this, url, VERSION_CHECK_TIMEOUT ) ); - _httpGet->start(); + ok = fetchVersion(); break; case Type::ChangeLog: - url += CHANGELOG; - _httpDownload.reset ( new HttpDownload ( this, url, _downloadDir + CHANGELOG ) ); - _httpDownload->start(); + ok = writeChangeLog(); break; case Type::Archive: - if ( _targetVersion.empty() ) + ok = downloadArchive(); + break; + + default: + break; + } + + LOCK ( _mutex ); + _success = ok; + _done = true; +} + +bool MainUpdater::fetchVersion() +{ + static const string headers = + "Accept: application/vnd.github+json\r\n" + "X-GitHub-Api-Version: 2022-11-28\r\n" + "Accept-Encoding: identity\r\n"; + + string body; + int status = 0; + + if ( ! httpsGet ( GITHUB_RELEASES_URL, headers, body, status, VERSION_CHECK_TIMEOUT ) ) + { + LOG ( "Failed to fetch GitHub releases (status=%d)", status ); + return false; + } + + return parseReleases ( body ); +} + +bool MainUpdater::parseReleases ( const string& body ) +{ + // This (cereal-vendored) rapidjson predates the modern API: Parse needs explicit flags and + // arrays are walked with Begin()/End() rather than GetArray(). + rapidjson::Document doc; + doc.Parse<0> ( body.c_str() ); + + if ( doc.HasParseError() || ! doc.IsArray() ) + { + LOG ( "Could not parse releases JSON" ); + return false; + } + + // Pick the Nth release matching the channel (newest-first): Latest=0, Previous=1. + const unsigned wantIndex = ( _temporal == Temporal::Previous ) ? 1 : 0; + unsigned matched = 0; + + for ( auto it = doc.Begin(); it != doc.End(); ++it ) + { + rapidjson::Value& rel = *it; + + if ( ! rel.IsObject() ) + continue; + + // This rapidjson exposes IsTrue()/IsFalse() rather than IsBool()/GetBool(); IsTrue() is + // exactly the "present and true" test we want. + const bool draft = rel.HasMember ( "draft" ) && rel["draft"].IsTrue(); + if ( draft ) + continue; + + const bool prerelease = rel.HasMember ( "prerelease" ) && rel["prerelease"].IsTrue(); + + // Stable channel skips prereleases; Dev channel keeps them. + if ( _channel == Channel::Stable && prerelease ) + continue; + + if ( matched++ != wantIndex ) + continue; + + if ( ! rel.HasMember ( "tag_name" ) || ! rel["tag_name"].IsString() ) + { + LOG ( "Matched release has no tag_name" ); + return false; + } + + const string code = stripTagPrefix ( rel["tag_name"].GetString() ); + + _targetVersion = Version ( code ); + + _changelogBody.clear(); + if ( rel.HasMember ( "body" ) && rel["body"].IsString() ) + _changelogBody = rel["body"].GetString(); + + // Choose the release asset: prefer cccaster.v.zip, else the first .zip. + _assetUrl.clear(); + const string preferred = format ( "cccaster.v%s.zip", code ); + + if ( rel.HasMember ( "assets" ) && rel["assets"].IsArray() ) + { + rapidjson::Value& assets = rel["assets"]; + + for ( auto a = assets.Begin(); a != assets.End(); ++a ) { - std::string name = getTargetDescName(); - name[0] = std::toupper(name[0]); - LOG(name + " version is unknown"); + rapidjson::Value& asset = *a; + + if ( ! asset.IsObject() + || ! asset.HasMember ( "name" ) || ! asset["name"].IsString() + || ! asset.HasMember ( "browser_download_url" ) || ! asset["browser_download_url"].IsString() ) + continue; - if ( owner ) - owner->fetchFailed ( this, Type::Archive ); - return; + const string name = asset["name"].GetString(); + const string url = asset["browser_download_url"].GetString(); + + if ( name == preferred ) + { + _assetUrl = url; + break; + } + + if ( _assetUrl.empty() && name.size() >= 4 && name.compare ( name.size() - 4, 4, ".zip" ) == 0 ) + _assetUrl = url; } + } - url += format ( "cccaster.v%s.zip", _targetVersion.code ); - _httpDownload.reset ( new HttpDownload ( this, url, _downloadDir + UPDATE_ARCHIVE_FILE ) ); - _httpDownload->start(); - break; + LOG ( "Resolved %s: code=%s asset=%s", getTargetDescName(), code, _assetUrl ); - default: - break; + return ( ! _targetVersion.major().empty() && ! _targetVersion.minor().empty() ); } + + LOG ( "No matching release for %s", getTargetDescName() ); + return false; +} + +bool MainUpdater::writeChangeLog() +{ + if ( _changelogBody.empty() ) + { + LOG ( "No changelog body cached" ); + return false; + } + + const string path = _downloadDir + CHANGELOG; + + ofstream out ( path.c_str(), ios::binary ); + if ( ! out ) + { + LOG ( "Could not write changelog: %s", path ); + return false; + } + + out.write ( _changelogBody.data(), _changelogBody.size() ); + out.close(); + + return true; +} + +bool MainUpdater::downloadArchive() +{ + if ( _targetVersion.empty() || _assetUrl.empty() ) + { + std::string name = getTargetDescName(); + name[0] = std::toupper ( name[0] ); + LOG ( name + " has no downloadable archive" ); + return false; + } + + return httpsDownload ( _assetUrl, _downloadDir + UPDATE_ARCHIVE_FILE, ARCHIVE_TIMEOUT, + [this] ( uint32_t done, uint32_t total ) + { + LOCK ( _mutex ); + _progDone = done; + _progTotal = total; + } ); } bool MainUpdater::openChangeLog() const @@ -157,31 +515,6 @@ bool MainUpdater::extractArchive() const return true; } -std::string MainUpdater::getVersionFilePath() const -{ - switch (_channel.value) { - case Channel::Stable: - switch (_temporal.value) { - case Temporal::Latest: - return "LatestVersion"; - case Temporal::Previous: - return "PreviousVersion"; - default: break; - } - case Channel::Dev: - switch (_temporal.value) { - case Temporal::Latest: - return "LatestVersionDev"; - case Temporal::Previous: - return "PreviousVersionDev"; - default: break; - } - break; - default: break; - } - return "LatestVersion"; -} - std::string MainUpdater::getChannelName() const { switch (_channel.value) { @@ -204,102 +537,3 @@ std::string MainUpdater::getTargetDescName() const { return getTemporalName() + " " + getChannelName(); } - -void MainUpdater::httpResponse ( HttpGet *httpGet, int code, const string& data, uint32_t remainingBytes ) -{ - ASSERT ( _httpGet.get() == httpGet ); - ASSERT ( _type == Type::Version ); - - Version version; - vector versiondata; - switch (_channel.value) { - case Channel::Stable: - version = Version( trimmed ( data ) ); - break; - case Channel::Dev: - versiondata = split( data, "\n" ); - LOG( versiondata[0] ); - LOG( versiondata[1] ); - LOG( versiondata[2] ); - version = Version ( trimmed ( versiondata[0] ), - trimmed ( versiondata[1] ), - trimmed ( versiondata[2] ) ); - break; - default: - version = Version( trimmed ( data ) ); - break; - } - - if ( code != 200 || version.major().empty() || version.minor().empty() ) - { - httpFailed ( httpGet ); - return; - } - - _httpGet.reset(); - - _targetVersion = version; - - if ( owner ) - owner->fetchCompleted ( this, Type::Version ); -} - -void MainUpdater::httpFailed ( HttpGet *httpGet ) -{ - ASSERT ( _httpGet.get() == httpGet ); - ASSERT ( _type == Type::Version ); - - _httpGet.reset(); - - ++_currentServerIdx; - - if ( _currentServerIdx >= updateServers.size() ) - { - if ( owner ) - owner->fetchFailed ( this, Type::Version ); - return; - } - - doFetch ( Type::Version ); -} - -void MainUpdater::downloadComplete ( HttpDownload *httpDownload ) -{ - ASSERT ( _httpDownload.get() == httpDownload ); - ASSERT ( _type == Type::ChangeLog || _type == Type::Archive ); - - _httpDownload.reset(); - - if ( owner ) - owner->fetchCompleted ( this, _type ); -} - -void MainUpdater::downloadFailed ( HttpDownload *httpDownload ) -{ - ASSERT ( _httpDownload.get() == httpDownload ); - ASSERT ( _type == Type::ChangeLog || _type == Type::Archive ); - - _httpDownload.reset(); - - ++_currentServerIdx; - - if ( _currentServerIdx >= updateServers.size() ) - { - if ( owner ) - owner->fetchFailed ( this, _type ); - return; - } - - doFetch ( _type ); - - // Reset the download progress to zero - downloadProgress ( _httpDownload.get(), 0, 1 ); -} - -void MainUpdater::downloadProgress ( HttpDownload *httpDownload, uint32_t downloadedBytes, uint32_t totalBytes ) -{ - if ( owner ) - owner->fetchProgress ( this, _type, double ( downloadedBytes ) / totalBytes ); - - LOG ( "%u / %u", downloadedBytes, totalBytes ); -} diff --git a/targets/MainUpdater.hpp b/targets/MainUpdater.hpp index 998fcbc5..d4321854 100644 --- a/targets/MainUpdater.hpp +++ b/targets/MainUpdater.hpp @@ -1,16 +1,15 @@ #pragma once #include "Enum.hpp" -#include "HttpDownload.hpp" -#include "HttpGet.hpp" +#include "Thread.hpp" +#include "Timer.hpp" #include "Version.hpp" #include class MainUpdater - : private HttpDownload::Owner - , private HttpGet::Owner + : private Timer::Owner { public: @@ -39,8 +38,6 @@ class MainUpdater bool extractArchive() const; - std::string getVersionFilePath() const; - void setChannel(const Channel& channel) { _channel = channel; } void setTemporal(const Temporal& temporal) { _temporal = temporal; } @@ -54,13 +51,20 @@ class MainUpdater private: - Type _type; + // Worker thread that runs the (blocking) WinINet operation off the event loop. + struct FetchThread : public Thread + { + MainUpdater& updater; + Type type; - std::shared_ptr _httpGet; + FetchThread ( MainUpdater& updater, const Type& type ) : updater ( updater ), type ( type ) {} - std::shared_ptr _httpDownload; + void run() override { updater.runFetch ( type ); } + }; - uint32_t _currentServerIdx = 0; + friend struct FetchThread; + + Type _type; Channel _channel; @@ -68,15 +72,37 @@ class MainUpdater Version _targetVersion; + // Changelog body and download URL cached from the most recent Version fetch. + std::string _changelogBody; + + std::string _assetUrl; + std::string _downloadDir; - void doFetch ( const Type& type ); + // Worker thread + the state it hands back to the polling timer (guarded by _mutex). + ThreadPtr _worker; + + TimerPtr _pollTimer; + + mutable Mutex _mutex; + + bool _done = false; + + bool _success = false; + + uint32_t _progDone = 0, _progTotal = 0; + + // Runs on the worker thread. + void runFetch ( const Type& type ); + + bool fetchVersion(); + + bool parseReleases ( const std::string& body ); + + bool writeChangeLog(); - void httpResponse ( HttpGet *httpGet, int code, const std::string& data, uint32_t remainingBytes ) override; - void httpFailed ( HttpGet *httpGet ) override; - void httpProgress ( HttpGet *httpGet, uint32_t receivedBytes, uint32_t totalBytes ) override {} + bool downloadArchive(); - void downloadComplete ( HttpDownload *httpDownload ) override; - void downloadFailed ( HttpDownload *httpDownload ) override; - void downloadProgress ( HttpDownload *httpDownload, uint32_t downloadedBytes, uint32_t totalBytes ) override; + // Runs on the event loop; polls the worker and dispatches owner callbacks. + void timerExpired ( Timer *timer ) override; }; diff --git a/tests/Test.LobbyQueue.cpp b/tests/Test.LobbyQueue.cpp new file mode 100644 index 00000000..7f8ee91d --- /dev/null +++ b/tests/Test.LobbyQueue.cpp @@ -0,0 +1,100 @@ +#ifndef RELEASE + +#include "LobbyQueue.hpp" + +#include + +#include + +using namespace std; + + +// Convenience: all of {1,2,3,...} readied, in the given order. +static vector ids ( initializer_list l ) { return vector ( l ); } + + +TEST ( LobbyQueue, WinnerStaysLoserToBack ) +{ + // 1 (king) beats 2; 1 stays at the front, 2 drops behind 3. + const vector order = ids ( { 1, 2, 3 } ); + const vector ready = ids ( { 1, 2, 3 } ); + + const vector next = LobbyQueue::advance ( order, /*host*/ 1, /*client*/ 2, /*winner*/ 1, ready ); + + EXPECT_EQ ( next, ids ( { 1, 3, 2 } ) ); +} + + +TEST ( LobbyQueue, ChallengerDethronesKing ) +{ + // 2 beats the king 1; 2 becomes the new king, 1 drops to the back behind 3. + const vector order = ids ( { 1, 2, 3 } ); + const vector ready = ids ( { 1, 2, 3 } ); + + const vector next = LobbyQueue::advance ( order, 1, 2, /*winner*/ 2, ready ); + + EXPECT_EQ ( next, ids ( { 2, 3, 1 } ) ); +} + + +TEST ( LobbyQueue, LoserUnreadiedIsDropped ) +{ + // 2 loses and un-readies before the next round; it disappears from the queue entirely. + const vector order = ids ( { 1, 2, 3 } ); + const vector ready = ids ( { 1, 3 } ); + + const vector next = LobbyQueue::advance ( order, 1, 2, 1, ready ); + + EXPECT_EQ ( next, ids ( { 1, 3 } ) ); +} + + +TEST ( LobbyQueue, NewJoinerAppendedAheadOfLoser ) +{ + // 4 readied up during the match; it should sit ahead of the just-demoted loser (2). + const vector order = ids ( { 1, 2, 3 } ); + const vector ready = ids ( { 1, 2, 3, 4 } ); + + const vector next = LobbyQueue::advance ( order, 1, 2, 1, ready ); + + EXPECT_EQ ( next, ids ( { 1, 3, 4, 2 } ) ); +} + + +TEST ( LobbyQueue, WinnerUnreadiedYieldsToField ) +{ + // The king 1 wins but un-readied; it is not re-seated and 3 leads the next field. + const vector order = ids ( { 1, 2, 3 } ); + const vector ready = ids ( { 2, 3 } ); + + const vector next = LobbyQueue::advance ( order, 1, 2, 1, ready ); + + EXPECT_EQ ( next, ids ( { 3, 2 } ) ); +} + + +TEST ( LobbyQueue, ReconcileDropsAndAppends ) +{ + // 2 left; 4 and 5 readied. Survivors keep order, newcomers append in ready order. + const vector order = ids ( { 1, 2, 3 } ); + const vector ready = ids ( { 3, 1, 4, 5 } ); + + const vector next = LobbyQueue::reconcile ( order, ready ); + + EXPECT_EQ ( next, ids ( { 1, 3, 4, 5 } ) ); +} + + +TEST ( LobbyQueue, TwoPlayersJustReplay ) +{ + // With exactly two ready players, the loser-to-back is the same pair again next round. + const vector order = ids ( { 1, 2 } ); + const vector ready = ids ( { 1, 2 } ); + + const vector next = LobbyQueue::advance ( order, 1, 2, 2, ready ); + + EXPECT_EQ ( next, ids ( { 2, 1 } ) ); +} + + +#endif // NOT RELEASE diff --git a/tools/Launcher.cpp b/tools/Launcher.cpp index 65913a03..a6ea5030 100644 --- a/tools/Launcher.cpp +++ b/tools/Launcher.cpp @@ -1,6 +1,8 @@ #define WIN32_LEAN_AND_MEAN #include +#include "SentryClient.hpp" + #include #include @@ -25,6 +27,8 @@ bool hookDLL ( const string& dll_path, const PROCESS_INFORMATION *pi ) char buffer[4096]; snprintf ( buffer, sizeof ( buffer ), "Could not create remote thread [%d].", ( int ) GetLastError() ); + SentryClient::captureMessage ( SentryClient::Level::Error, buffer ); + if ( popup_errors ) MessageBox ( 0, buffer, "launcher error", MB_OK ); @@ -46,6 +50,8 @@ bool hookDLL ( const string& dll_path, const PROCESS_INFORMATION *pi ) { TerminateProcess ( pi->hProcess, -1 ); + SentryClient::captureMessage ( SentryClient::Level::Error, "Could not hook dll" ); + if ( popup_errors ) MessageBox ( 0, "Could not hook dll", "launcher error", MB_OK ); return false; @@ -87,6 +93,8 @@ bool hook ( const string& exe_path, const string& dll_path, bool high_priority, snprintf ( buffer, sizeof ( buffer ), "Couldn't find exe='%s'\nError [%d].", exe_path.c_str(), ( int ) GetLastError() ); + SentryClient::captureMessage ( SentryClient::Level::Error, buffer ); + if ( popup_errors ) MessageBox ( 0, buffer, "launcher error", MB_OK ); return false; @@ -98,6 +106,8 @@ bool hook ( const string& exe_path, const string& dll_path, bool high_priority, snprintf ( buffer, sizeof ( buffer ), "Couldn't find dll='%s'\nError [%d].", dll_path.c_str(), ( int ) GetLastError() ); + SentryClient::captureMessage ( SentryClient::Level::Error, buffer ); + if ( popup_errors ) MessageBox ( 0, buffer, "launcher error", MB_OK ); return false; @@ -119,6 +129,8 @@ bool hook ( const string& exe_path, const string& dll_path, bool high_priority, snprintf ( buffer, sizeof ( buffer ), "exe='%s'\ndir='%s'\nCould not create process [%d].", exe_path.c_str(), dir_path.c_str(), ( int ) GetLastError() ); + SentryClient::captureMessage ( SentryClient::Level::Error, buffer ); + if ( popup_errors ) MessageBox ( 0, buffer, "launcher error", MB_OK ); return false; @@ -130,6 +142,8 @@ bool hook ( const string& exe_path, const string& dll_path, bool high_priority, DWORD address; if ( ! getbase ( pi.hProcess, &address, &orig_code ) ) { + SentryClient::captureMessage ( SentryClient::Level::Error, "Could not find entry point" ); + if ( popup_errors ) MessageBox ( 0, "Could not find entry point", "launcher error", MB_OK | MB_ICONEXCLAMATION ); return false; @@ -153,6 +167,8 @@ bool hook ( const string& exe_path, const string& dll_path, bool high_priority, Sleep ( 100 ); TerminateProcess ( pi.hProcess, -1 ); + SentryClient::captureMessage ( SentryClient::Level::Error, "Could not get thread context." ); + if ( popup_errors ) MessageBox ( 0, "Could not get thread context.", "launcher error", MB_OK ); return false; @@ -191,6 +207,11 @@ int main ( int argc, char *argv[] ) popup_errors = ( options.find ( "--popup_errors" ) != options.end() ); + // Crash/error reporting. No-op unless a DSN was baked in at build time. + SentryClient::init ( SENTRY_DSN, "", "", "" ); + SentryClient::setTag ( "component", "launcher" ); + SentryClient::installCrashHandler(); + // Create process and hook library. if ( argc > 2 && hook ( argv[1], argv[2], options.find ( "--high" ) != options.end(), options.find ( "--framestep" ) != options.end(), argv[3] ) ) return 0; diff --git a/vendor/steam_api.dll b/vendor/steam_api.dll new file mode 100644 index 00000000..b7ae7971 Binary files /dev/null and b/vendor/steam_api.dll differ diff --git a/vendor/steam_appid.txt b/vendor/steam_appid.txt new file mode 100644 index 00000000..3cff4177 --- /dev/null +++ b/vendor/steam_appid.txt @@ -0,0 +1 @@ +411370 \ No newline at end of file