Ctrl K
ComunidadGuías…

ParadoxRuntime/p2p/PlayerHost.cpp

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/*
 * Copyright (C) 2026 MysticFox / Pranav Karande (pranav158/Mystic-Paradox)
 * Licensed under the GNU Affero General Public License v3.0.
 *
 * SPDX-License-Identifier: AGPL-3.0-only
 * Additional terms under AGPLv3 Section 7 apply. See ADDITIONAL_TERMS.md.
 */

// Player-hosted (P2P) server mode: a game server the launcher runs on a player's PC for one hunt
// session (MYSTICPARADOX_AUTH_MODE=HUNT_CAPABILITY). It binds to loopback only, authenticates to the
// backend with a session-scoped host capability instead of the gameserver key, reports readiness and
// accepts its fence over launcher-owned named pipes, and feeds the optional anti-cheat module's Hunt
// Guard evidence producer (AntiCheatHooks.h).

#include <windows.h>
#include <algorithm>
#include <ranges>
#include <string>

#include "../RuntimeShared.h"
#include "../P2PHooks.h"
#include "AntiCheatHooks.h"

namespace State {
    // MYSTICPARADOX_AUTH_MODE=HUNT_CAPABILITY on a server (ClaimAuthMode).
    static bool Active = false;
    // The launcher's bootstrap environment was complete and valid (ReadLaunch).
    static bool BootstrapValid = false;
    static volatile LONG FenceRequested = 0;
    static volatile LONG FenceHandled = 0;
    static std::string SessionId;
    static std::string Generation;
    static std::string SessionKey;
    static std::string RuntimeCapability;
    static std::string ControlPipe;
    static DWORD ExpectedLauncherPid = 0;
    // Launcher-declared role (ReadHostedRole): MYSTICPARADOX_HOSTED_ROLE=HUB makes a City/TrainingGrounds server a
    // persistent hub; absent or HUNT keeps it disposable. The two numbers are meaningful for a HUB only.
    static bool HostedHub = false;
    static int MaxPlayers = 0;        // MYSTICPARADOX_MAX_PLAYERS 1..64, 0 = not set
    static int HubAfkSeconds = -1;    // MYSTICPARADOX_HUB_AFK_SECONDS 0..7200, -1 = not set
    // The first invalid role variable (a string literal; ReadLaunch runs under the loader lock and cannot log, so
    // StartServer reports it before ending the process).
    static const char* RoleProblem = nullptr;
}

static bool MpSafeIdentifier(const std::string& Value, size_t Minimum, size_t Maximum) {
    if (Value.size() < Minimum || Value.size() > Maximum) return false;
    for (const unsigned char Character : Value) {
        if ((Character >= 'a' && Character <= 'z') ||
            (Character >= 'A' && Character <= 'Z') ||
            (Character >= '0' && Character <= '9') || Character == '.' || Character == '_' || Character == '-') {
            continue;
        }
        return false;
    }
    return true;
}

static bool MpSafePipeName(const std::string& Value, const char* Prefix) {
    const std::string PrefixString(Prefix);
    return Value.starts_with(PrefixString) &&
        MpSafeIdentifier(Value.substr(PrefixString.size()), 16, 96);
}

enum class OptionalEnvironment { Absent, Valid, Invalid };

// An optional launch variable: Absent when it is not set, Invalid when it is set but empty, too long or carries a
// control character (MpReadEnvironment cannot tell these apart from an absent variable).
static OptionalEnvironment MpReadOptionalEnvironment(const char* Name, std::string& Value, size_t Maximum) {
    Value.clear();
    char Buffer[32]{};
    SetLastError(ERROR_SUCCESS);
    const DWORD Length = GetEnvironmentVariableA(Name, Buffer, static_cast<DWORD>(sizeof(Buffer)));
    if (Length == 0) {
        return GetLastError() == ERROR_ENVVAR_NOT_FOUND ? OptionalEnvironment::Absent : OptionalEnvironment::Invalid;
    }
    if (Length >= sizeof(Buffer) || Length > Maximum) return OptionalEnvironment::Invalid;
    Value.assign(Buffer, Length);
    for (const unsigned char Character : Value) {
        if (Character < 0x20 || Character == 0x7F) return OptionalEnvironment::Invalid;
    }
    return OptionalEnvironment::Valid;
}

// Plain decimal in [Minimum, Maximum]: digits only, no sign, no leading zero (except "0" itself), at most 5 digits.
static bool MpParseBoundedInteger(const std::string& Text, int Minimum, int Maximum, int& Out) {
    if (Text.empty() || Text.size() > 5 || (Text.size() > 1 && Text[0] == '0')) return false;
    int Value = 0;
    for (const unsigned char Character : Text) {
        if (Character < '0' || Character > '9') return false;
        Value = Value * 10 + (Character - '0');
    }
    if (Value < Minimum || Value > Maximum) return false;
    Out = Value;
    return true;
}

// MYSTICPARADOX_HOSTED_ROLE (HUNT | HUB, absent = HUNT), MYSTICPARADOX_MAX_PLAYERS (1..64) and
// MYSTICPARADOX_HUB_AFK_SECONDS (0..7200): each is optional, a present value must be valid. The numbers are checked
// for a HUNT role too (a launcher bug should fail loudly either way) but only a HUB uses them. False = invalid.
static bool ReadHostedRole() {
    State::HostedHub = false;
    State::MaxPlayers = 0;
    State::HubAfkSeconds = -1;
    State::RoleProblem = nullptr;
    std::string Text;

    switch (MpReadOptionalEnvironment("MYSTICPARADOX_HOSTED_ROLE", Text, 8)) {
    case OptionalEnvironment::Absent:
        break;
    case OptionalEnvironment::Valid:
        if (Text == "HUB") State::HostedHub = true;
        else if (Text != "HUNT") {
            State::RoleProblem = "MYSTICPARADOX_HOSTED_ROLE is neither HUNT nor HUB";
            return false;
        }
        break;
    case OptionalEnvironment::Invalid:
        State::RoleProblem = "MYSTICPARADOX_HOSTED_ROLE is set but empty or malformed";
        return false;
    }

    switch (MpReadOptionalEnvironment("MYSTICPARADOX_MAX_PLAYERS", Text, 5)) {
    case OptionalEnvironment::Absent:
        break;
    case OptionalEnvironment::Valid:
        if (!MpParseBoundedInteger(Text, 1, 64, State::MaxPlayers)) {
            State::RoleProblem = "MYSTICPARADOX_MAX_PLAYERS is not a whole number from 1 to 64";
            return false;
        }
        break;
    case OptionalEnvironment::Invalid:
        State::RoleProblem = "MYSTICPARADOX_MAX_PLAYERS is set but empty or malformed";
        return false;
    }

    switch (MpReadOptionalEnvironment("MYSTICPARADOX_HUB_AFK_SECONDS", Text, 5)) {
    case OptionalEnvironment::Absent:
        break;
    case OptionalEnvironment::Valid:
        if (!MpParseBoundedInteger(Text, 0, 7200, State::HubAfkSeconds)) {
            State::RoleProblem = "MYSTICPARADOX_HUB_AFK_SECONDS is not a whole number from 0 to 7200";
            return false;
        }
        break;
    case OptionalEnvironment::Invalid:
        State::RoleProblem = "MYSTICPARADOX_HUB_AFK_SECONDS is set but empty or malformed";
        return false;
    }
    return true;
}

static DWORD WINAPI PlayerHostControlThread(void*) {
    const std::string PipeName = State::ControlPipe;
    HANDLE Pipe = CreateNamedPipeA(
        PipeName.c_str(), PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE,
        PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS,
        1, 512, 512, 0, nullptr);
    if (Pipe == INVALID_HANDLE_VALUE) {
        MpLog("[PlayerHostControl] failed to create control pipe error=" + std::to_string(GetLastError()));
        InterlockedExchange(&State::FenceRequested, 1);
        return 1;
    }

    const BOOL Connected = ConnectNamedPipe(Pipe, nullptr) ? TRUE : (GetLastError() == ERROR_PIPE_CONNECTED);
    if (!Connected) {
        MpLog("[PlayerHostControl] launcher did not connect error=" + std::to_string(GetLastError()));
        CloseHandle(Pipe);
        InterlockedExchange(&State::FenceRequested, 1);
        return 1;
    }

    ULONG ClientPid = 0;
    if (!GetNamedPipeClientProcessId(Pipe, &ClientPid) || ClientPid != State::ExpectedLauncherPid) {
        MpLog("[PlayerHostControl] rejected unexpected client pid");
        DisconnectNamedPipe(Pipe);
        CloseHandle(Pipe);
        InterlockedExchange(&State::FenceRequested, 1);
        return 1;
    }

    char Buffer[512]{};
    DWORD Read = 0;
    const BOOL ReadOk = ReadFile(Pipe, Buffer, sizeof(Buffer) - 1, &Read, nullptr);
    const std::string Expected = "MYS1 FENCE session=" + State::SessionId +
        " generation=" + State::Generation + "\n";
    if (!ReadOk || Read == 0 || Read >= sizeof(Buffer) || std::string(Buffer, Read) != Expected) {
        MpLog("[PlayerHostControl] rejected malformed fence request");
        DisconnectNamedPipe(Pipe);
        CloseHandle(Pipe);
        InterlockedExchange(&State::FenceRequested, 1);
        return 1;
    }

    InterlockedExchange(&State::FenceRequested, 1);
    const std::string Response = "MYS1 FENCE_ACK session=" + State::SessionId +
        " generation=" + State::Generation + "\n";
    DWORD Written = 0;
    WriteFile(Pipe, Response.data(), static_cast<DWORD>(Response.size()), &Written, nullptr);
    FlushFileBuffers(Pipe);
    DisconnectNamedPipe(Pipe);
    CloseHandle(Pipe);
    MpLog("[PlayerHostControl] generation fenced by authenticated launcher pid");
    return 0;
}

// Headless player host: the process the launcher spawned with a valid bootstrap and -nullrhi (reported in [InitRole]).
static bool IsHeadlessPlayerHost() {
    static int s_cached = -1;
    if (s_cached < 0) {
        const bool amServer = Globals::AmServer;
        const bool isHuntCapability = State::Active;
        const bool bootstrapValid = State::BootstrapValid;
        const bool hasNullRhi = std::string(GetCommandLineA()).contains("-nullrhi");
        s_cached = (amServer && isHuntCapability && bootstrapValid && hasNullRhi) ? 1 : 0;
    }
    return s_cached == 1;
}

bool P2PHooks::ClaimAuthMode(const std::string& Value) {
    if (Value != "HUNT_CAPABILITY") return false;
    State::Active = true;
    return true;
}

void P2PHooks::ReadLaunch(int NumArgs, wchar_t** Args) {
    bool HasLoopbackMultihome = false;
    for (int Index = 1; Index < NumArgs; ++Index) {
        if (_wcsicmp(Args[Index], L"-MULTIHOME=127.0.0.1") == 0) {
            HasLoopbackMultihome = true;
        }
    }

    std::string BindAddress;
    std::string ReadyPipe;
    std::string LaunchId;
    std::string LauncherPid;
    State::BootstrapValid =
        NumArgs > 1 && std::wstring(Args[1]) == L"-" && HasLoopbackMultihome &&
        MpReadEnvironment("MYSTICPARADOX_BIND_ADDRESS", BindAddress, 15) && BindAddress == "127.0.0.1" &&
        MpReadEnvironment("MYSTICPARADOX_SESSION_ID", State::SessionId, 36) &&
        MpSafeIdentifier(State::SessionId, 36, 36) &&
        MpReadEnvironment("MYSTICPARADOX_SESSION_GENERATION", State::Generation, 10) &&
        !State::Generation.empty() &&
        std::ranges::all_of(State::Generation, [](unsigned char Character) { return Character >= '0' && Character <= '9'; }) &&
        MpReadEnvironment("MYSTICPARADOX_HOST_RUNTIME_CAPABILITY", State::RuntimeCapability, 43) &&
        MpSafeIdentifier(State::RuntimeCapability, 43, 43) &&
        MpReadEnvironment("MYSTICPARADOX_CONTROL_PIPE", State::ControlPipe, 240) &&
        MpSafePipeName(State::ControlPipe, "\\\\.\\pipe\\MysticInternalServer.") &&
        MpReadEnvironment("MYSTICPARADOX_READY_PIPE", ReadyPipe, 240) &&
        MpSafePipeName(ReadyPipe, "\\\\.\\pipe\\MysticInternalServer.") &&
        MpReadEnvironment("MYSTICPARADOX_GAMESERVER_LAUNCH_ID", LaunchId, 96) &&
        MpSafeIdentifier(LaunchId, 16, 96) &&
        MpReadEnvironment("MYSTICPARADOX_LAUNCHER_PID", LauncherPid, 10) &&
        std::ranges::all_of(LauncherPid, [](unsigned char Character) { return Character >= '0' && Character <= '9'; });
    if (State::BootstrapValid) {
        try {
            const unsigned long ParsedPid = std::stoul(LauncherPid);
            State::ExpectedLauncherPid = ParsedPid <= MAXDWORD ? static_cast<DWORD>(ParsedPid) : 0;
            State::BootstrapValid = State::ExpectedLauncherPid != 0;
        }
        catch (...) {
            State::BootstrapValid = false;
        }
    }
    // The launcher's hosted role (hub or hunt): an invalid value ends the process before listen like any other
    // invalid launch value. Read even when the checks above failed so StartServer can name every problem it knows.
    if (!ReadHostedRole()) State::BootstrapValid = false;
    State::SessionKey = State::SessionId + ":" + State::Generation;
}

void P2PHooks::InstallServerHooks() {
    // Evidence is produced only by a player-hosted process the launcher explicitly opted in, and only
    // when the anti-cheat module is compiled in.
    std::string HuntGuardEvents;
    const bool EnableHuntGuardEvents = State::Active
        && MpReadEnvironment("MYSTICPARADOX_HUNT_GUARD_EVENTS", HuntGuardEvents, 8)
        && HuntGuardEvents == "1";
    AntiCheatHooks::Initialize(EnableHuntGuardEvents, State::SessionKey);
    if (!AntiCheatHooks::Present()) {
        MpLog("[HuntGuard] anti-cheat module not built in; no evidence (unmonitored hunt, no rewards)");
    }
    else {
        MpLog(std::string("[HuntGuard] event pipe is ")
            + (EnableHuntGuardEvents ? "ENABLED (explicit opt-in)" : "disabled (safe default)"));
    }

    // [2026-10-10] The WebBrowserWidget (Chromium/CEF) startup skip that used to live here, for this headless host
    // only, is now installed for every server role by InstallServerWebBrowserSkip in dllmain.cpp.
}

std::string P2PHooks::DescribeRole() {
    return std::string(" BootstrapValid=") + (State::BootstrapValid ? "1" : "0")
        + " HeadlessPlayerHost=" + (IsHeadlessPlayerHost() ? "1" : "0")
        + " HostedRole=" + (State::HostedHub ? "HUB" : "HUNT");
}

bool P2PHooks::StartServer() {
    if (!State::BootstrapValid) {
        MpLog("[PlayerHostControl] invalid player-host bootstrap; terminating before listen");
        if (State::RoleProblem) MpLog(std::string("[HostedRole] invalid launcher role: ") + State::RoleProblem);
        TerminateProcess(GetCurrentProcess(), 0xE103);
        return false;
    }
    // The launcher's role decision, once, so a hub or hunt log always states which one the launcher asked for.
    if (State::HostedHub) {
        MpLog(std::string("[HostedRole] launcher role=HUB (persistent hub) maxPlayers=")
            + (State::MaxPlayers > 0 ? std::to_string(State::MaxPlayers) + " (launcher)"
                                     : std::string("HUB_MAX_PLAYERS.txt/default"))
            + " hubAfkSeconds="
            + (State::HubAfkSeconds >= 0 ? std::to_string(State::HubAfkSeconds) + " (launcher)"
                                         : std::string("HUB_AFK_TIMEOUT_SECONDS.txt/default")));
    }
    else {
        MpLog(std::string("[HostedRole] launcher role=HUNT (disposable)")
            + ((State::MaxPlayers > 0 || State::HubAfkSeconds >= 0)
                ? " - MYSTICPARADOX_MAX_PLAYERS / MYSTICPARADOX_HUB_AFK_SECONDS are set but ignored (they apply to a HUB only)"
                : ""));
    }
    HANDLE ControlThread = CreateThread(nullptr, 0, PlayerHostControlThread, nullptr, 0, nullptr);
    if (!ControlThread) {
        MpLog("[PlayerHostControl] control thread creation failed; terminating before listen");
        TerminateProcess(GetCurrentProcess(), 0xE104);
        return false;
    }
    CloseHandle(ControlThread);
    MpLog("[PlayerHostControl] control pipe worker started");

    if (!AntiCheatHooks::StartWorker()) {
        // A player-host process without its evidence worker has no completion authority.
        // Fail before listen instead of running a hunt that can never be validated.
        MpLog("[HuntGuard] evidence worker creation failed; terminating before listen");
        TerminateProcess(GetCurrentProcess(), 0xE105);
        return false;
    }
    if (AntiCheatHooks::Present()) MpLog("[HuntGuard] non-blocking evidence worker started");
    return true;
}

bool P2PHooks::PrepareListen(std::wstring& Host) {
    if (!State::Active) return true;
    std::string BindAddress;
    if (!MpReadEnvironment("MYSTICPARADOX_BIND_ADDRESS", BindAddress, 15) || BindAddress != "127.0.0.1") {
        MpLog("[Networking::Listen] player-host bind address is not loopback; refusing listen");
        return false;
    }
    // [2026-10-11 1.14.7] The socket's address is not the FURL host: UE binds the MULTIHOME= of the engine's
    // FCommandLine. The launcher passes -MULTIHOME=127.0.0.1 (ReadLaunch requires it), the core carries it into the
    // engine line (dllmain HookFCommandLineGet) and Networking::Listen refuses the listen unless that MULTIHOME is
    // exactly this Host - so pinning Host here is what enforces the loopback-only bind.
    Host = L"127.0.0.1";
    return true;
}

bool P2PHooks::ReportsReadiness() {
    return State::Active;
}

// The launcher, not DeployServer, waits for this process: report readiness on its private pipe.
bool P2PHooks::ReportReady(int Port) {
    std::string PipeName;
    std::string SessionId;
    std::string Generation;
    std::string LaunchId;
    const std::string PipePrefix = "\\\\.\\pipe\\MysticInternalServer.";
    if (!MpReadEnvironment("MYSTICPARADOX_READY_PIPE", PipeName, 240) || !MpSafePipeName(PipeName, PipePrefix.c_str()) ||
        !MpReadEnvironment("MYSTICPARADOX_SESSION_ID", SessionId, 36) || !MpSafeIdentifier(SessionId, 36, 36) ||
        !MpReadEnvironment("MYSTICPARADOX_SESSION_GENERATION", Generation, 10) || !MpSafeIdentifier(Generation, 1, 10) ||
        Generation.find_first_not_of("0123456789") != std::string::npos ||
        !MpReadEnvironment("MYSTICPARADOX_GAMESERVER_LAUNCH_ID", LaunchId, 96) || !MpSafeIdentifier(LaunchId, 16, 96)) {
        MpLog("[Networking::Listen] player-host readiness environment is invalid");
        return false;
    }

    // [2026-10-13 1.14.7] The first live host on 1.14.7 failed this open right after a successful wait, and the
    // error was not logged. Log it, and retry a busy pipe (another opener can take the single instance between the
    // wait and the open) for up to 5 s.
    HANDLE Pipe = INVALID_HANDLE_VALUE;
    DWORD OpenError = 0;
    const ULONGLONG OpenDeadline = GetTickCount64() + 5000;
    for (int Attempt = 1;; ++Attempt) {
        if (!WaitNamedPipeA(PipeName.c_str(), 5000)) {
            MpLog("[Networking::Listen] launcher readiness pipe did not become available error=" +
                std::to_string(GetLastError()) + " attempt=" + std::to_string(Attempt));
            return false;
        }
        Pipe = CreateFileA(PipeName.c_str(), GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
        if (Pipe != INVALID_HANDLE_VALUE) break;
        OpenError = GetLastError();
        MpLog("[Networking::Listen] launcher readiness pipe open failed error=" + std::to_string(OpenError) +
            " attempt=" + std::to_string(Attempt));
        if (OpenError != ERROR_PIPE_BUSY || GetTickCount64() >= OpenDeadline) return false;
        Sleep(100);
    }
    const std::string Message = "MYS1 READY session=" + SessionId + " generation=" + Generation +
        " launch=" + LaunchId + " port=" + std::to_string(Port) + " bind=127.0.0.1\n";
    DWORD Written = 0;
    const BOOL Ok = WriteFile(Pipe, Message.data(), static_cast<DWORD>(Message.size()), &Written, nullptr);
    FlushFileBuffers(Pipe);
    CloseHandle(Pipe);
    if (!Ok || Written != static_cast<DWORD>(Message.size())) {
        MpLog("[Networking::Listen] launcher readiness message write failed");
        return false;
    }
    MpLog("[Networking::Listen] player-host readiness acknowledged by launcher pipe");
    return true;
}

bool P2PHooks::TickShutdown() {
    if (InterlockedCompareExchange(&State::FenceRequested, 0, 0) == 0) return false;
    static ULONGLONG FenceStartedAt = 0;
    if (InterlockedCompareExchange(&State::FenceHandled, 1, 0) == 0) {
        FenceStartedAt = GetTickCount64();
        // [2026-10-11 1.14.7] On a server MpQuitGame ends the process at once with exit code 0 (the reap path; QuitGame
        // did nothing without a local PlayerController, so every fence used to end in the 0xE105 fallback below, which
        // stays for SERVER_QUITGAME_EXIT.flag).
        MpLog("[PlayerHostControl] applying fence on game thread; ending the process (MpQuitGame)");
        MpQuitGame();
    }
    else if (GetTickCount64() - FenceStartedAt >= 5000) {
        MpLog("[PlayerHostControl] graceful quit deadline elapsed; forcing process termination");
        TerminateProcess(GetCurrentProcess(), 0xE105);
    }
    return true;
}

void P2PHooks::OnListenFailed() {
    InterlockedExchange(&State::FenceRequested, 1);
}

// Player-hosted processes never receive the global gameserver credential. The launcher-provisioned
// capability is bound to this one host/session and is validated as ordinary player authority by the
// Metagame.
void P2PHooks::RequestHeader(std::wstring& Name, std::wstring& Value) {
    if (State::RuntimeCapability.size() != 43) return;
    Name = L"x-mystic-host-capability";
    Value.assign(State::RuntimeCapability.begin(), State::RuntimeCapability.end());
}

void P2PHooks::ObserveServerEvent(const std::string& FunctionName, void* Parms) {
    AntiCheatHooks::ObserveServerEvent(FunctionName, Parms);
}

// A hub role only counts for a valid bootstrap of this mode; every other process answers "no hub, no override".
bool P2PHooks::HostedRoleIsHub() {
    return State::Active && State::BootstrapValid && State::HostedHub;
}

int P2PHooks::MaxPlayersOverride() {
    return HostedRoleIsHub() ? State::MaxPlayers : 0;
}

int P2PHooks::HubAfkSecondsOverride() {
    return HostedRoleIsHub() ? State::HubAfkSeconds : -1;
}