From 34fa21139fafcdf8c17519b72b37f857cab48511 Mon Sep 17 00:00:00 2001 From: Gabe Date: Fri, 2 Oct 2026 15:44:12 -0500 Subject: [PATCH] Windows support: portable local server launch + clean quit, %APPDATA% data dir, Win32 libs, MinGW/MSVC build docs --- CMakeLists.txt | 25 +++++- README.md | 50 +++++++++++ client/App.cpp | 55 ++---------- client/App.h | 3 +- client/LocalServer.cpp | 195 +++++++++++++++++++++++++++++++++++++++++ client/LocalServer.h | 41 +++++++++ server/Log.h | 5 +- shared/Protocol.h | 5 +- 8 files changed, 326 insertions(+), 53 deletions(-) create mode 100644 client/LocalServer.cpp create mode 100644 client/LocalServer.h diff --git a/CMakeLists.txt b/CMakeLists.txt index b2d2971..def67e6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -41,12 +41,19 @@ target_include_directories(bs_shared PUBLIC shared) target_link_libraries(bs_shared PUBLIC glm::glm nlohmann_json::nlohmann_json enet) target_compile_definitions(bs_shared PUBLIC GLM_FORCE_RADIANS GLM_ENABLE_EXPERIMENTAL BS_ASSET_DIR="${CMAKE_SOURCE_DIR}/assets") +if(WIN32) + target_link_libraries(bs_shared PUBLIC ws2_32 winmm) # ENet sockets + timers +endif() if(MSVC) - target_compile_options(bs_shared PRIVATE /W4) - target_link_libraries(bs_shared PUBLIC ws2_32 winmm) + target_compile_options(bs_shared PRIVATE /W4 /utf-8) else() target_compile_options(bs_shared PRIVATE -Wall -Wextra -Wno-unused-parameter) endif() +if(MINGW) + # no libgcc / libstdc++ DLLs to ship next to the executables; C99 printf (%zu) on msvcrt + add_link_options(-static-libgcc -static-libstdc++) + target_compile_definitions(bs_shared PUBLIC __USE_MINGW_ANSI_STDIO=1) +endif() # --- Server ------------------------------------------------------------------- if(BS_BUILD_SERVER) @@ -54,7 +61,12 @@ if(BS_BUILD_SERVER) find_package(Threads REQUIRED) file(GLOB BS_SERVER_SOURCES CONFIGURE_DEPENDS server/*.cpp) add_executable(safari_server ${BS_SERVER_SOURCES}) - target_link_libraries(safari_server PRIVATE bs_shared SQLite::SQLite3 Threads::Threads) + if(TARGET SQLite3::SQLite3) # CMake >= 4.3 name; older releases only define SQLite::SQLite3 + set(BS_SQLITE SQLite3::SQLite3) + else() + set(BS_SQLITE SQLite::SQLite3) + endif() + target_link_libraries(safari_server PRIVATE bs_shared ${BS_SQLITE} Threads::Threads) if(NOT MSVC) target_compile_options(safari_server PRIVATE -Wall -Wextra -Wno-unused-parameter) endif() @@ -86,6 +98,13 @@ if(BS_BUILD_CLIENT) if(NOT MSVC) target_compile_options(safari_client PRIVATE -Wall -Wextra -Wno-unused-parameter) endif() + if(WIN32) + # A game, not a console tool: no console window opens next to it. + set_target_properties(safari_client PROPERTIES WIN32_EXECUTABLE TRUE) + if(MSVC) + target_link_options(safari_client PRIVATE /ENTRY:mainCRTStartup) # keep int main() + endif() + endif() # The client launches the dedicated server for "Host game" from its own folder. if(BS_BUILD_SERVER) add_dependencies(safari_client safari_server) diff --git a/README.md b/README.md index 985569f..f1edaea 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,56 @@ ninja -C build # safari_client, safari_server, safari_bot Server only (no GPU needed): `cmake -S . -B build_srv -G Ninja -DBS_BUILD_CLIENT=OFF`. +Built files load assets from the source checkout (`BS_ASSET_DIR` and the engine's asset path are +baked in at configure time), so run them where they were built. + +### Windows (MSYS2, recommended) + +Install [MSYS2](https://www.msys2.org/) and open the **MSYS2 UCRT64** shell: + +```sh +pacman -S --needed git mingw-w64-ucrt-x86_64-{gcc,cmake,ninja,pkgconf,glfw,glm,libepoxy,sqlite3,nlohmann-json} + +git clone ssh://git@git.gabjimmy.com:30009/Gabe/Vibe-HD-2D.git +git clone Brandling-Safari # side by side with Vibe-HD-2D +cd Brandling-Safari +cmake -S . -B build -G Ninja +ninja -C build # safari_client.exe, safari_server.exe, safari_bot.exe +./build/safari_client.exe +``` + +The executables find `glfw3.dll`, `libepoxy-0.dll`, `libsqlite3-0.dll` and +`libwinpthread-1.dll` on the UCRT64 shell's `PATH`. To start the game from Explorer or a shortcut, +copy them next to the executables once: + +```sh +ldd build/safari_client.exe build/safari_server.exe | grep -i /ucrt64/ | awk '{print $3}' | sort -u | xargs -I{} cp {} build/ +``` + +### Windows (Visual Studio + vcpkg) + +In an **x64 Native Tools Command Prompt for VS 2022**: + +```bat +git clone https://github.com/microsoft/vcpkg C:\vcpkg && C:\vcpkg\bootstrap-vcpkg.bat +C:\vcpkg\vcpkg install glfw3 glm libepoxy sqlite3 nlohmann-json --triplet x64-windows + +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo ^ + -DCMAKE_TOOLCHAIN_FILE=C:/vcpkg/scripts/buildsystems/vcpkg.cmake +cmake --build build +build\safari_client.exe +``` + +vcpkg copies the DLLs next to the executables. The MinGW build is verified (zero warnings); +the MSVC build has not been compiled yet, so expect possible small fixes there. + +Windows notes: the client has no console window. Your identity and a hosted game's `world.db` +and `server.log` live in `%APPDATA%\brandling-safari`. The first time a server runs, allow it +through Windows Defender Firewall on private networks so LAN players can join (UDP 27420 for the +game, 27421 for discovery). The art tools in `tools/` work on Windows with Python 3 (`pip install +requests pillow numpy`); only Company_Sprite_Gen's part-library generator (`pixellab_library.py`) +is Linux/macOS-only. + ## Playing ```sh diff --git a/client/App.cpp b/client/App.cpp index 64fe332..4fe83a0 100644 --- a/client/App.cpp +++ b/client/App.cpp @@ -9,18 +9,13 @@ #include #include #include -#include +#include #include #include #include #include #include #include -#include -#include -#include - -extern char** environ; namespace bs { @@ -29,15 +24,6 @@ namespace ui = hd2d::ui; namespace { -fs::path dataDir() { - const char* xdg = std::getenv("XDG_DATA_HOME"); - fs::path base = xdg && *xdg ? fs::path(xdg) : fs::path(std::getenv("HOME") ? std::getenv("HOME") : ".") / ".local/share"; - fs::path d = base / "brandling-safari"; - std::error_code ec; - fs::create_directories(d, ec); - return d; -} - std::string randomToken() { std::random_device rd; static const char* hex = "0123456789abcdef"; @@ -106,7 +92,7 @@ int keyCode(const std::string& k) { // ------------------------------------------------------------------ identity void App::loadIdentity() { - fs::path p = dataDir() / (opt_.profile.empty() ? "client.json" : "client-" + opt_.profile + ".json"); + fs::path p = userDataDir() / (opt_.profile.empty() ? "client.json" : "client-" + opt_.profile + ".json"); std::ifstream f(p); if (f) { try { @@ -126,7 +112,7 @@ void App::loadIdentity() { } void App::saveIdentity() { - fs::path p = dataDir() / (opt_.profile.empty() ? "client.json" : "client-" + opt_.profile + ".json"); + fs::path p = userDataDir() / (opt_.profile.empty() ? "client.json" : "client-" + opt_.profile + ".json"); nlohmann::json j = {{"token", token_}, {"lastHost", lastHost_}, {"lastPort", lastPort_}, {"name", savedName_}}; std::ofstream(p) << j.dump(2); } @@ -283,38 +269,13 @@ bool App::scripted(const char* key) const { return pressed(keyCode(key)); } // ------------------------------------------------------------------ local server bool App::startLocalServer() { - if (serverPid_ > 0) return true; - std::error_code ec; - fs::path exe = fs::read_symlink("/proc/self/exe", ec); - fs::path server = exe.parent_path() / "safari_server"; - if (ec || !fs::exists(server)) { - message_ = "safari_server was not found next to the game"; - return false; - } - std::string port = std::to_string(opt_.port); - std::string name = std::string(nameBuf_) + "'s Safari"; - std::vector args = {server.string(), "--port", port, "--name", name}; - std::vector argv; - for (std::string& a : args) argv.push_back(a.data()); - argv.push_back(nullptr); - pid_t pid = 0; - if (posix_spawn(&pid, server.c_str(), nullptr, nullptr, argv.data(), environ) != 0) { - message_ = "could not start the local server"; - return false; - } - serverPid_ = int(pid); - HD2D_LOG("started local server (pid %d) on port %s", serverPid_, port.c_str()); - usleep(250000); // let it bind before we connect (the client retries anyway) + if (localServer_.running()) return true; + if (!localServer_.start(opt_.port, std::string(nameBuf_) + "'s Safari", message_)) return false; + std::this_thread::sleep_for(std::chrono::milliseconds(250)); // let it bind (the client retries anyway) return true; } -void App::stopLocalServer() { - if (serverPid_ <= 0) return; - kill(pid_t(serverPid_), SIGTERM); - int status = 0; - for (int i = 0; i < 50 && waitpid(pid_t(serverPid_), &status, WNOHANG) == 0; ++i) usleep(100000); - serverPid_ = -1; -} +void App::stopLocalServer() { localServer_.stop(); } // ------------------------------------------------------------------ flow @@ -345,7 +306,7 @@ void App::handleNet() { NetClient::State st = net_.state(); if (st == NetClient::State::Failed && screen_ != Screen::Title && screen_ != Screen::Join) { // a freshly started local server may not be listening yet: retry for a few seconds - if (screen_ == Screen::Connecting && serverPid_ > 0 && connectTimer_ < 6.0f) { + if (screen_ == Screen::Connecting && localServer_.running() && connectTimer_ < 6.0f) { net_.connect(lastHost_, lastPort_, token_); return; } diff --git a/client/App.h b/client/App.h index 383dc80..24568f2 100644 --- a/client/App.h +++ b/client/App.h @@ -3,6 +3,7 @@ // creator, play) and the glue between NetClient, World and the HD-2D UI. #include "Common.h" +#include "LocalServer.h" #include "NetClient.h" #include "World.h" @@ -111,7 +112,7 @@ private: std::vector found_; float scanTimer_ = 0.0f; char addressBuf_[128] = "127.0.0.1"; - int serverPid_ = -1; + LocalServer localServer_; float connectTimer_ = 0.0f; // creator diff --git a/client/LocalServer.cpp b/client/LocalServer.cpp new file mode 100644 index 0000000..9563d54 --- /dev/null +++ b/client/LocalServer.cpp @@ -0,0 +1,195 @@ +#include "LocalServer.h" + +#include "hd2d/Log.h" + +#include +#include +#include +#include + +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#else +#include +#include +#include +#include +#include +extern char** environ; +#endif + +namespace bs { + +namespace fs = std::filesystem; + +fs::path userDataDir() { + fs::path base; +#if defined(_WIN32) + if (const char* a = std::getenv("APPDATA"); a && *a) base = a; +#else + if (const char* x = std::getenv("XDG_DATA_HOME"); x && *x) base = x; + else if (const char* h = std::getenv("HOME"); h && *h) base = fs::path(h) / ".local/share"; +#endif + if (base.empty()) base = "."; + fs::path d = base / "brandling-safari"; + std::error_code ec; + fs::create_directories(d, ec); + return d; +} + +fs::path executableDir() { +#if defined(_WIN32) + std::wstring buf(32768, L'\0'); + DWORD n = GetModuleFileNameW(nullptr, buf.data(), DWORD(buf.size())); + buf.resize(n); + return fs::path(buf).parent_path(); +#else + std::error_code ec; + fs::path exe = fs::read_symlink("/proc/self/exe", ec); + return ec ? fs::current_path() : exe.parent_path(); +#endif +} + +#if defined(_WIN32) + +namespace { +std::wstring quoteArg(const std::wstring& a) { + std::wstring out = L"\""; + for (wchar_t c : a) { + if (c == L'"') out += L'\\'; + out += c; + } + return out + L"\""; +} +std::wstring widen(const std::string& s) { + int n = MultiByteToWideChar(CP_UTF8, 0, s.data(), int(s.size()), nullptr, 0); + std::wstring w(size_t(n), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, s.data(), int(s.size()), w.data(), n); + return w; +} +} // namespace + +bool LocalServer::start(uint16_t port, const std::string& name, std::string& error) { + if (running()) return true; + fs::path server = executableDir() / "safari_server.exe"; + if (!fs::exists(server)) { + error = "safari_server.exe was not found next to the game"; + return false; + } + SECURITY_ATTRIBUTES sa{sizeof sa, nullptr, TRUE}; + HANDLE readEnd = nullptr, writeEnd = nullptr; + if (!CreatePipe(&readEnd, &writeEnd, &sa, 0)) { + error = "could not create a pipe for the local server"; + return false; + } + SetHandleInformation(writeEnd, HANDLE_FLAG_INHERIT, 0); // only the read end goes to the child + HANDLE log = CreateFileW((userDataDir() / "server.log").wstring().c_str(), GENERIC_WRITE, FILE_SHARE_READ, &sa, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + + STARTUPINFOW si{}; + si.cb = sizeof si; + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdInput = readEnd; + si.hStdOutput = log != INVALID_HANDLE_VALUE ? log : nullptr; + si.hStdError = si.hStdOutput; + PROCESS_INFORMATION pi{}; + std::wstring cmd = quoteArg(server.wstring()) + L" --port " + std::to_wstring(port) + L" --name " + + quoteArg(widen(name)); + BOOL ok = CreateProcessW(server.wstring().c_str(), cmd.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, + nullptr, &si, &pi); + CloseHandle(readEnd); + if (log != INVALID_HANDLE_VALUE) CloseHandle(log); + if (!ok) { + CloseHandle(writeEnd); + error = "could not start the local server"; + return false; + } + CloseHandle(pi.hThread); + process_ = pi.hProcess; + stdin_ = writeEnd; + HD2D_LOG("started local server (pid %lu) on port %u", pi.dwProcessId, unsigned(port)); + return true; +} + +void LocalServer::stop() { + if (!process_) return; + DWORD written = 0; + WriteFile(stdin_, "quit\n", 5, &written, nullptr); + CloseHandle(stdin_); + if (WaitForSingleObject(process_, 5000) != WAIT_OBJECT_0) TerminateProcess(process_, 1); + CloseHandle(process_); + process_ = stdin_ = nullptr; +} + +bool LocalServer::running() const { return process_ && WaitForSingleObject(process_, 0) == WAIT_TIMEOUT; } + +#else + +bool LocalServer::start(uint16_t port, const std::string& name, std::string& error) { + if (running()) return true; + fs::path server = executableDir() / "safari_server"; + if (!fs::exists(server)) { + error = "safari_server was not found next to the game"; + return false; + } + int fds[2]; + if (pipe(fds) != 0) { + error = "could not create a pipe for the local server"; + return false; + } + fcntl(fds[1], F_SETFD, FD_CLOEXEC); + std::string log = (userDataDir() / "server.log").string(); + posix_spawn_file_actions_t fa; + posix_spawn_file_actions_init(&fa); + posix_spawn_file_actions_adddup2(&fa, fds[0], 0); + posix_spawn_file_actions_addopen(&fa, 1, log.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644); + posix_spawn_file_actions_adddup2(&fa, 1, 2); + std::vector args = {server.string(), "--port", std::to_string(port), "--name", name}; + std::vector argv; + for (std::string& a : args) argv.push_back(a.data()); + argv.push_back(nullptr); + pid_t pid = 0; + int rc = posix_spawn(&pid, server.c_str(), &fa, nullptr, argv.data(), environ); + posix_spawn_file_actions_destroy(&fa); + close(fds[0]); + if (rc != 0) { + close(fds[1]); + error = "could not start the local server"; + return false; + } + signal(SIGPIPE, SIG_IGN); // writing `quit` to a server that already exited must not kill us + pid_ = int(pid); + stdin_ = fds[1]; + HD2D_LOG("started local server (pid %d) on port %u", pid_, unsigned(port)); + return true; +} + +void LocalServer::stop() { + if (pid_ <= 0) return; + if (write(stdin_, "quit\n", 5) < 0) { + } + close(stdin_); + int status = 0; + bool exited = false; + for (int i = 0; i < 50 && !exited; ++i) { + exited = waitpid(pid_t(pid_), &status, WNOHANG) != 0; + if (!exited) std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + if (!exited) { + kill(pid_t(pid_), SIGKILL); + waitpid(pid_t(pid_), &status, 0); + } + pid_ = stdin_ = -1; +} + +bool LocalServer::running() const { return pid_ > 0 && waitpid(pid_t(pid_), nullptr, WNOHANG) == 0; } + +#endif + +} // namespace bs diff --git a/client/LocalServer.h b/client/LocalServer.h new file mode 100644 index 0000000..28f9fc4 --- /dev/null +++ b/client/LocalServer.h @@ -0,0 +1,41 @@ +#pragma once +// Runs safari_server next to the client executable for "Host & Play". +// The child's stdin is a pipe: stop() sends the console command `quit` so the +// server saves and exits cleanly on every platform (Windows has no SIGTERM), +// and only kills it if it does not exit in time. Its output goes to a log file. + +#include +#include +#include + +namespace bs { + +// Per-user data folder: %APPDATA%\brandling-safari on Windows, +// $XDG_DATA_HOME/brandling-safari or ~/.local/share/brandling-safari elsewhere +// (the server uses the same folder for world.db). Created if missing. +std::filesystem::path userDataDir(); +std::filesystem::path executableDir(); + +class LocalServer { +public: + LocalServer() = default; + LocalServer(const LocalServer&) = delete; + LocalServer& operator=(const LocalServer&) = delete; + ~LocalServer() { stop(); } + + // Starts `safari_server --port --name `; false + `error` on failure. + bool start(uint16_t port, const std::string& name, std::string& error); + void stop(); + bool running() const; + +private: +#if defined(_WIN32) + void* process_ = nullptr; // HANDLE + void* stdin_ = nullptr; // HANDLE (write end) +#else + int pid_ = -1; + int stdin_ = -1; +#endif +}; + +} // namespace bs diff --git a/server/Log.h b/server/Log.h index c24871f..ed8e43c 100644 --- a/server/Log.h +++ b/server/Log.h @@ -7,7 +7,10 @@ namespace bs { -#if defined(__GNUC__) +// MinGW builds define __USE_MINGW_ANSI_STDIO (see CMakeLists.txt) so %zu works on old msvcrt. +#if defined(__MINGW32__) +__attribute__((format(gnu_printf, 1, 2))) +#elif defined(__GNUC__) __attribute__((format(printf, 1, 2))) #endif inline void logf(const char* fmt, ...) { diff --git a/shared/Protocol.h b/shared/Protocol.h index a3b4bc5..453b6b0 100644 --- a/shared/Protocol.h +++ b/shared/Protocol.h @@ -75,7 +75,10 @@ enum class ActionKind : uint8_t { class Writer { public: - explicit Writer(Msg m) { u8(uint8_t(m)); } + explicit Writer(Msg m) { + buf_.reserve(64); // most messages fit; avoids regrowth for every field + u8(uint8_t(m)); + } void u8(uint8_t v) { buf_.push_back(v); } void u16(uint16_t v) { raw(&v, 2); } void u32(uint32_t v) { raw(&v, 4); }