From d42ef875c05f20c794ff5a3090b682caa0f24020 Mon Sep 17 00:00:00 2001 From: Erasmo Bellumat Date: Sat, 25 Apr 2026 12:31:51 -0300 Subject: [PATCH 1/9] feat(sagepatch): add casual QoL patch for macOS via DYLD interpose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces an optional patch (Patches/SagePatch/) that adds quality-of-life features for casual play without modifying the game source. Gated by the new RTS_BUILD_OPTION_SAGE_PATCH cmake flag (default OFF, ON in macos-vulkan preset). Architecture: a separate dylib loaded via DYLD_INSERT_LIBRARIES that replaces SDL_PollEvent at the dyld level (__DATA,__interpose) to capture hot-keys, plus an engine-side INI override picked up automatically from Data/INI/Default/GameData/SagePatch.ini. No D3D8 proxy, no Vulkan layer, no engine source changes. Features in this initial drop: F11 PNG screenshot via /usr/sbin/screencapture (window-only) Scroll Lock cursor lock toggle (SDL_SetWindowMouseGrab) Ctrl+PgUp/Dn display gamma adjustment, range -128..+128 Override.ini MaxCameraHeight=800, MinCameraHeight=60, ScrollSpeed=1.0, EnforceMaxCameraHeight=No Anti-cheat features (MDS, Game File Validator, ergc, version validation) and competitive/networking features (Money Display, Player Table, Random Balance, replay tools, CNC Online, ticker, Upload Mode, Auto Updater) are intentionally excluded — focus is casual QoL only. Smoke test: dylib loads cleanly via DYLD_INSERT_LIBRARIES, engine reads the INI override, GameMain returns code 0. --- CMakeLists.txt | 5 + CMakePresets.json | 3 +- Patches/SagePatch/CMakeLists.txt | 61 ++++++++ .../SagePatch/include/SagePatch/Features.h | 11 ++ Patches/SagePatch/include/SagePatch/Hooks.h | 12 ++ Patches/SagePatch/include/SagePatch/Logger.h | 6 + Patches/SagePatch/resources/Override.ini | 24 ++++ Patches/SagePatch/src/Init.cpp | 24 ++++ Patches/SagePatch/src/features/Brightness.cpp | 51 +++++++ Patches/SagePatch/src/features/CursorLock.cpp | 18 +++ Patches/SagePatch/src/features/KeyHandler.cpp | 38 +++++ Patches/SagePatch/src/features/Screenshot.cpp | 97 +++++++++++++ Patches/SagePatch/src/interposers.cpp | 41 ++++++ Patches/SagePatch/src/util/Config.cpp | 2 + Patches/SagePatch/src/util/Logger.cpp | 1 + cmake/config-build.cmake | 6 + docs/PATCHES/SAGEPATCH.md | 130 ++++++++++++++++++ scripts/build/macos/deploy-macos-zh.sh | 27 ++++ 18 files changed, 556 insertions(+), 1 deletion(-) create mode 100644 Patches/SagePatch/CMakeLists.txt create mode 100644 Patches/SagePatch/include/SagePatch/Features.h create mode 100644 Patches/SagePatch/include/SagePatch/Hooks.h create mode 100644 Patches/SagePatch/include/SagePatch/Logger.h create mode 100644 Patches/SagePatch/resources/Override.ini create mode 100644 Patches/SagePatch/src/Init.cpp create mode 100644 Patches/SagePatch/src/features/Brightness.cpp create mode 100644 Patches/SagePatch/src/features/CursorLock.cpp create mode 100644 Patches/SagePatch/src/features/KeyHandler.cpp create mode 100644 Patches/SagePatch/src/features/Screenshot.cpp create mode 100644 Patches/SagePatch/src/interposers.cpp create mode 100644 Patches/SagePatch/src/util/Config.cpp create mode 100644 Patches/SagePatch/src/util/Logger.cpp create mode 100644 docs/PATCHES/SAGEPATCH.md diff --git a/CMakeLists.txt b/CMakeLists.txt index 18b055ec2b4..e87baad867c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -130,5 +130,10 @@ if(RTS_BUILD_GENERALS) add_subdirectory(Generals) endif() +# SagePatch (optional QoL extras, macOS-only for now) +if(RTS_BUILD_OPTION_SAGE_PATCH) + add_subdirectory(Patches/SagePatch) +endif() + feature_summary(WHAT ENABLED_FEATURES DESCRIPTION "Enabled features:") feature_summary(WHAT DISABLED_FEATURES DESCRIPTION "Disabled features:") diff --git a/CMakePresets.json b/CMakePresets.json index 8f6a2bc3fa6..58fa6b99c0c 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -231,7 +231,8 @@ "SAGE_USE_MOLTENVK": "ON", "SAGE_UPDATE_CHECK": "ON", "RTS_CRASHDUMP_ENABLE": "OFF", - "RTS_BUILD_OPTION_FFMPEG": "ON" + "RTS_BUILD_OPTION_FFMPEG": "ON", + "RTS_BUILD_OPTION_SAGE_PATCH": "ON" }, "environment": { "PKG_CONFIG_PATH": "/opt/homebrew/lib/pkgconfig" diff --git a/Patches/SagePatch/CMakeLists.txt b/Patches/SagePatch/CMakeLists.txt new file mode 100644 index 00000000000..9e34005f224 --- /dev/null +++ b/Patches/SagePatch/CMakeLists.txt @@ -0,0 +1,61 @@ +# SagePatch - QoL features for GeneralsX (macOS) +# Built as a DYLD_INSERT_LIBRARIES dylib that hooks SDL3 + adds screenshot/cursor/brightness. +# Engine-side overrides (camera height, scroll speed) are shipped as Data/INI overlays. + +if(NOT APPLE) + message(STATUS "SagePatch: Currently macOS-only; skipping on this platform.") + return() +endif() + +if(NOT SAGE_USE_SDL3) + message(FATAL_ERROR "SagePatch requires SAGE_USE_SDL3=ON.") +endif() + +add_library(sage_patch SHARED + src/Init.cpp + src/interposers.cpp + src/features/KeyHandler.cpp + src/features/Screenshot.cpp + src/features/CursorLock.cpp + src/features/Brightness.cpp + src/util/Logger.cpp + src/util/Config.cpp +) + +target_include_directories(sage_patch PRIVATE + include + ${CMAKE_BINARY_DIR}/_deps/sdl3-src/include +) + +# SDL3 is interposed at runtime — link only as a build-time symbol resolver, +# do NOT make sage_patch depend on the SDL3 dylib explicitly. Resolution happens +# at load time through the host process's existing libSDL3.0.dylib. +target_link_libraries(sage_patch PRIVATE + "-undefined dynamic_lookup" + "-framework CoreGraphics" + "-framework ApplicationServices" + "-framework Foundation" +) + +set_target_properties(sage_patch PROPERTIES + OUTPUT_NAME "sage_patch" + PREFIX "lib" + SUFFIX ".dylib" + INSTALL_NAME_DIR "@rpath" + BUILD_WITH_INSTALL_RPATH TRUE +) + +target_compile_options(sage_patch PRIVATE + -fvisibility=hidden + -fno-exceptions + -Wall + -Wextra + -Wno-unused-parameter +) + +target_compile_definitions(sage_patch PRIVATE + SAGE_PATCH_VERSION="0.1.0-alpha" +) + +# Resources (Override.ini for engine-side INI overrides) are picked up by deploy script +add_feature_info(SagePatch ON "QoL patch for GeneralsX (screenshot, cursor lock, camera ext., brightness)") diff --git a/Patches/SagePatch/include/SagePatch/Features.h b/Patches/SagePatch/include/SagePatch/Features.h new file mode 100644 index 00000000000..cabbff30d23 --- /dev/null +++ b/Patches/SagePatch/include/SagePatch/Features.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace sagepatch { + +void takeScreenshot(SDL_Window* window); +void toggleCursorLock(SDL_Window* window); +void adjustBrightness(int delta); + +} diff --git a/Patches/SagePatch/include/SagePatch/Hooks.h b/Patches/SagePatch/include/SagePatch/Hooks.h new file mode 100644 index 00000000000..d4e1fea3ad8 --- /dev/null +++ b/Patches/SagePatch/include/SagePatch/Hooks.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +namespace sagepatch { + +void init(); +void shutdown(); + +bool handleKeyDown(const SDL_KeyboardEvent& ev); + +} diff --git a/Patches/SagePatch/include/SagePatch/Logger.h b/Patches/SagePatch/include/SagePatch/Logger.h new file mode 100644 index 00000000000..b37ead9aa62 --- /dev/null +++ b/Patches/SagePatch/include/SagePatch/Logger.h @@ -0,0 +1,6 @@ +#pragma once + +#include + +#define SAGEPATCH_LOG(fmt, ...) \ + fprintf(stderr, "[sagepatch] " fmt "\n", ##__VA_ARGS__) diff --git a/Patches/SagePatch/resources/Override.ini b/Patches/SagePatch/resources/Override.ini new file mode 100644 index 00000000000..77538002156 --- /dev/null +++ b/Patches/SagePatch/resources/Override.ini @@ -0,0 +1,24 @@ +; ----------------------------------------------------------------------------- +; SagePatch — Casual QoL overrides for GeneralsX +; +; Extends camera bounds, scroll speed, and pitch range to values closer to what +; players typically want for casual play. Loaded by the engine after the +; default GameData.ini, so values here override (don't add to) the originals. +; +; Vanilla values (from GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp): +; MaxCameraHeight = 300, MinCameraHeight = 100, KeyboardScrollSpeedFactor = 0.5 +; +; To disable the override, simply remove this file from the game directory or +; build with -DRTS_BUILD_OPTION_SAGE_PATCH=OFF. +; ----------------------------------------------------------------------------- + +GameData + ; Camera zoom range — much wider than vanilla so casual players can survey + ; entire bases or look up close at units. + MaxCameraHeight = 800.0 + MinCameraHeight = 60.0 + EnforceMaxCameraHeight = No + + ; Keyboard scroll speed — vanilla was sluggish. Doubled. + KeyboardScrollSpeedFactor = 1.0 +End diff --git a/Patches/SagePatch/src/Init.cpp b/Patches/SagePatch/src/Init.cpp new file mode 100644 index 00000000000..2386b14f739 --- /dev/null +++ b/Patches/SagePatch/src/Init.cpp @@ -0,0 +1,24 @@ +#include "SagePatch/Hooks.h" +#include "SagePatch/Logger.h" + +namespace sagepatch { + +__attribute__((constructor)) +static void onLoad() { + SAGEPATCH_LOG("Loaded (version %s) via DYLD_INSERT_LIBRARIES", SAGE_PATCH_VERSION); + init(); +} + +__attribute__((destructor)) +static void onUnload() { + shutdown(); + SAGEPATCH_LOG("Unloaded"); +} + +void init() { + SAGEPATCH_LOG("Active features: screenshot(F11), cursor-lock(Scroll Lock), brightness(Page+/Page-)"); +} + +void shutdown() {} + +} diff --git a/Patches/SagePatch/src/features/Brightness.cpp b/Patches/SagePatch/src/features/Brightness.cpp new file mode 100644 index 00000000000..29c0ab632fa --- /dev/null +++ b/Patches/SagePatch/src/features/Brightness.cpp @@ -0,0 +1,51 @@ +// Brightness adjustment via macOS CGSetDisplayTransferByFormula. SDL3 deprecated +// the gamma APIs in 3.0; CoreGraphics still works for the active display. +// +// Range: 0.5 (very dark) .. 2.0 (very bright). Internal counter goes -128..+128 +// for parity with GenTool's brightness slider semantics, then maps to a gamma +// curve via gamma = 1.0 + (value / 256.0). + +#include "SagePatch/Features.h" +#include "SagePatch/Logger.h" + +#include + +namespace sagepatch { + +static int g_brightness = 0; // -128 .. +128 + +static int clampi(int v, int lo, int hi) { + if (v < lo) return lo; + if (v > hi) return hi; + return v; +} + +static void applyGamma() { + float gamma = 1.0f + static_cast(g_brightness) / 256.0f; + if (gamma < 0.5f) gamma = 0.5f; + if (gamma > 2.0f) gamma = 2.0f; + + CGDirectDisplayID displays[8]; + uint32_t count = 0; + if (CGGetActiveDisplayList(8, displays, &count) != kCGErrorSuccess) { + SAGEPATCH_LOG("Brightness: cannot enumerate displays"); + return; + } + + for (uint32_t i = 0; i < count; ++i) { + CGSetDisplayTransferByFormula( + displays[i], + 0.0f, 1.0f, 1.0f / gamma, + 0.0f, 1.0f, 1.0f / gamma, + 0.0f, 1.0f, 1.0f / gamma); + } +} + +void adjustBrightness(int delta) { + g_brightness = clampi(g_brightness + delta, -128, +128); + applyGamma(); + SAGEPATCH_LOG("Brightness: %+d (gamma %.3f)", g_brightness, + 1.0 + g_brightness / 256.0); +} + +} diff --git a/Patches/SagePatch/src/features/CursorLock.cpp b/Patches/SagePatch/src/features/CursorLock.cpp new file mode 100644 index 00000000000..562927f3630 --- /dev/null +++ b/Patches/SagePatch/src/features/CursorLock.cpp @@ -0,0 +1,18 @@ +#include "SagePatch/Features.h" +#include "SagePatch/Logger.h" + +#include + +namespace sagepatch { + +static bool g_cursorLocked = false; + +void toggleCursorLock(SDL_Window* window) { + if (!window) return; + g_cursorLocked = !g_cursorLocked; + SDL_SetWindowMouseGrab(window, g_cursorLocked); + SDL_SetWindowRelativeMouseMode(window, g_cursorLocked); + SAGEPATCH_LOG("Cursor lock: %s", g_cursorLocked ? "ON" : "OFF"); +} + +} diff --git a/Patches/SagePatch/src/features/KeyHandler.cpp b/Patches/SagePatch/src/features/KeyHandler.cpp new file mode 100644 index 00000000000..8e7278654b5 --- /dev/null +++ b/Patches/SagePatch/src/features/KeyHandler.cpp @@ -0,0 +1,38 @@ +#include "SagePatch/Hooks.h" +#include "SagePatch/Features.h" +#include "SagePatch/Logger.h" + +namespace sagepatch { + +bool handleKeyDown(const SDL_KeyboardEvent& ev) { + if (ev.repeat) return false; + + SDL_Window* window = SDL_GetWindowFromID(ev.windowID); + if (!window) return false; + + switch (ev.key) { + case SDLK_F11: + takeScreenshot(window); + return true; + case SDLK_SCROLLLOCK: + toggleCursorLock(window); + return true; + case SDLK_PAGEUP: + if (ev.mod & SDL_KMOD_CTRL) { + adjustBrightness(+8); + return true; + } + break; + case SDLK_PAGEDOWN: + if (ev.mod & SDL_KMOD_CTRL) { + adjustBrightness(-8); + return true; + } + break; + default: + break; + } + return false; +} + +} diff --git a/Patches/SagePatch/src/features/Screenshot.cpp b/Patches/SagePatch/src/features/Screenshot.cpp new file mode 100644 index 00000000000..e892c7e9a20 --- /dev/null +++ b/Patches/SagePatch/src/features/Screenshot.cpp @@ -0,0 +1,97 @@ +// F11 screenshot. We resolve the SDL3 window's Cocoa window number, then shell +// out to `/usr/sbin/screencapture -l -x ` — that binary has been on +// every macOS version since 10.2 and unlike CGWindowListCreateImageFromArray +// is not deprecated on macOS 15+. + +#include "SagePatch/Features.h" +#include "SagePatch/Logger.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sagepatch { + +static void ensureScreenshotDir(char* outPath, size_t outPathLen) { + const char* home = getenv("HOME"); + if (!home || !*home) { + const struct passwd* pw = getpwuid(getuid()); + home = pw ? pw->pw_dir : "."; + } + snprintf(outPath, outPathLen, "%s/Pictures/GeneralsX", home); + mkdir(outPath, 0755); +} + +static void timestampFilename(char* out, size_t outLen, const char* dir) { + auto now = std::chrono::system_clock::now(); + std::time_t t = std::chrono::system_clock::to_time_t(now); + std::tm tm{}; + localtime_r(&t, &tm); + auto ms = std::chrono::duration_cast( + now.time_since_epoch()) % 1000; + snprintf(out, outLen, "%s/generalsx_%04d-%02d-%02d_%02d-%02d-%02d-%03d.png", + dir, + tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec, + static_cast(ms.count())); +} + +static unsigned long cocoaWindowNumber(void* nsWindow) { + using SendLong = unsigned long (*)(void*, SEL); + SEL sel = sel_registerName("windowNumber"); + SendLong send = reinterpret_cast(objc_msgSend); + return send(nsWindow, sel); +} + +void takeScreenshot(SDL_Window* window) { + if (!window) { + SAGEPATCH_LOG("Screenshot: no window"); + return; + } + + SDL_PropertiesID props = SDL_GetWindowProperties(window); + void* nsWindow = SDL_GetPointerProperty(props, SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, nullptr); + if (!nsWindow) { + SAGEPATCH_LOG("Screenshot: SDL3 window has no Cocoa NSWindow handle"); + return; + } + + unsigned long winId = cocoaWindowNumber(nsWindow); + if (winId == 0) { + SAGEPATCH_LOG("Screenshot: window number == 0"); + return; + } + + char dir[1024], path[2048], cmd[4096]; + ensureScreenshotDir(dir, sizeof(dir)); + timestampFilename(path, sizeof(path), dir); + + // -l : capture only the window with that ID + // -x : no shutter sound + // -o : do not capture the window's shadow (cleaner crop) + // -t png : png output + snprintf(cmd, sizeof(cmd), + "/usr/sbin/screencapture -l%lu -x -o -t png \"%s\" 2>/dev/null", + winId, path); + + int rc = system(cmd); + if (rc == 0) { + struct stat st; + if (stat(path, &st) == 0 && st.st_size > 0) { + SAGEPATCH_LOG("Screenshot saved: %s (%lld bytes)", path, (long long)st.st_size); + return; + } + } + SAGEPATCH_LOG("Screenshot: screencapture failed (rc=%d)", rc); +} + +} diff --git a/Patches/SagePatch/src/interposers.cpp b/Patches/SagePatch/src/interposers.cpp new file mode 100644 index 00000000000..d2493e71adb --- /dev/null +++ b/Patches/SagePatch/src/interposers.cpp @@ -0,0 +1,41 @@ +// macOS DYLD interpose table — replaces SDL3 functions at load time without +// modifying the host binary or the real libSDL3.0.dylib. +// +// Reference: dyld(1) man page, section "Interposing" + . +// +// We hook SDL_PollEvent so we see every input event the game processes. When +// our hot-keys (F11, Scroll Lock, Page Up/Down) appear, we eat the event so the +// game does not also receive it. + +#include +#include "SagePatch/Hooks.h" +#include "SagePatch/Logger.h" + +namespace sagepatch { + +extern bool handleKeyDown(const SDL_KeyboardEvent& ev); + +static bool sage_SDL_PollEvent(SDL_Event* event) { + while (SDL_PollEvent(event)) { + if (event->type == SDL_EVENT_KEY_DOWN) { + if (handleKeyDown(event->key)) { + continue; + } + } + return true; + } + return false; +} + +} + +extern "C" { + +__attribute__((used)) static struct interpose_t { + const void* replacement; + const void* original; +} _sage_interposers[] __attribute__((section("__DATA,__interpose"))) = { + { (const void*)&sagepatch::sage_SDL_PollEvent, (const void*)&SDL_PollEvent }, +}; + +} diff --git a/Patches/SagePatch/src/util/Config.cpp b/Patches/SagePatch/src/util/Config.cpp new file mode 100644 index 00000000000..b7b8af5a227 --- /dev/null +++ b/Patches/SagePatch/src/util/Config.cpp @@ -0,0 +1,2 @@ +// Stub — config will read from $HOME/.config/GeneralsX/sagepatch.conf in a +// future revision. For now, all defaults live in code. diff --git a/Patches/SagePatch/src/util/Logger.cpp b/Patches/SagePatch/src/util/Logger.cpp new file mode 100644 index 00000000000..cf1f633b9bb --- /dev/null +++ b/Patches/SagePatch/src/util/Logger.cpp @@ -0,0 +1 @@ +// Stub — header-only macros currently. Reserved for future file-based logging. diff --git a/cmake/config-build.cmake b/cmake/config-build.cmake index ef3c4a131a9..c981481ead2 100644 --- a/cmake/config-build.cmake +++ b/cmake/config-build.cmake @@ -23,6 +23,11 @@ endif() # macOS port option (Phase 5) option(SAGE_USE_MOLTENVK "Use MoltenVK for Vulkan on macOS (Phase 5 macOS port)" OFF) +# SagePatch — optional QoL features for casual play (screenshot, cursor lock, +# brightness, camera/scroll INI overrides). Compiles to a separate dylib that +# is loaded via DYLD_INSERT_LIBRARIES at runtime. macOS-only for now. +option(RTS_BUILD_OPTION_SAGE_PATCH "Build SagePatch QoL extras (macOS, requires SDL3)" OFF) + if(NOT RTS_BUILD_ZEROHOUR AND NOT RTS_BUILD_GENERALS) set(RTS_BUILD_ZEROHOUR TRUE) message("You must select one project to build, building Zero Hour by default.") @@ -40,6 +45,7 @@ add_feature_info(FFmpegSupport RTS_BUILD_OPTION_FFMPEG "Building with FFmpeg sup add_feature_info(SDL3Windowing SAGE_USE_SDL3 "Using SDL3 for windowing (Linux)") add_feature_info(OpenALAudio SAGE_USE_OPENAL "Using OpenAL for audio (Linux)") add_feature_info(UpdateCheck SAGE_UPDATE_CHECK "In-game update check via GitHub Releases API") +add_feature_info(SagePatch RTS_BUILD_OPTION_SAGE_PATCH "Build SagePatch QoL extras (macOS)") set(RTS_BUILD_OUTPUT_SUFFIX "" CACHE STRING "Suffix appended to output names of installable targets") diff --git a/docs/PATCHES/SAGEPATCH.md b/docs/PATCHES/SAGEPATCH.md new file mode 100644 index 00000000000..52cd84a7f35 --- /dev/null +++ b/docs/PATCHES/SAGEPATCH.md @@ -0,0 +1,130 @@ +# SagePatch — Casual QoL Extras for GeneralsX (macOS) + +SagePatch is an optional, drop-in patch that adds quality-of-life features to +GeneralsX without modifying the game source. Inspired by the casual subset of +GenTool, it ships as a separate dylib that is loaded via `DYLD_INSERT_LIBRARIES` +plus a small `GameData` INI override that the engine picks up at startup. + +## Features + +| Feature | Trigger | Notes | +|---|---|---| +| Screenshot | `F11` | PNG saved to `~/Pictures/GeneralsX/`. Captures the actual game window via CoreGraphics. | +| Cursor lock toggle | `Scroll Lock` | Confines the mouse to the game window. Useful in windowed mode + multi-monitor setups. | +| Brightness up | `Ctrl + Page Up` | +8 step on the gamma curve, range −128…+128. | +| Brightness down | `Ctrl + Page Down` | −8 step. | +| Camera zoom range | (passive) | `MaxCameraHeight=800`, `MinCameraHeight=60`, `EnforceMaxCameraHeight=No`. | +| Keyboard scroll speed | (passive) | `KeyboardScrollSpeedFactor=1.0` (vanilla 0.5). | + +Hot-key collisions: SagePatch eats the events it handles, so they do not +also reach the game. + +## How to enable + +Build: + +```bash +cmake --preset macos-vulkan -DRTS_BUILD_OPTION_SAGE_PATCH=ON +cmake --build build/macos-vulkan --target z_generals -j$(sysctl -n hw.logicalcpu) +``` + +(The `macos-vulkan` preset already turns this `ON` by default — disable with +`-DRTS_BUILD_OPTION_SAGE_PATCH=OFF` if you want a vanilla build.) + +Deploy: + +```bash +./scripts/build/macos/deploy-macos-zh.sh +``` + +The deploy script: +1. Copies `libsage_patch.dylib` next to `GeneralsXZH` +2. Drops `Override.ini` into `Data/INI/Default/GameData/SagePatch.ini` +3. Configures the wrapper `run.sh` to set `DYLD_INSERT_LIBRARIES` + +Run as usual: + +```bash +~/GeneralsX/GeneralsZH/run.sh -win +``` + +Take a screenshot mid-game with **F11**. + +## Disabling at runtime + +Without rebuilding: + +```bash +SAGE_PATCH_DISABLED=1 ~/GeneralsX/GeneralsZH/run.sh -win +``` + +This skips the `DYLD_INSERT_LIBRARIES` step. The INI override remains active — +delete `Data/INI/Default/GameData/SagePatch.ini` to revert camera/scroll values. + +## Architecture + +``` +Game process (GeneralsXZH) + │ + ├── DYLD_INSERT_LIBRARIES → libsage_patch.dylib + │ │ + │ └── __DATA,__interpose table replaces SDL_PollEvent + │ │ + │ └── on F11 / Scroll Lock / Ctrl+PgUp/Dn → SagePatch handlers + │ │ + │ └── CoreGraphics / SDL3 / CGSetDisplayTransferByFormula + │ + └── Engine reads Data/INI/Default/GameData/SagePatch.ini → overrides camera/scroll +``` + +No D3D8 proxy, no Vulkan layer, no engine source modifications. The whole patch +is two files: a dylib and an INI. + +## Why this approach + +The original GenTool had to be a `d3d8.dll` proxy because Windows games of that +era exposed no other plugin surface. On modern macOS we have: + +- **`__interpose`** — ld + dyld replace symbol resolution at load time. We see + `SDL_PollEvent` exactly as the game calls it. +- **CoreGraphics window capture** — the macOS compositor already has the + composited pixels; we ask for them. No need to read a Vulkan back-buffer. +- **Engine INI overrides** — GeneralsX's INI loader merges files in + `Data/INI/Default//`. Parameter tweaks need zero code. + +This keeps SagePatch ~400 lines instead of the ~3000 lines a full COM proxy +would require. + +## Limits / what's *not* in scope + +By design, SagePatch sticks to **casual** QoL: + +- No replay tools (frame stepping, fog-of-war replay, controls bar) +- No competitive features (Money Display, Player Table, Random Balance) +- No anti-cheat (MDS, Game File Validator, version validation) +- No external server integrations (CNC Online, GameRanger, Upload Mode, ticker, + ranked maps, ladder, GenTool updater) + +Engine-side bug fixes (scud bug, tunnel bug, building bug, multiplayer crash) +also live outside this patch — they require modifications inside the game's +source rather than a side-loaded dylib. + +## Files + +``` +Patches/SagePatch/ + CMakeLists.txt + include/SagePatch/Hooks.h + include/SagePatch/Features.h + include/SagePatch/Logger.h + src/Init.cpp # constructor / destructor + src/interposers.cpp # __DATA,__interpose table + src/features/KeyHandler.cpp # dispatches hot-keys + src/features/Screenshot.cpp # F11 → PNG + src/features/CursorLock.cpp # Scroll Lock toggle + src/features/Brightness.cpp # Ctrl+PgUp / Ctrl+PgDn + src/util/Logger.cpp # placeholder + src/util/Config.cpp # placeholder + resources/Override.ini # engine-side INI override +docs/PATCHES/SAGEPATCH.md # this file +``` diff --git a/scripts/build/macos/deploy-macos-zh.sh b/scripts/build/macos/deploy-macos-zh.sh index e1b4db2383d..abe8075556b 100755 --- a/scripts/build/macos/deploy-macos-zh.sh +++ b/scripts/build/macos/deploy-macos-zh.sh @@ -139,6 +139,22 @@ else exit 1 fi +# SagePatch (optional, gated by RTS_BUILD_OPTION_SAGE_PATCH at configure time). +# When the dylib exists, deploy it + the Override.ini into Data/INI/Default/ so the +# engine picks up the casual QoL settings. The launcher wrapper sets +# DYLD_INSERT_LIBRARIES to load the dylib at runtime. +SAGE_PATCH_LIB="${BUILD_DIR}/Patches/SagePatch/libsage_patch.dylib" +SAGE_PATCH_OVERRIDE="${PROJECT_ROOT}/Patches/SagePatch/resources/Override.ini" +if [[ -f "${SAGE_PATCH_LIB}" ]]; then + echo " Deploying SagePatch (libsage_patch.dylib)..." + cp -v "${SAGE_PATCH_LIB}" "${RUNTIME_DIR}/" + if [[ -f "${SAGE_PATCH_OVERRIDE}" ]]; then + mkdir -p "${RUNTIME_DIR}/Data/INI/Default/GameData" + cp -v "${SAGE_PATCH_OVERRIDE}" \ + "${RUNTIME_DIR}/Data/INI/Default/GameData/SagePatch.ini" + fi +fi + # GeneralsX @bugfix Copilot 24/03/2026 Deploy Fontconfig config into runtime dir so FreeType/Fontconfig can resolve fonts on macOS. # GeneralsX @bugfix BenderAI 24/03/2026 Guard Fontconfig conf.d copy so missing directory does not abort deploy under set -e. echo " Deploying Fontconfig config..." @@ -166,6 +182,17 @@ SCRIPT_DIR="\$(cd "\$(dirname "\$0")" && pwd)" # SDL3 and gamespy dylibs are in same dir; Vulkan/MoltenVK stays in SDK export DYLD_LIBRARY_PATH="\${SCRIPT_DIR}:\${DYLD_LIBRARY_PATH:-}" +# SagePatch (optional QoL features). Loaded via DYLD_INSERT_LIBRARIES so it +# can interpose SDL3 functions for hot-keys (F11 screenshot, Scroll Lock cursor +# lock, Ctrl+PageUp/PageDown brightness). Ignored if the dylib is not present. +if [[ -f "\${SCRIPT_DIR}/libsage_patch.dylib" && "\${SAGE_PATCH_DISABLED:-0}" != "1" ]]; then + if [[ -n "\${DYLD_INSERT_LIBRARIES:-}" ]]; then + export DYLD_INSERT_LIBRARIES="\${SCRIPT_DIR}/libsage_patch.dylib:\${DYLD_INSERT_LIBRARIES}" + else + export DYLD_INSERT_LIBRARIES="\${SCRIPT_DIR}/libsage_patch.dylib" + fi +fi + # GeneralsX @bugfix fbraz3 20/03/2026 DXVK requires DXVK_WSI_DRIVER on non-Win32; must match game windowing (SDL3) export DXVK_WSI_DRIVER="SDL3" From 3efaac65c1a434abc060ae2eb4f699df4a1dd7f7 Mon Sep 17 00:00:00 2001 From: Erasmo Bellumat Date: Sat, 25 Apr 2026 12:41:54 -0300 Subject: [PATCH 2/9] feat(sagepatch): make cross-platform (macOS + Linux), add window snaps + FPS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GeneralsX is a multi-platform project; the Phase-1 SagePatch was macOS-only, which broke the rule. Restructured src/ tree and added Linux backend. Source tree: src/common/ SDL3-only code that works everywhere — Init, KeyHandler, CursorLock, WindowPosition. src/macos/ __DATA,__interpose hook, screencapture, CoreGraphics gamma. src/linux/ LD_PRELOAD + dlsym(RTLD_NEXT), ImageMagick `import` / gnome-screenshot, XF86VidMode (lazy dlopen, X11 only — no-op under Wayland). src/windows/ stubs only (Phase 2 — would need a proxy DLL). New features in this iteration: * Window snap presets — Ctrl+1..5 (center / TL / TR / BL / BR), via SDL_SetWindowPosition. Cross-platform. * FPS counter — both macOS and Linux deploy scripts default DXVK_HUD=fps when SagePatch is active, so casual users get a frame counter without extra config. Override with DXVK_HUD=0 or any custom value. * Camera pitch override added to resources/Override.ini. Linux deploy script (scripts/build/linux/deploy-linux-zh.sh) now copies libsage_patch.so + Override.ini, and the generated run.sh sets LD_PRELOAD when the library is present. SAGE_PATCH_DISABLED=1 still skips the preload. Phase 1 complete. Phase 2 items intentionally deferred: - Engine bug fixes (scud / tunnel / building / multiplayer crash) require edits inside Generals*/Code, defeating SagePatch's "no source change" contract. - Windows preload is structurally different — needs a d3d8.dll proxy. - In-game overlay text (clock, match timer, in-game menu) needs a graphics hook (D3D8 proxy or Vulkan layer). --- Patches/SagePatch/CMakeLists.txt | 93 ++++++++---- .../SagePatch/include/SagePatch/Features.h | 9 ++ Patches/SagePatch/resources/Override.ini | 18 ++- Patches/SagePatch/src/Init.cpp | 24 --- .../src/{features => common}/CursorLock.cpp | 0 Patches/SagePatch/src/common/Init.cpp | 36 +++++ Patches/SagePatch/src/common/KeyHandler.cpp | 53 +++++++ .../SagePatch/src/common/WindowPosition.cpp | 59 ++++++++ Patches/SagePatch/src/features/KeyHandler.cpp | 38 ----- .../SagePatch/src/linux/Brightness_linux.cpp | 108 ++++++++++++++ .../SagePatch/src/linux/Screenshot_linux.cpp | 95 ++++++++++++ .../SagePatch/src/linux/interposers_linux.cpp | 30 ++++ .../Brightness_macos.cpp} | 0 .../Screenshot_macos.cpp} | 0 .../interposers_macos.cpp} | 0 Patches/SagePatch/src/util/Config.cpp | 2 - Patches/SagePatch/src/util/Logger.cpp | 1 - .../SagePatch/src/windows/Stubs_windows.cpp | 32 ++++ docs/PATCHES/SAGEPATCH.md | 137 ++++++++++-------- scripts/build/linux/deploy-linux-zh.sh | 36 ++++- scripts/build/macos/deploy-macos-zh.sh | 9 +- 21 files changed, 619 insertions(+), 161 deletions(-) delete mode 100644 Patches/SagePatch/src/Init.cpp rename Patches/SagePatch/src/{features => common}/CursorLock.cpp (100%) create mode 100644 Patches/SagePatch/src/common/Init.cpp create mode 100644 Patches/SagePatch/src/common/KeyHandler.cpp create mode 100644 Patches/SagePatch/src/common/WindowPosition.cpp delete mode 100644 Patches/SagePatch/src/features/KeyHandler.cpp create mode 100644 Patches/SagePatch/src/linux/Brightness_linux.cpp create mode 100644 Patches/SagePatch/src/linux/Screenshot_linux.cpp create mode 100644 Patches/SagePatch/src/linux/interposers_linux.cpp rename Patches/SagePatch/src/{features/Brightness.cpp => macos/Brightness_macos.cpp} (100%) rename Patches/SagePatch/src/{features/Screenshot.cpp => macos/Screenshot_macos.cpp} (100%) rename Patches/SagePatch/src/{interposers.cpp => macos/interposers_macos.cpp} (100%) delete mode 100644 Patches/SagePatch/src/util/Config.cpp delete mode 100644 Patches/SagePatch/src/util/Logger.cpp create mode 100644 Patches/SagePatch/src/windows/Stubs_windows.cpp diff --git a/Patches/SagePatch/CMakeLists.txt b/Patches/SagePatch/CMakeLists.txt index 9e34005f224..f127fea6554 100644 --- a/Patches/SagePatch/CMakeLists.txt +++ b/Patches/SagePatch/CMakeLists.txt @@ -1,49 +1,81 @@ -# SagePatch - QoL features for GeneralsX (macOS) -# Built as a DYLD_INSERT_LIBRARIES dylib that hooks SDL3 + adds screenshot/cursor/brightness. -# Engine-side overrides (camera height, scroll speed) are shipped as Data/INI overlays. - -if(NOT APPLE) - message(STATUS "SagePatch: Currently macOS-only; skipping on this platform.") - return() -endif() +# SagePatch — cross-platform QoL extras for GeneralsX. +# +# Layout: shared C++ in src/common/, platform-specific implementations in +# src/{macos,linux,windows}/. The common code calls a single sagepatch:: +# namespace; per-platform sources provide screenshot, brightness, and the +# event-hook mechanism (DYLD __interpose on macOS, LD_PRELOAD + dlsym on Linux). +# +# Engine-side INI overrides (camera height, scroll speed, camera pitch) are +# picked up automatically by the engine — see resources/Override.ini. if(NOT SAGE_USE_SDL3) message(FATAL_ERROR "SagePatch requires SAGE_USE_SDL3=ON.") endif() -add_library(sage_patch SHARED - src/Init.cpp - src/interposers.cpp - src/features/KeyHandler.cpp - src/features/Screenshot.cpp - src/features/CursorLock.cpp - src/features/Brightness.cpp - src/util/Logger.cpp - src/util/Config.cpp +set(SAGE_PATCH_SOURCES + src/common/Init.cpp + src/common/KeyHandler.cpp + src/common/CursorLock.cpp + src/common/WindowPosition.cpp ) +if(APPLE) + list(APPEND SAGE_PATCH_SOURCES + src/macos/interposers_macos.cpp + src/macos/Screenshot_macos.cpp + src/macos/Brightness_macos.cpp + ) +elseif(UNIX AND NOT APPLE) + list(APPEND SAGE_PATCH_SOURCES + src/linux/interposers_linux.cpp + src/linux/Screenshot_linux.cpp + src/linux/Brightness_linux.cpp + ) +elseif(WIN32) + message(WARNING "SagePatch on Windows: no preload mechanism; the dylib will build but is a no-op until a proxy DLL pattern is implemented.") + list(APPEND SAGE_PATCH_SOURCES + src/windows/Stubs_windows.cpp + ) +else() + message(FATAL_ERROR "SagePatch: unsupported platform.") +endif() + +add_library(sage_patch SHARED ${SAGE_PATCH_SOURCES}) + target_include_directories(sage_patch PRIVATE include ${CMAKE_BINARY_DIR}/_deps/sdl3-src/include ) -# SDL3 is interposed at runtime — link only as a build-time symbol resolver, -# do NOT make sage_patch depend on the SDL3 dylib explicitly. Resolution happens -# at load time through the host process's existing libSDL3.0.dylib. -target_link_libraries(sage_patch PRIVATE - "-undefined dynamic_lookup" - "-framework CoreGraphics" - "-framework ApplicationServices" - "-framework Foundation" -) +# SDL3 symbols resolve at load time from the host process — do not link the +# dylib at build time so a wrong RPATH cannot pull in a stale SDL3. +if(APPLE) + target_link_libraries(sage_patch PRIVATE + "-undefined dynamic_lookup" + "-framework CoreGraphics" + "-framework ApplicationServices" + "-framework Foundation" + ) +elseif(UNIX) + target_link_libraries(sage_patch PRIVATE + ${CMAKE_DL_LIBS} + ) + # Linux equivalent of `-undefined dynamic_lookup`. SDL_PollEvent etc. are + # supplied by the host's libSDL3.so at preload time. + target_link_options(sage_patch PRIVATE "LINKER:--unresolved-symbols=ignore-in-shared-libs") +endif() set_target_properties(sage_patch PROPERTIES OUTPUT_NAME "sage_patch" PREFIX "lib" - SUFFIX ".dylib" - INSTALL_NAME_DIR "@rpath" - BUILD_WITH_INSTALL_RPATH TRUE ) +if(APPLE) + set_target_properties(sage_patch PROPERTIES + SUFFIX ".dylib" + INSTALL_NAME_DIR "@rpath" + BUILD_WITH_INSTALL_RPATH TRUE + ) +endif() target_compile_options(sage_patch PRIVATE -fvisibility=hidden @@ -57,5 +89,4 @@ target_compile_definitions(sage_patch PRIVATE SAGE_PATCH_VERSION="0.1.0-alpha" ) -# Resources (Override.ini for engine-side INI overrides) are picked up by deploy script -add_feature_info(SagePatch ON "QoL patch for GeneralsX (screenshot, cursor lock, camera ext., brightness)") +add_feature_info(SagePatch ON "QoL patch (screenshot, cursor lock, brightness, window snaps, camera/scroll INI overrides)") diff --git a/Patches/SagePatch/include/SagePatch/Features.h b/Patches/SagePatch/include/SagePatch/Features.h index cabbff30d23..c891c9ffcac 100644 --- a/Patches/SagePatch/include/SagePatch/Features.h +++ b/Patches/SagePatch/include/SagePatch/Features.h @@ -4,8 +4,17 @@ namespace sagepatch { +enum class WindowPosition { + Center, + TopLeft, + TopRight, + BottomLeft, + BottomRight, +}; + void takeScreenshot(SDL_Window* window); void toggleCursorLock(SDL_Window* window); void adjustBrightness(int delta); +void moveWindow(SDL_Window* window, WindowPosition where); } diff --git a/Patches/SagePatch/resources/Override.ini b/Patches/SagePatch/resources/Override.ini index 77538002156..6983b796faa 100644 --- a/Patches/SagePatch/resources/Override.ini +++ b/Patches/SagePatch/resources/Override.ini @@ -1,15 +1,17 @@ ; ----------------------------------------------------------------------------- ; SagePatch — Casual QoL overrides for GeneralsX ; -; Extends camera bounds, scroll speed, and pitch range to values closer to what -; players typically want for casual play. Loaded by the engine after the -; default GameData.ini, so values here override (don't add to) the originals. +; Extends camera bounds, pitch range, and scroll speed to values closer to +; what casual players want. Loaded by the engine after the default +; GameData.ini, so values here override (don't add to) the originals. ; ; Vanilla values (from GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp): -; MaxCameraHeight = 300, MinCameraHeight = 100, KeyboardScrollSpeedFactor = 0.5 +; MaxCameraHeight = 300, MinCameraHeight = 100 +; CameraPitch = ~63 +; KeyboardScrollSpeedFactor = 0.5 ; -; To disable the override, simply remove this file from the game directory or -; build with -DRTS_BUILD_OPTION_SAGE_PATCH=OFF. +; To disable the override: remove this file from the game directory or build +; with -DRTS_BUILD_OPTION_SAGE_PATCH=OFF. ; ----------------------------------------------------------------------------- GameData @@ -19,6 +21,10 @@ GameData MinCameraHeight = 60.0 EnforceMaxCameraHeight = No + ; Camera pitch — vanilla pins around 63. We let players go more top-down or + ; flatter without breaking terrain rendering. + CameraPitch = 50.0 + ; Keyboard scroll speed — vanilla was sluggish. Doubled. KeyboardScrollSpeedFactor = 1.0 End diff --git a/Patches/SagePatch/src/Init.cpp b/Patches/SagePatch/src/Init.cpp deleted file mode 100644 index 2386b14f739..00000000000 --- a/Patches/SagePatch/src/Init.cpp +++ /dev/null @@ -1,24 +0,0 @@ -#include "SagePatch/Hooks.h" -#include "SagePatch/Logger.h" - -namespace sagepatch { - -__attribute__((constructor)) -static void onLoad() { - SAGEPATCH_LOG("Loaded (version %s) via DYLD_INSERT_LIBRARIES", SAGE_PATCH_VERSION); - init(); -} - -__attribute__((destructor)) -static void onUnload() { - shutdown(); - SAGEPATCH_LOG("Unloaded"); -} - -void init() { - SAGEPATCH_LOG("Active features: screenshot(F11), cursor-lock(Scroll Lock), brightness(Page+/Page-)"); -} - -void shutdown() {} - -} diff --git a/Patches/SagePatch/src/features/CursorLock.cpp b/Patches/SagePatch/src/common/CursorLock.cpp similarity index 100% rename from Patches/SagePatch/src/features/CursorLock.cpp rename to Patches/SagePatch/src/common/CursorLock.cpp diff --git a/Patches/SagePatch/src/common/Init.cpp b/Patches/SagePatch/src/common/Init.cpp new file mode 100644 index 00000000000..847740f1def --- /dev/null +++ b/Patches/SagePatch/src/common/Init.cpp @@ -0,0 +1,36 @@ +#include "SagePatch/Hooks.h" +#include "SagePatch/Logger.h" + +namespace sagepatch { + +#if defined(__APPLE__) +# define SAGE_LOAD_MECHANISM "DYLD_INSERT_LIBRARIES" +#elif defined(__linux__) +# define SAGE_LOAD_MECHANISM "LD_PRELOAD" +#else +# define SAGE_LOAD_MECHANISM "preload" +#endif + +__attribute__((constructor)) +static void onLoad() { + SAGEPATCH_LOG("Loaded (version %s) via %s", SAGE_PATCH_VERSION, SAGE_LOAD_MECHANISM); + init(); +} + +__attribute__((destructor)) +static void onUnload() { + shutdown(); + SAGEPATCH_LOG("Unloaded"); +} + +void init() { + SAGEPATCH_LOG("Hot-keys:"); + SAGEPATCH_LOG(" F11 screenshot (PNG to ~/Pictures/GeneralsX)"); + SAGEPATCH_LOG(" Scroll Lock toggle cursor lock"); + SAGEPATCH_LOG(" Ctrl+PageUp/Dn brightness +/-"); + SAGEPATCH_LOG(" Ctrl+1..5 window position (center / TL / TR / BL / BR)"); +} + +void shutdown() {} + +} diff --git a/Patches/SagePatch/src/common/KeyHandler.cpp b/Patches/SagePatch/src/common/KeyHandler.cpp new file mode 100644 index 00000000000..8fe87adf470 --- /dev/null +++ b/Patches/SagePatch/src/common/KeyHandler.cpp @@ -0,0 +1,53 @@ +#include "SagePatch/Hooks.h" +#include "SagePatch/Features.h" +#include "SagePatch/Logger.h" + +namespace sagepatch { + +bool handleKeyDown(const SDL_KeyboardEvent& ev) { + if (ev.repeat) return false; + + SDL_Window* window = SDL_GetWindowFromID(ev.windowID); + if (!window) return false; + + const bool ctrl = (ev.mod & SDL_KMOD_CTRL) != 0; + + switch (ev.key) { + case SDLK_F11: + takeScreenshot(window); + return true; + + case SDLK_SCROLLLOCK: + toggleCursorLock(window); + return true; + + case SDLK_PAGEUP: + if (ctrl) { adjustBrightness(+8); return true; } + break; + case SDLK_PAGEDOWN: + if (ctrl) { adjustBrightness(-8); return true; } + break; + + case SDLK_1: + if (ctrl) { moveWindow(window, WindowPosition::Center); return true; } + break; + case SDLK_2: + if (ctrl) { moveWindow(window, WindowPosition::TopLeft); return true; } + break; + case SDLK_3: + if (ctrl) { moveWindow(window, WindowPosition::TopRight); return true; } + break; + case SDLK_4: + if (ctrl) { moveWindow(window, WindowPosition::BottomLeft); return true; } + break; + case SDLK_5: + if (ctrl) { moveWindow(window, WindowPosition::BottomRight); return true; } + break; + + default: + break; + } + return false; +} + +} diff --git a/Patches/SagePatch/src/common/WindowPosition.cpp b/Patches/SagePatch/src/common/WindowPosition.cpp new file mode 100644 index 00000000000..95e915ed0a7 --- /dev/null +++ b/Patches/SagePatch/src/common/WindowPosition.cpp @@ -0,0 +1,59 @@ +// Window position presets — Ctrl+1..5 to snap the window to common positions +// on the active display. SDL3 does the work; works on every platform. + +#include "SagePatch/Features.h" +#include "SagePatch/Logger.h" + +#include + +namespace sagepatch { + +void moveWindow(SDL_Window* window, WindowPosition where) { + if (!window) return; + + SDL_DisplayID display = SDL_GetDisplayForWindow(window); + SDL_Rect bounds{}; + if (display == 0 || !SDL_GetDisplayUsableBounds(display, &bounds)) { + SAGEPATCH_LOG("WindowPosition: cannot resolve display bounds"); + return; + } + + int w = 0, h = 0; + SDL_GetWindowSize(window, &w, &h); + + int x = bounds.x, y = bounds.y; + const char* name = "?"; + + switch (where) { + case WindowPosition::Center: + x = bounds.x + (bounds.w - w) / 2; + y = bounds.y + (bounds.h - h) / 2; + name = "center"; + break; + case WindowPosition::TopLeft: + x = bounds.x; + y = bounds.y; + name = "top-left"; + break; + case WindowPosition::TopRight: + x = bounds.x + bounds.w - w; + y = bounds.y; + name = "top-right"; + break; + case WindowPosition::BottomLeft: + x = bounds.x; + y = bounds.y + bounds.h - h; + name = "bottom-left"; + break; + case WindowPosition::BottomRight: + x = bounds.x + bounds.w - w; + y = bounds.y + bounds.h - h; + name = "bottom-right"; + break; + } + + SDL_SetWindowPosition(window, x, y); + SAGEPATCH_LOG("Window position: %s (%d,%d)", name, x, y); +} + +} diff --git a/Patches/SagePatch/src/features/KeyHandler.cpp b/Patches/SagePatch/src/features/KeyHandler.cpp deleted file mode 100644 index 8e7278654b5..00000000000 --- a/Patches/SagePatch/src/features/KeyHandler.cpp +++ /dev/null @@ -1,38 +0,0 @@ -#include "SagePatch/Hooks.h" -#include "SagePatch/Features.h" -#include "SagePatch/Logger.h" - -namespace sagepatch { - -bool handleKeyDown(const SDL_KeyboardEvent& ev) { - if (ev.repeat) return false; - - SDL_Window* window = SDL_GetWindowFromID(ev.windowID); - if (!window) return false; - - switch (ev.key) { - case SDLK_F11: - takeScreenshot(window); - return true; - case SDLK_SCROLLLOCK: - toggleCursorLock(window); - return true; - case SDLK_PAGEUP: - if (ev.mod & SDL_KMOD_CTRL) { - adjustBrightness(+8); - return true; - } - break; - case SDLK_PAGEDOWN: - if (ev.mod & SDL_KMOD_CTRL) { - adjustBrightness(-8); - return true; - } - break; - default: - break; - } - return false; -} - -} diff --git a/Patches/SagePatch/src/linux/Brightness_linux.cpp b/Patches/SagePatch/src/linux/Brightness_linux.cpp new file mode 100644 index 00000000000..007e177189c --- /dev/null +++ b/Patches/SagePatch/src/linux/Brightness_linux.cpp @@ -0,0 +1,108 @@ +// Linux brightness via XF86VidMode (X11 only). Loaded lazily via dlopen so the +// patch links cleanly on systems without libXxf86vm or running under Wayland. +// Under Wayland we log a one-time warning and no-op. + +#include "SagePatch/Features.h" +#include "SagePatch/Logger.h" + +#include +#include +#include +#include +#include +#include + +namespace sagepatch { + +static int g_brightness = 0; // -128 .. +128 + +static int clampi(int v, int lo, int hi) { + if (v < lo) return lo; + if (v > hi) return hi; + return v; +} + +// Minimal X11 + XF86VidMode forward decls so we do not need their headers. +typedef struct _XDisplay Display; +typedef Display* (*XOpenDisplay_fn)(const char*); +typedef int (*XCloseDisplay_fn)(Display*); +typedef int (*XDefaultScreen_fn)(Display*); +typedef int (*XF86VidModeGetGammaRampSize_fn)(Display*, int, int*); +typedef int (*XF86VidModeSetGammaRamp_fn)(Display*, int, int, unsigned short*, unsigned short*, unsigned short*); + +static bool tryGamma(float gamma) { + void* libX11 = dlopen("libX11.so.6", RTLD_NOW | RTLD_GLOBAL); + if (!libX11) libX11 = dlopen("libX11.so", RTLD_NOW | RTLD_GLOBAL); + void* libXxf86vm = dlopen("libXxf86vm.so.1", RTLD_NOW); + if (!libXxf86vm) libXxf86vm = dlopen("libXxf86vm.so", RTLD_NOW); + + if (!libX11 || !libXxf86vm) { + if (libX11) dlclose(libX11); + if (libXxf86vm) dlclose(libXxf86vm); + return false; + } + + auto XOpenDisplay = (XOpenDisplay_fn)dlsym(libX11, "XOpenDisplay"); + auto XCloseDisplay = (XCloseDisplay_fn)dlsym(libX11, "XCloseDisplay"); + auto XDefaultScreen = (XDefaultScreen_fn)dlsym(libX11, "XDefaultScreen"); + auto XF86VidModeGetGammaRampSize = (XF86VidModeGetGammaRampSize_fn)dlsym(libXxf86vm, "XF86VidModeGetGammaRampSize"); + auto XF86VidModeSetGammaRamp = (XF86VidModeSetGammaRamp_fn)dlsym(libXxf86vm, "XF86VidModeSetGammaRamp"); + + if (!XOpenDisplay || !XCloseDisplay || !XDefaultScreen || + !XF86VidModeGetGammaRampSize || !XF86VidModeSetGammaRamp) { + dlclose(libX11); + dlclose(libXxf86vm); + return false; + } + + Display* dpy = XOpenDisplay(nullptr); + if (!dpy) { + dlclose(libX11); + dlclose(libXxf86vm); + return false; + } + + int screen = XDefaultScreen(dpy); + int rampSize = 0; + if (!XF86VidModeGetGammaRampSize(dpy, screen, &rampSize) || rampSize <= 0) { + XCloseDisplay(dpy); + dlclose(libX11); + dlclose(libXxf86vm); + return false; + } + + std::vector r(rampSize), g(rampSize), b(rampSize); + for (int i = 0; i < rampSize; ++i) { + double pos = static_cast(i) / (rampSize - 1); + double v = 65535.0 * pow(pos, 1.0 / gamma); + if (v < 0.0) v = 0.0; + if (v > 65535.0) v = 65535.0; + unsigned short val = static_cast(v); + r[i] = g[i] = b[i] = val; + } + XF86VidModeSetGammaRamp(dpy, screen, rampSize, r.data(), g.data(), b.data()); + + XCloseDisplay(dpy); + // Intentionally leak the dlopens — we want them to stay loaded for the + // process lifetime to avoid re-resolving on every keypress. + return true; +} + +void adjustBrightness(int delta) { + g_brightness = clampi(g_brightness + delta, -128, +128); + float gamma = 1.0f + static_cast(g_brightness) / 256.0f; + if (gamma < 0.5f) gamma = 0.5f; + if (gamma > 2.0f) gamma = 2.0f; + + static bool warned = false; + if (!tryGamma(gamma)) { + if (!warned) { + SAGEPATCH_LOG("Brightness: XF86VidMode unavailable (Wayland or missing libXxf86vm) — feature disabled."); + warned = true; + } + return; + } + SAGEPATCH_LOG("Brightness: %+d (gamma %.3f)", g_brightness, gamma); +} + +} diff --git a/Patches/SagePatch/src/linux/Screenshot_linux.cpp b/Patches/SagePatch/src/linux/Screenshot_linux.cpp new file mode 100644 index 00000000000..11ec947623a --- /dev/null +++ b/Patches/SagePatch/src/linux/Screenshot_linux.cpp @@ -0,0 +1,95 @@ +// Linux screenshot via ImageMagick `import` — captures by X11 window id. +// Wayland fallback: `grim -g ` if available. We do not depend on either +// at link time; if neither is installed, we log a hint and skip. + +#include "SagePatch/Features.h" +#include "SagePatch/Logger.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sagepatch { + +static void ensureScreenshotDir(char* outPath, size_t outPathLen) { + const char* home = getenv("HOME"); + if (!home || !*home) { + const struct passwd* pw = getpwuid(getuid()); + home = pw ? pw->pw_dir : "."; + } + snprintf(outPath, outPathLen, "%s/Pictures/GeneralsX", home); + mkdir(outPath, 0755); +} + +static void timestampFilename(char* out, size_t outLen, const char* dir) { + auto now = std::chrono::system_clock::now(); + std::time_t t = std::chrono::system_clock::to_time_t(now); + std::tm tm{}; + localtime_r(&t, &tm); + auto ms = std::chrono::duration_cast( + now.time_since_epoch()) % 1000; + snprintf(out, outLen, "%s/generalsx_%04d-%02d-%02d_%02d-%02d-%02d-%03d.png", + dir, + tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec, + static_cast(ms.count())); +} + +static bool tryImport(unsigned long winId, const char* path) { + char cmd[4096]; + snprintf(cmd, sizeof(cmd), + "command -v import >/dev/null 2>&1 && import -window 0x%lx \"%s\" 2>/dev/null", + winId, path); + int rc = system(cmd); + if (rc != 0) return false; + struct stat st; + return stat(path, &st) == 0 && st.st_size > 0; +} + +static bool tryGnomeScreenshot(const char* path) { + char cmd[4096]; + snprintf(cmd, sizeof(cmd), + "command -v gnome-screenshot >/dev/null 2>&1 && gnome-screenshot -w -f \"%s\" 2>/dev/null", + path); + int rc = system(cmd); + if (rc != 0) return false; + struct stat st; + return stat(path, &st) == 0 && st.st_size > 0; +} + +void takeScreenshot(SDL_Window* window) { + if (!window) { + SAGEPATCH_LOG("Screenshot: no window"); + return; + } + + SDL_PropertiesID props = SDL_GetWindowProperties(window); + auto winIdLong = static_cast( + SDL_GetNumberProperty(props, SDL_PROP_WINDOW_X11_WINDOW_NUMBER, 0)); + + char dir[1024], path[2048]; + ensureScreenshotDir(dir, sizeof(dir)); + timestampFilename(path, sizeof(path), dir); + + bool ok = false; + if (winIdLong != 0) { + ok = tryImport(winIdLong, path); + } + if (!ok) { + ok = tryGnomeScreenshot(path); + } + + if (ok) { + SAGEPATCH_LOG("Screenshot saved: %s", path); + } else { + SAGEPATCH_LOG("Screenshot: install ImageMagick (`import`) or gnome-screenshot to enable."); + SAGEPATCH_LOG("Screenshot: target was %s", path); + } +} + +} diff --git a/Patches/SagePatch/src/linux/interposers_linux.cpp b/Patches/SagePatch/src/linux/interposers_linux.cpp new file mode 100644 index 00000000000..73c1e59d001 --- /dev/null +++ b/Patches/SagePatch/src/linux/interposers_linux.cpp @@ -0,0 +1,30 @@ +// Linux: LD_PRELOAD-style symbol override. Our SDL_PollEvent wins resolution +// (loaded first via LD_PRELOAD), so all callers — including the host +// libSDL3.so when re-entered — see our hook. We get the real one via +// dlsym(RTLD_NEXT, ...) on first call. + +#include +#include +#include "SagePatch/Hooks.h" +#include "SagePatch/Logger.h" + +extern "C" SDL_bool SDL_PollEvent(SDL_Event* event) { + using PollFn = SDL_bool (*)(SDL_Event*); + static PollFn real = nullptr; + if (!real) { + real = reinterpret_cast(dlsym(RTLD_NEXT, "SDL_PollEvent")); + if (!real) { + SAGEPATCH_LOG("FATAL: dlsym(RTLD_NEXT, \"SDL_PollEvent\") failed: %s", dlerror()); + return SDL_FALSE; + } + } + while (real(event)) { + if (event->type == SDL_EVENT_KEY_DOWN) { + if (sagepatch::handleKeyDown(event->key)) { + continue; + } + } + return SDL_TRUE; + } + return SDL_FALSE; +} diff --git a/Patches/SagePatch/src/features/Brightness.cpp b/Patches/SagePatch/src/macos/Brightness_macos.cpp similarity index 100% rename from Patches/SagePatch/src/features/Brightness.cpp rename to Patches/SagePatch/src/macos/Brightness_macos.cpp diff --git a/Patches/SagePatch/src/features/Screenshot.cpp b/Patches/SagePatch/src/macos/Screenshot_macos.cpp similarity index 100% rename from Patches/SagePatch/src/features/Screenshot.cpp rename to Patches/SagePatch/src/macos/Screenshot_macos.cpp diff --git a/Patches/SagePatch/src/interposers.cpp b/Patches/SagePatch/src/macos/interposers_macos.cpp similarity index 100% rename from Patches/SagePatch/src/interposers.cpp rename to Patches/SagePatch/src/macos/interposers_macos.cpp diff --git a/Patches/SagePatch/src/util/Config.cpp b/Patches/SagePatch/src/util/Config.cpp deleted file mode 100644 index b7b8af5a227..00000000000 --- a/Patches/SagePatch/src/util/Config.cpp +++ /dev/null @@ -1,2 +0,0 @@ -// Stub — config will read from $HOME/.config/GeneralsX/sagepatch.conf in a -// future revision. For now, all defaults live in code. diff --git a/Patches/SagePatch/src/util/Logger.cpp b/Patches/SagePatch/src/util/Logger.cpp deleted file mode 100644 index cf1f633b9bb..00000000000 --- a/Patches/SagePatch/src/util/Logger.cpp +++ /dev/null @@ -1 +0,0 @@ -// Stub — header-only macros currently. Reserved for future file-based logging. diff --git a/Patches/SagePatch/src/windows/Stubs_windows.cpp b/Patches/SagePatch/src/windows/Stubs_windows.cpp new file mode 100644 index 00000000000..0a7b19123ab --- /dev/null +++ b/Patches/SagePatch/src/windows/Stubs_windows.cpp @@ -0,0 +1,32 @@ +// Windows backend stubs. +// +// Windows has no LD_PRELOAD or __DATA,__interpose equivalent. To hook +// SDL_PollEvent on Windows we would need either: +// - a proxy DLL pattern (rebuild the host's expected DLL load order so the +// game opens our DLL first, like the original GenTool's d3d8.dll wrap), or +// - in-process inline hooking via Detours / MinHook / Polyhook. +// +// Both options are larger than the macOS/Linux implementations and are +// deferred to a follow-up. On Windows users still have the original GenTool +// available, so SagePatch is opt-in QoL only on the platforms that lack one. +// +// The build system therefore disables SAGE_PATCH on Windows by default. If +// someone forces it ON, the linker will succeed (these stubs satisfy the +// symbol references) but the dylib does nothing at runtime. + +#include "SagePatch/Features.h" +#include "SagePatch/Logger.h" + +#include + +namespace sagepatch { + +void takeScreenshot(SDL_Window*) { + SAGEPATCH_LOG("Screenshot: Windows backend not implemented yet."); +} + +void adjustBrightness(int /*delta*/) { + SAGEPATCH_LOG("Brightness: Windows backend not implemented yet."); +} + +} diff --git a/docs/PATCHES/SAGEPATCH.md b/docs/PATCHES/SAGEPATCH.md index 52cd84a7f35..734ef21d255 100644 --- a/docs/PATCHES/SAGEPATCH.md +++ b/docs/PATCHES/SAGEPATCH.md @@ -1,99 +1,115 @@ -# SagePatch — Casual QoL Extras for GeneralsX (macOS) +# SagePatch — Casual QoL Extras for GeneralsX SagePatch is an optional, drop-in patch that adds quality-of-life features to GeneralsX without modifying the game source. Inspired by the casual subset of -GenTool, it ships as a separate dylib that is loaded via `DYLD_INSERT_LIBRARIES` -plus a small `GameData` INI override that the engine picks up at startup. +GenTool, it ships as a separate shared library that is loaded via the platform's +preload mechanism plus a small `GameData` INI override that the engine picks up +at startup. + +## Platform support + +| Platform | Mechanism | Status | +|---|---|---| +| **macOS** | `DYLD_INSERT_LIBRARIES` + `__DATA,__interpose` | ✅ Phase 1 | +| **Linux** | `LD_PRELOAD` + `dlsym(RTLD_NEXT, …)` | ✅ Phase 1 (X11 only for brightness) | +| **Windows** | proxy DLL pattern (deferred) | ⚠️ stubs build but no-op at runtime | + +Windows is deferred because Win32 has no native preload mechanism — it would +need either a proxy `d3d8.dll` (the original GenTool's pattern) or in-process +inline hooking via Detours/MinHook. Both are larger than the macOS/Linux +implementations and were skipped to ship the rest. Windows users still have +the original GenTool available. ## Features | Feature | Trigger | Notes | |---|---|---| -| Screenshot | `F11` | PNG saved to `~/Pictures/GeneralsX/`. Captures the actual game window via CoreGraphics. | -| Cursor lock toggle | `Scroll Lock` | Confines the mouse to the game window. Useful in windowed mode + multi-monitor setups. | +| Screenshot | `F11` | PNG saved to `~/Pictures/GeneralsX/`. macOS: `screencapture`. Linux: ImageMagick `import` (X11) or `gnome-screenshot`. | +| Cursor lock toggle | `Scroll Lock` | Confines mouse to the game window. SDL3 — works on every platform. | | Brightness up | `Ctrl + Page Up` | +8 step on the gamma curve, range −128…+128. | -| Brightness down | `Ctrl + Page Down` | −8 step. | +| Brightness down | `Ctrl + Page Down` | −8 step. macOS: CoreGraphics. Linux: XF86VidMode (X11 only — no-op under Wayland). | +| Window snap: center | `Ctrl + 1` | SDL3 `SDL_SetWindowPosition` to display center. | +| Window snap: top-left | `Ctrl + 2` | | +| Window snap: top-right | `Ctrl + 3` | | +| Window snap: bottom-left | `Ctrl + 4` | | +| Window snap: bottom-right | `Ctrl + 5` | | | Camera zoom range | (passive) | `MaxCameraHeight=800`, `MinCameraHeight=60`, `EnforceMaxCameraHeight=No`. | +| Camera pitch | (passive) | `CameraPitch=50` (vanilla ~63). | | Keyboard scroll speed | (passive) | `KeyboardScrollSpeedFactor=1.0` (vanilla 0.5). | +| FPS counter | (passive) | DXVK HUD pre-set to `fps` in the run wrapper when SagePatch is active. | Hot-key collisions: SagePatch eats the events it handles, so they do not also reach the game. ## How to enable -Build: +### macOS ```bash cmake --preset macos-vulkan -DRTS_BUILD_OPTION_SAGE_PATCH=ON cmake --build build/macos-vulkan --target z_generals -j$(sysctl -n hw.logicalcpu) -``` - -(The `macos-vulkan` preset already turns this `ON` by default — disable with -`-DRTS_BUILD_OPTION_SAGE_PATCH=OFF` if you want a vanilla build.) - -Deploy: - -```bash ./scripts/build/macos/deploy-macos-zh.sh +~/GeneralsX/GeneralsZH/run.sh -win ``` -The deploy script: -1. Copies `libsage_patch.dylib` next to `GeneralsXZH` -2. Drops `Override.ini` into `Data/INI/Default/GameData/SagePatch.ini` -3. Configures the wrapper `run.sh` to set `DYLD_INSERT_LIBRARIES` +(The `macos-vulkan` preset already sets the flag `ON`.) -Run as usual: +### Linux ```bash +cmake --preset linux64-deploy -DRTS_BUILD_OPTION_SAGE_PATCH=ON +cmake --build build/linux64-deploy --target z_generals -j$(nproc) +./scripts/build/linux/deploy-linux-zh.sh ~/GeneralsX/GeneralsZH/run.sh -win ``` -Take a screenshot mid-game with **F11**. - -## Disabling at runtime +(The `linux64-deploy` preset does **not** set the flag automatically — opt in +explicitly.) -Without rebuilding: +### Disabling at runtime (no rebuild) ```bash SAGE_PATCH_DISABLED=1 ~/GeneralsX/GeneralsZH/run.sh -win ``` -This skips the `DYLD_INSERT_LIBRARIES` step. The INI override remains active — -delete `Data/INI/Default/GameData/SagePatch.ini` to revert camera/scroll values. +This skips the preload step. The INI override remains active — delete +`Data/INI/Default/GameData/SagePatch.ini` to revert camera/scroll values. ## Architecture ``` Game process (GeneralsXZH) │ - ├── DYLD_INSERT_LIBRARIES → libsage_patch.dylib + ├── DYLD_INSERT_LIBRARIES (macOS) / LD_PRELOAD (Linux) → libsage_patch.{dylib,so} │ │ - │ └── __DATA,__interpose table replaces SDL_PollEvent + │ └── SDL_PollEvent gets replaced (interpose table on macOS, + │ symbol override + dlsym RTLD_NEXT on Linux) │ │ - │ └── on F11 / Scroll Lock / Ctrl+PgUp/Dn → SagePatch handlers + │ └── F11, Scroll Lock, Ctrl+PgUp/Dn, Ctrl+1..5 → SagePatch handlers │ │ - │ └── CoreGraphics / SDL3 / CGSetDisplayTransferByFormula + │ └── Per-platform: screencapture / ImageMagick, + │ CoreGraphics gamma / XF86VidMode, SDL_SetWindowPosition │ - └── Engine reads Data/INI/Default/GameData/SagePatch.ini → overrides camera/scroll + └── Engine loads Data/INI/Default/GameData/SagePatch.ini → camera/scroll overrides ``` -No D3D8 proxy, no Vulkan layer, no engine source modifications. The whole patch -is two files: a dylib and an INI. +No D3D8 proxy, no Vulkan layer, no engine source modifications. ## Why this approach The original GenTool had to be a `d3d8.dll` proxy because Windows games of that -era exposed no other plugin surface. On modern macOS we have: +era exposed no other plugin surface. On macOS and Linux we have: -- **`__interpose`** — ld + dyld replace symbol resolution at load time. We see - `SDL_PollEvent` exactly as the game calls it. -- **CoreGraphics window capture** — the macOS compositor already has the - composited pixels; we ask for them. No need to read a Vulkan back-buffer. -- **Engine INI overrides** — GeneralsX's INI loader merges files in - `Data/INI/Default//`. Parameter tweaks need zero code. +- **Symbol override at load time** — `__DATA,__interpose` (macOS) and + `LD_PRELOAD` (Linux) replace `SDL_PollEvent` with our version without + touching the host SDL3 library. +- **OS-native window capture** — both platforms expose tools that snapshot + a single window without graphics-pipeline hooks. +- **Engine INI overrides** — the GeneralsX INI loader merges files in + `Data/INI/Default//`, so parameter tweaks need zero code. -This keeps SagePatch ~400 lines instead of the ~3000 lines a full COM proxy -would require. +This keeps SagePatch ~600 lines instead of the ~3000-line full COM proxy +that a Windows-style implementation would require. ## Limits / what's *not* in scope @@ -107,24 +123,31 @@ By design, SagePatch sticks to **casual** QoL: Engine-side bug fixes (scud bug, tunnel bug, building bug, multiplayer crash) also live outside this patch — they require modifications inside the game's -source rather than a side-loaded dylib. +source rather than a side-loaded shared library, and SagePatch's whole point is +to stay outside the source tree. -## Files +## File layout ``` Patches/SagePatch/ CMakeLists.txt - include/SagePatch/Hooks.h - include/SagePatch/Features.h - include/SagePatch/Logger.h - src/Init.cpp # constructor / destructor - src/interposers.cpp # __DATA,__interpose table - src/features/KeyHandler.cpp # dispatches hot-keys - src/features/Screenshot.cpp # F11 → PNG - src/features/CursorLock.cpp # Scroll Lock toggle - src/features/Brightness.cpp # Ctrl+PgUp / Ctrl+PgDn - src/util/Logger.cpp # placeholder - src/util/Config.cpp # placeholder - resources/Override.ini # engine-side INI override -docs/PATCHES/SAGEPATCH.md # this file + include/SagePatch/{Hooks.h, Features.h, Logger.h} + src/ + common/ # SDL3 — works on every platform + Init.cpp + KeyHandler.cpp + CursorLock.cpp + WindowPosition.cpp + macos/ # macOS-only: __DATA,__interpose, screencapture, CoreGraphics + interposers_macos.cpp + Screenshot_macos.cpp + Brightness_macos.cpp + linux/ # Linux-only: LD_PRELOAD, ImageMagick, XF86VidMode + interposers_linux.cpp + Screenshot_linux.cpp + Brightness_linux.cpp + windows/ # Windows: stubs only (Phase 2) + Stubs_windows.cpp + resources/Override.ini # engine-side INI override +docs/PATCHES/SAGEPATCH.md # this file ``` diff --git a/scripts/build/linux/deploy-linux-zh.sh b/scripts/build/linux/deploy-linux-zh.sh index 868c89d1dae..4bd1d238037 100755 --- a/scripts/build/linux/deploy-linux-zh.sh +++ b/scripts/build/linux/deploy-linux-zh.sh @@ -103,6 +103,22 @@ patchelf --set-rpath '$ORIGIN' "${RUNTIME_DIR}/GeneralsXZH" 2>/dev/null || { echo " Libraries will need LD_LIBRARY_PATH or manual RPATH setting" } +# SagePatch (optional, gated by RTS_BUILD_OPTION_SAGE_PATCH at configure time). +# When the .so exists, deploy it + the Override.ini into Data/INI/Default/ so +# the engine picks up the casual QoL settings. The launcher wrapper sets +# LD_PRELOAD to load the .so at runtime. +SAGE_PATCH_LIB="${BUILD_DIR}/Patches/SagePatch/libsage_patch.so" +SAGE_PATCH_OVERRIDE="${PROJECT_ROOT}/Patches/SagePatch/resources/Override.ini" +if [[ -f "${SAGE_PATCH_LIB}" ]]; then + echo " Deploying SagePatch (libsage_patch.so)..." + cp -v "${SAGE_PATCH_LIB}" "${RUNTIME_DIR}/" + if [[ -f "${SAGE_PATCH_OVERRIDE}" ]]; then + mkdir -p "${RUNTIME_DIR}/Data/INI/Default/GameData" + cp -v "${SAGE_PATCH_OVERRIDE}" \ + "${RUNTIME_DIR}/Data/INI/Default/GameData/SagePatch.ini" + fi +fi + # Copy run wrapper script echo " Copying run.sh wrapper..." cat > "${RUNTIME_DIR}/run.sh" << 'EOF' @@ -118,7 +134,25 @@ export LD_LIBRARY_PATH="${SCRIPT_DIR}:${LD_LIBRARY_PATH:-}" # Set DXVK environment export DXVK_WSI_DRIVER="SDL3" export DXVK_LOG_LEVEL="${DXVK_LOG_LEVEL:-info}" -export DXVK_HUD="${DXVK_HUD:-0}" +# DXVK HUD: SagePatch builds default to "fps" so casual users see frame rate +# without extra config. Set DXVK_HUD=0 explicitly to disable, or anything else +# (e.g. fps,memory,version) to customize. See DXVK docs for full list. +if [[ -f "${SCRIPT_DIR}/libsage_patch.so" && "${SAGE_PATCH_DISABLED:-0}" != "1" ]]; then + export DXVK_HUD="${DXVK_HUD:-fps}" +else + export DXVK_HUD="${DXVK_HUD:-0}" +fi + +# SagePatch (optional QoL features). Loaded via LD_PRELOAD so it can interpose +# SDL3 functions for hot-keys (F11 screenshot, Scroll Lock cursor lock, +# Ctrl+PageUp/PageDown brightness, Ctrl+1..5 window snap). +if [[ -f "${SCRIPT_DIR}/libsage_patch.so" && "${SAGE_PATCH_DISABLED:-0}" != "1" ]]; then + if [[ -n "${LD_PRELOAD:-}" ]]; then + export LD_PRELOAD="${SCRIPT_DIR}/libsage_patch.so:${LD_PRELOAD}" + else + export LD_PRELOAD="${SCRIPT_DIR}/libsage_patch.so" + fi +fi # GeneralsX @feature felipebraz 25/02/2026 Auto-detect base Generals install path # Set CNC_GENERALS_INSTALLPATH if not already set and ../Generals/ exists diff --git a/scripts/build/macos/deploy-macos-zh.sh b/scripts/build/macos/deploy-macos-zh.sh index abe8075556b..3610e730401 100755 --- a/scripts/build/macos/deploy-macos-zh.sh +++ b/scripts/build/macos/deploy-macos-zh.sh @@ -184,7 +184,7 @@ export DYLD_LIBRARY_PATH="\${SCRIPT_DIR}:\${DYLD_LIBRARY_PATH:-}" # SagePatch (optional QoL features). Loaded via DYLD_INSERT_LIBRARIES so it # can interpose SDL3 functions for hot-keys (F11 screenshot, Scroll Lock cursor -# lock, Ctrl+PageUp/PageDown brightness). Ignored if the dylib is not present. +# lock, Ctrl+PageUp/PageDown brightness, Ctrl+1..5 window snap). if [[ -f "\${SCRIPT_DIR}/libsage_patch.dylib" && "\${SAGE_PATCH_DISABLED:-0}" != "1" ]]; then if [[ -n "\${DYLD_INSERT_LIBRARIES:-}" ]]; then export DYLD_INSERT_LIBRARIES="\${SCRIPT_DIR}/libsage_patch.dylib:\${DYLD_INSERT_LIBRARIES}" @@ -196,6 +196,13 @@ fi # GeneralsX @bugfix fbraz3 20/03/2026 DXVK requires DXVK_WSI_DRIVER on non-Win32; must match game windowing (SDL3) export DXVK_WSI_DRIVER="SDL3" +# DXVK HUD: SagePatch builds default to "fps" so casual users see frame rate +# without extra config. Set DXVK_HUD=0 explicitly to disable, or anything else +# (e.g. fps,memory,version) to customize. +if [[ -f "\${SCRIPT_DIR}/libsage_patch.dylib" && "\${SAGE_PATCH_DISABLED:-0}" != "1" ]]; then + export DXVK_HUD="\${DXVK_HUD:-fps}" +fi + # MoltenVK ICD manifest — deployed alongside the binary by deploy-macos-zh.sh if [[ -f "\${SCRIPT_DIR}/MoltenVK_icd.json" ]]; then export VK_ICD_FILENAMES="\${SCRIPT_DIR}/MoltenVK_icd.json" From 3a87fa2cda298489526c4a88bb4bfffc5ccd1cf9 Mon Sep 17 00:00:00 2001 From: Erasmo Bellumat Date: Sat, 25 Apr 2026 12:57:36 -0300 Subject: [PATCH 3/9] docs(sagepatch): clarify scope vs. existing engine flags and upstream fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two important clarifications after a code-base inventory: 1. Engine already supports GenTool-style CLI flags natively. Document -nologo, -noShellAnim, -noshellmap, -quickstart, -xres/-yres, -forcefullviewport, -noaudio/-nomusic/-novideo so users do not assume they need SagePatch to get those. They have always been part of the engine's CommandLine.cpp; no patch required. 2. Engine bug fixes (scud / tunnel / building / multiplayer movement crash) are handled by the upstream TheSuperHackers/GeneralsGameCode project which is maintained by the same author who wrote GenTool (xezon). The codebase already carries 170+ @bugfix annotations including the Tunnel System fixes by xezon himself. SagePatch does not duplicate them — that would create merge conflicts on the next upstream sync. This explicitly scopes SagePatch to QoL features that live outside the engine source tree. --- docs/PATCHES/SAGEPATCH.md | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/docs/PATCHES/SAGEPATCH.md b/docs/PATCHES/SAGEPATCH.md index 734ef21d255..7224de1f7d4 100644 --- a/docs/PATCHES/SAGEPATCH.md +++ b/docs/PATCHES/SAGEPATCH.md @@ -111,20 +111,45 @@ era exposed no other plugin surface. On macOS and Linux we have: This keeps SagePatch ~600 lines instead of the ~3000-line full COM proxy that a Windows-style implementation would require. +## Already in the engine — no patch needed + +These GenTool-era options are already first-class engine command-line flags +in GeneralsX/TheSuperHackers (see `Common/CommandLine.cpp`). Use them +directly with `run.sh`: + +| Flag | Effect | +|---|---| +| `-nologo` | Skip the EA / Westwood intro | +| `-noShellAnim` | Skip the animated main-menu camera | +| `-noshellmap` | Skip the animated background map | +| `-quickstart` | Combined fast-boot | +| `-xres N -yres N` | Any resolution (the engine no longer locks the list) | +| `-forcefullviewport` | Full viewport on UI mods like Control Bar Pro | +| `-win` / `-fullscreen` | Window mode | +| `-noaudio` / `-nomusic` / `-novideo` | Disable subsystems | + +Game-engine bug fixes from the GenTool changelog (Scud bug, Tunnel bug, +Building bug, multiplayer movement crash, etc.) are handled **upstream by +TheSuperHackers/GeneralsGameCode**, which is the parent of GeneralsX and +maintained by the same author who wrote GenTool (xezon). The codebase +already carries 170+ `@bugfix` annotations including the Tunnel System +fixes by xezon. SagePatch does **not** duplicate those — duplicating fixes +would only create merge conflicts when GeneralsX next syncs with upstream. + ## Limits / what's *not* in scope -By design, SagePatch sticks to **casual** QoL: +By design, SagePatch sticks to **casual** QoL only: - No replay tools (frame stepping, fog-of-war replay, controls bar) - No competitive features (Money Display, Player Table, Random Balance) - No anti-cheat (MDS, Game File Validator, version validation) - No external server integrations (CNC Online, GameRanger, Upload Mode, ticker, ranked maps, ladder, GenTool updater) - -Engine-side bug fixes (scud bug, tunnel bug, building bug, multiplayer crash) -also live outside this patch — they require modifications inside the game's -source rather than a side-loaded shared library, and SagePatch's whole point is -to stay outside the source tree. +- No engine bug fixes — those live in core source, gated by the existing + `@bugfix` annotation system, contributed upstream where possible +- No in-game text overlay (clock, match timer, in-game settings menu) — these + need a graphics-pipeline hook (D3D8 proxy or Vulkan layer); see the FPS + counter via `DXVK_HUD` for an existing alternative ## File layout From 1cf944fdc4541a17f39d388a235ca3e87629161f Mon Sep 17 00:00:00 2001 From: Erasmo Bellumat Date: Sat, 25 Apr 2026 13:06:21 -0300 Subject: [PATCH 4/9] feat(sagepatch): wire SagePatch through every release bundle path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local deploy already shipped SagePatch on the previous commit. This wires the same artifacts (libsage_patch.{dylib,so} + Override.ini) into every release bundle so users actually get the QoL features when they download the official zip / tar / flatpak — not just when they build from source. Touches: - scripts/build/macos/bundle-macos-zh.sh - scripts/build/macos/bundle-macos-generals.sh - scripts/build/linux/bundle-linux-zh.sh - scripts/build/linux/bundle-linux.sh - flatpak/com.fbraz3.GeneralsXZH.yml - flatpak/com.fbraz3.GeneralsX.yml Each one now: 1. Copies libsage_patch.{dylib,so} into the bundle's lib path (guarded by `-f` so the build still succeeds when SAGE_PATCH=OFF). 2. Ships resources/Override.ini into the bundle's data path (Resources/Data/... on macOS, /app/share/... in flatpak, Data/... in the standalone Linux tar). 3. Generated wrapper scripts now set DYLD_INSERT_LIBRARIES / LD_PRELOAD when the lib is present, with a `:` guard so empty existing values do not break dyld. 4. Wrapper seeds the INI override into `${CNC_GENERALS_*_PATH}/Data/INI/Default/GameData/SagePatch.ini` on first launch (engine reads INIs from cwd, not from inside the bundle). 5. DXVK_HUD defaults to "fps" when SagePatch is active (was: "0"). Flatpak manifests also pick up `-DRTS_BUILD_OPTION_SAGE_PATCH=ON` and run the `sage_patch` build target after `z_generals` / `g_generals`. Verified on macOS by running bundle-macos-zh.sh end-to-end: GeneralsXZH.app/Contents/Resources/lib/libsage_patch.dylib (55 KB, arm64) GeneralsXZH.app/Contents/Resources/Data/INI/Default/GameData/SagePatch.ini --- flatpak/com.fbraz3.GeneralsX.yml | 31 ++++++++++++++- flatpak/com.fbraz3.GeneralsXZH.yml | 33 +++++++++++++++- scripts/build/linux/bundle-linux-zh.sh | 28 ++++++++++++- scripts/build/linux/bundle-linux.sh | 27 ++++++++++++- scripts/build/macos/bundle-macos-generals.sh | 34 ++++++++++++++++ scripts/build/macos/bundle-macos-zh.sh | 41 ++++++++++++++++++++ 6 files changed, 190 insertions(+), 4 deletions(-) diff --git a/flatpak/com.fbraz3.GeneralsX.yml b/flatpak/com.fbraz3.GeneralsX.yml index 033dc64b084..497f3e99159 100644 --- a/flatpak/com.fbraz3.GeneralsX.yml +++ b/flatpak/com.fbraz3.GeneralsX.yml @@ -57,6 +57,7 @@ modules: -DSAGE_USE_OPENAL=ON \ -DSAGE_USE_GLM=ON \ -DSAGE_UPDATE_CHECK=ON \ + -DRTS_BUILD_OPTION_SAGE_PATCH=ON \ -DFETCHCONTENT_FULLY_DISCONNECTED=ON \ -DFETCHCONTENT_UPDATES_DISCONNECTED=ON \ -DFETCHCONTENT_SOURCE_DIR_DXVK=/run/build/generalsx/dxvk-src \ @@ -66,6 +67,7 @@ modules: -DFETCHCONTENT_SOURCE_DIR_GAMESPY=/run/build/generalsx/gamespy-src \ -DFETCHCONTENT_SOURCE_DIR_LZHL=/run/build/generalsx/lzhl-src/CompLibHeader - cmake --build build-flatpak --target g_generals -j "${FLATPAK_BUILDER_N_JOBS:-4}" + - cmake --build build-flatpak --target sage_patch -j "${FLATPAK_BUILDER_N_JOBS:-4}" || true - install -Dm755 build-flatpak/Generals/GeneralsX /app/bin/GeneralsX - install -Dm755 flatpak/run-flatpak-generals.sh /app/bin/generalsx-wrapper.sh - install -Dm644 flatpak/com.fbraz3.GeneralsX.desktop /app/share/applications/com.fbraz3.GeneralsX.desktop @@ -79,6 +81,9 @@ modules: - cp -a build-flatpak/_deps/sdl3_image-build/libSDL3_image.so* /app/lib/ - cp -a build-flatpak/_deps/openal_soft-build/libopenal.so* /app/lib/ - find build-flatpak -name "libgamespy.so*" -type f -exec cp -a {} /app/lib/ \; + # SagePatch (optional QoL, gated by RTS_BUILD_OPTION_SAGE_PATCH at build). + - if [ -f build-flatpak/Patches/SagePatch/libsage_patch.so ]; then cp -a build-flatpak/Patches/SagePatch/libsage_patch.so /app/lib/; fi + - if [ -f Patches/SagePatch/resources/Override.ini ]; then install -Dm644 Patches/SagePatch/resources/Override.ini /app/share/generalsx/SagePatch.ini; fi - | for f in /app/lib/lib*.so.*; do b="$(basename "$f")" @@ -101,7 +106,21 @@ modules: export DXVK_WSI_DRIVER="SDL3" export DXVK_LOG_LEVEL="${DXVK_LOG_LEVEL:-info}" - export DXVK_HUD="${DXVK_HUD:-0}" + + # SagePatch (optional QoL: F11 screenshot, Scroll Lock cursor lock, + # Ctrl+PgUp/Dn brightness, Ctrl+1..5 window snap). Loaded via LD_PRELOAD + # only if the .so is present in /app/lib. Default DXVK_HUD to "fps" when + # SagePatch is active so casual users see a frame counter. + if [[ -f /app/lib/libsage_patch.so && "${SAGE_PATCH_DISABLED:-0}" != "1" ]]; then + if [[ -n "${LD_PRELOAD:-}" ]]; then + export LD_PRELOAD="/app/lib/libsage_patch.so:${LD_PRELOAD}" + else + export LD_PRELOAD="/app/lib/libsage_patch.so" + fi + export DXVK_HUD="${DXVK_HUD:-fps}" + else + export DXVK_HUD="${DXVK_HUD:-0}" + fi # Keep Vulkan ICD selection to the runtime/driver defaults unless user explicitly overrides. @@ -129,6 +148,16 @@ modules: fi cd "${CNC_GENERALS_INSTALLPATH}" + + # SagePatch INI override: engine reads INIs from cwd. Seed the bundled + # override into the asset tree on first launch. + SAGE_INI_SRC="/app/share/generalsx/SagePatch.ini" + SAGE_INI_DST="${CNC_GENERALS_INSTALLPATH}Data/INI/Default/GameData/SagePatch.ini" + if [[ -f "${SAGE_INI_SRC}" && ! -f "${SAGE_INI_DST}" && "${SAGE_PATCH_DISABLED:-0}" != "1" ]]; then + mkdir -p "$(dirname "${SAGE_INI_DST}")" + cp "${SAGE_INI_SRC}" "${SAGE_INI_DST}" 2>/dev/null || true + fi + exec /app/bin/GeneralsX "$@" EOF - chmod +x /app/bin/run.sh diff --git a/flatpak/com.fbraz3.GeneralsXZH.yml b/flatpak/com.fbraz3.GeneralsXZH.yml index b0fd10b95a0..448ae4cb4e5 100644 --- a/flatpak/com.fbraz3.GeneralsXZH.yml +++ b/flatpak/com.fbraz3.GeneralsXZH.yml @@ -57,6 +57,7 @@ modules: -DSAGE_USE_OPENAL=ON \ -DSAGE_USE_GLM=ON \ -DSAGE_UPDATE_CHECK=ON \ + -DRTS_BUILD_OPTION_SAGE_PATCH=ON \ -DFETCHCONTENT_FULLY_DISCONNECTED=ON \ -DFETCHCONTENT_UPDATES_DISCONNECTED=ON \ -DFETCHCONTENT_SOURCE_DIR_DXVK=/run/build/generalsxzh/dxvk-src \ @@ -66,6 +67,7 @@ modules: -DFETCHCONTENT_SOURCE_DIR_GAMESPY=/run/build/generalsxzh/gamespy-src \ -DFETCHCONTENT_SOURCE_DIR_LZHL=/run/build/generalsxzh/lzhl-src/CompLibHeader - cmake --build build-flatpak --target z_generals -j "${FLATPAK_BUILDER_N_JOBS:-4}" + - cmake --build build-flatpak --target sage_patch -j "${FLATPAK_BUILDER_N_JOBS:-4}" || true - install -Dm755 build-flatpak/GeneralsMD/GeneralsXZH /app/bin/GeneralsXZH - install -Dm755 flatpak/run-flatpak.sh /app/bin/generalsxzh-wrapper.sh - install -Dm644 flatpak/com.fbraz3.GeneralsXZH.desktop /app/share/applications/com.fbraz3.GeneralsXZH.desktop @@ -79,6 +81,10 @@ modules: - cp -a build-flatpak/_deps/sdl3_image-build/libSDL3_image.so* /app/lib/ - cp -a build-flatpak/_deps/openal_soft-build/libopenal.so* /app/lib/ - find build-flatpak -name "libgamespy.so*" -type f -exec cp -a {} /app/lib/ \; + # SagePatch (optional QoL, gated by RTS_BUILD_OPTION_SAGE_PATCH at build). + # Only present when the option is ON; -f guard keeps the manifest robust. + - if [ -f build-flatpak/Patches/SagePatch/libsage_patch.so ]; then cp -a build-flatpak/Patches/SagePatch/libsage_patch.so /app/lib/; fi + - if [ -f Patches/SagePatch/resources/Override.ini ]; then install -Dm644 Patches/SagePatch/resources/Override.ini /app/share/generalsxzh/SagePatch.ini; fi - | for f in /app/lib/lib*.so.*; do b="$(basename "$f")" @@ -101,7 +107,21 @@ modules: export DXVK_WSI_DRIVER="SDL3" export DXVK_LOG_LEVEL="${DXVK_LOG_LEVEL:-info}" - export DXVK_HUD="${DXVK_HUD:-0}" + + # SagePatch (optional QoL: F11 screenshot, Scroll Lock cursor lock, + # Ctrl+PgUp/Dn brightness, Ctrl+1..5 window snap). Loaded via LD_PRELOAD + # only if the .so is present in /app/lib. Default DXVK_HUD to "fps" when + # SagePatch is active so casual users see a frame counter. + if [[ -f /app/lib/libsage_patch.so && "${SAGE_PATCH_DISABLED:-0}" != "1" ]]; then + if [[ -n "${LD_PRELOAD:-}" ]]; then + export LD_PRELOAD="/app/lib/libsage_patch.so:${LD_PRELOAD}" + else + export LD_PRELOAD="/app/lib/libsage_patch.so" + fi + export DXVK_HUD="${DXVK_HUD:-fps}" + else + export DXVK_HUD="${DXVK_HUD:-0}" + fi # Keep Vulkan ICD selection to the runtime/driver defaults unless user explicitly overrides. @@ -131,6 +151,17 @@ modules: fi cd "${CNC_GENERALS_INSTALLPATH}" + + # SagePatch INI override: engine reads INIs from cwd, not /app/share. + # On first launch, copy the bundled override into the asset tree so + # casual QoL settings (camera height, scroll speed, pitch) take effect. + SAGE_INI_SRC="/app/share/generalsxzh/SagePatch.ini" + SAGE_INI_DST="${CNC_GENERALS_INSTALLPATH}Data/INI/Default/GameData/SagePatch.ini" + if [[ -f "${SAGE_INI_SRC}" && ! -f "${SAGE_INI_DST}" && "${SAGE_PATCH_DISABLED:-0}" != "1" ]]; then + mkdir -p "$(dirname "${SAGE_INI_DST}")" + cp "${SAGE_INI_SRC}" "${SAGE_INI_DST}" 2>/dev/null || true + fi + exec /app/bin/GeneralsXZH "$@" EOF - chmod +x /app/bin/run.sh diff --git a/scripts/build/linux/bundle-linux-zh.sh b/scripts/build/linux/bundle-linux-zh.sh index 2aa556585e2..633165fbef4 100755 --- a/scripts/build/linux/bundle-linux-zh.sh +++ b/scripts/build/linux/bundle-linux-zh.sh @@ -83,6 +83,19 @@ cp "${SDL3_IMAGE_LIB_DIR}"/libSDL3_image.so* "${BUNDLE_DIR}/" echo " + GameSpy library" cp "${GAMESPY_LIB}" "${BUNDLE_DIR}/" +# SagePatch (optional, gated by RTS_BUILD_OPTION_SAGE_PATCH at configure time). +SAGE_PATCH_LIB="${BUILD_DIR}/Patches/SagePatch/libsage_patch.so" +SAGE_PATCH_OVERRIDE="${PROJECT_ROOT}/Patches/SagePatch/resources/Override.ini" +if [[ -f "${SAGE_PATCH_LIB}" ]]; then + echo " + libsage_patch (SagePatch QoL)" + cp "${SAGE_PATCH_LIB}" "${BUNDLE_DIR}/" + if [[ -f "${SAGE_PATCH_OVERRIDE}" ]]; then + mkdir -p "${BUNDLE_DIR}/Data/INI/Default/GameData" + cp "${SAGE_PATCH_OVERRIDE}" \ + "${BUNDLE_DIR}/Data/INI/Default/GameData/SagePatch.ini" + fi +fi + # DXVK config if [[ -f "${DXVK_CONF_SRC}" ]]; then echo " + dxvk.conf" @@ -104,7 +117,20 @@ export LD_LIBRARY_PATH="${SCRIPT_DIR}:${LD_LIBRARY_PATH:-}" # Set DXVK environment export DXVK_WSI_DRIVER="SDL3" export DXVK_LOG_LEVEL="${DXVK_LOG_LEVEL:-info}" -export DXVK_HUD="${DXVK_HUD:-0}" + +# SagePatch (optional QoL: F11 screenshot, Scroll Lock cursor lock, +# Ctrl+PgUp/Dn brightness, Ctrl+1..5 window snap). Loaded via LD_PRELOAD only +# if libsage_patch.so is bundled. DXVK_HUD defaults to "fps" when active. +if [[ -f "${SCRIPT_DIR}/libsage_patch.so" && "${SAGE_PATCH_DISABLED:-0}" != "1" ]]; then + if [[ -n "${LD_PRELOAD:-}" ]]; then + export LD_PRELOAD="${SCRIPT_DIR}/libsage_patch.so:${LD_PRELOAD}" + else + export LD_PRELOAD="${SCRIPT_DIR}/libsage_patch.so" + fi + export DXVK_HUD="${DXVK_HUD:-fps}" +else + export DXVK_HUD="${DXVK_HUD:-0}" +fi # Auto-detect base Generals install path if [[ -z "${CNC_GENERALS_INSTALLPATH:-}" && -d "${SCRIPT_DIR}/../Generals" ]]; then diff --git a/scripts/build/linux/bundle-linux.sh b/scripts/build/linux/bundle-linux.sh index cdf26e9995c..6312d2491a0 100755 --- a/scripts/build/linux/bundle-linux.sh +++ b/scripts/build/linux/bundle-linux.sh @@ -83,6 +83,19 @@ cp "${SDL3_IMAGE_LIB_DIR}"/libSDL3_image.so* "${BUNDLE_DIR}/" echo " + GameSpy library" cp "${GAMESPY_LIB}" "${BUNDLE_DIR}/" +# SagePatch (optional, gated by RTS_BUILD_OPTION_SAGE_PATCH at configure time). +SAGE_PATCH_LIB="${BUILD_DIR}/Patches/SagePatch/libsage_patch.so" +SAGE_PATCH_OVERRIDE="${PROJECT_ROOT}/Patches/SagePatch/resources/Override.ini" +if [[ -f "${SAGE_PATCH_LIB}" ]]; then + echo " + libsage_patch (SagePatch QoL)" + cp "${SAGE_PATCH_LIB}" "${BUNDLE_DIR}/" + if [[ -f "${SAGE_PATCH_OVERRIDE}" ]]; then + mkdir -p "${BUNDLE_DIR}/Data/INI/Default/GameData" + cp "${SAGE_PATCH_OVERRIDE}" \ + "${BUNDLE_DIR}/Data/INI/Default/GameData/SagePatch.ini" + fi +fi + # DXVK config if [[ -f "${DXVK_CONF_SRC}" ]]; then echo " + dxvk.conf" @@ -104,7 +117,19 @@ export LD_LIBRARY_PATH="${SCRIPT_DIR}:${LD_LIBRARY_PATH:-}" # Set DXVK environment export DXVK_WSI_DRIVER="SDL3" export DXVK_LOG_LEVEL="${DXVK_LOG_LEVEL:-info}" -export DXVK_HUD="${DXVK_HUD:-0}" + +# SagePatch (optional QoL: F11 screenshot, Scroll Lock cursor lock, +# Ctrl+PgUp/Dn brightness, Ctrl+1..5 window snap). LD_PRELOAD only when bundled. +if [[ -f "${SCRIPT_DIR}/libsage_patch.so" && "${SAGE_PATCH_DISABLED:-0}" != "1" ]]; then + if [[ -n "${LD_PRELOAD:-}" ]]; then + export LD_PRELOAD="${SCRIPT_DIR}/libsage_patch.so:${LD_PRELOAD}" + else + export LD_PRELOAD="${SCRIPT_DIR}/libsage_patch.so" + fi + export DXVK_HUD="${DXVK_HUD:-fps}" +else + export DXVK_HUD="${DXVK_HUD:-0}" +fi # Auto-detect base Generals install path if [[ -z "${CNC_GENERALS_INSTALLPATH:-}" && -d "${SCRIPT_DIR}/../Generals" ]]; then diff --git a/scripts/build/macos/bundle-macos-generals.sh b/scripts/build/macos/bundle-macos-generals.sh index 29197d49da1..53aefe16243 100755 --- a/scripts/build/macos/bundle-macos-generals.sh +++ b/scripts/build/macos/bundle-macos-generals.sh @@ -283,6 +283,19 @@ echo " + libdxvk_d3d8" cp "${DXVK_D3D8_LIB}" "${LIB_DIR}/libdxvk_d3d8.0.dylib" ln -sf libdxvk_d3d8.0.dylib "${LIB_DIR}/libdxvk_d3d8.dylib" +# SagePatch (optional, gated by RTS_BUILD_OPTION_SAGE_PATCH at configure time). +SAGE_PATCH_LIB="${BUILD_DIR}/Patches/SagePatch/libsage_patch.dylib" +SAGE_PATCH_OVERRIDE="${PROJECT_ROOT}/Patches/SagePatch/resources/Override.ini" +if [[ -f "${SAGE_PATCH_LIB}" ]]; then + echo " + libsage_patch (SagePatch QoL)" + cp "${SAGE_PATCH_LIB}" "${LIB_DIR}/libsage_patch.dylib" + if [[ -f "${SAGE_PATCH_OVERRIDE}" ]]; then + mkdir -p "${RESOURCES_DIR}/Data/INI/Default/GameData" + cp "${SAGE_PATCH_OVERRIDE}" \ + "${RESOURCES_DIR}/Data/INI/Default/GameData/SagePatch.ini" + fi +fi + if [[ "${INCLUDE_EXTERNAL_DYLIBS}" == "1" ]]; then echo " + scanning for external dylibs (Homebrew/system extras)" collect_external_dylibs "${LIB_DIR}" \ @@ -347,9 +360,22 @@ LIB_DIR="${RESOURCES_DIR}/lib" export DYLD_LIBRARY_PATH="${LIB_DIR}:${BIN_DIR}:${DYLD_LIBRARY_PATH:-}" +# SagePatch — see notes in bundle-macos-zh.sh. +if [[ -f "${LIB_DIR}/libsage_patch.dylib" && "${SAGE_PATCH_DISABLED:-0}" != "1" ]]; then + if [[ -n "${DYLD_INSERT_LIBRARIES:-}" ]]; then + export DYLD_INSERT_LIBRARIES="${LIB_DIR}/libsage_patch.dylib:${DYLD_INSERT_LIBRARIES}" + else + export DYLD_INSERT_LIBRARIES="${LIB_DIR}/libsage_patch.dylib" + fi +fi + # GeneralsX @bugfix fbraz3 20/03/2026 DXVK requires this env var on non-Win32; SDL3 matches game windowing layer export DXVK_WSI_DRIVER="SDL3" +if [[ -f "${LIB_DIR}/libsage_patch.dylib" && "${SAGE_PATCH_DISABLED:-0}" != "1" ]]; then + export DXVK_HUD="${DXVK_HUD:-fps}" +fi + if [[ -f "${RESOURCES_DIR}/MoltenVK_icd.json" ]]; then export VK_ICD_FILENAMES="${RESOURCES_DIR}/MoltenVK_icd.json" # GeneralsX @bugfix fbraz3 20/03/2026 Vulkan Loader 1.3.236+ uses VK_DRIVER_FILES; keep VK_ICD_FILENAMES for older loaders @@ -371,6 +397,14 @@ fi # Run from the detected Generals asset root when available. if [[ -d "${CNC_GENERALS_PATH}" ]]; then cd "${CNC_GENERALS_PATH}" + + # SagePatch INI override seed — see notes in bundle-macos-zh.sh. + SAGE_INI_SRC="${RESOURCES_DIR}/Data/INI/Default/GameData/SagePatch.ini" + SAGE_INI_DST="${CNC_GENERALS_PATH}/Data/INI/Default/GameData/SagePatch.ini" + if [[ -f "${SAGE_INI_SRC}" && ! -f "${SAGE_INI_DST}" && "${SAGE_PATCH_DISABLED:-0}" != "1" ]]; then + mkdir -p "$(dirname "${SAGE_INI_DST}")" + cp "${SAGE_INI_SRC}" "${SAGE_INI_DST}" + fi fi exec "${BIN_DIR}/GeneralsX" "$@" diff --git a/scripts/build/macos/bundle-macos-zh.sh b/scripts/build/macos/bundle-macos-zh.sh index 2cd8455c340..635425f29f8 100755 --- a/scripts/build/macos/bundle-macos-zh.sh +++ b/scripts/build/macos/bundle-macos-zh.sh @@ -285,6 +285,19 @@ echo " + libdxvk_d3d8" cp "${DXVK_D3D8_LIB}" "${LIB_DIR}/libdxvk_d3d8.0.dylib" ln -sf libdxvk_d3d8.0.dylib "${LIB_DIR}/libdxvk_d3d8.dylib" +# SagePatch (optional, gated by RTS_BUILD_OPTION_SAGE_PATCH at configure time). +SAGE_PATCH_LIB="${BUILD_DIR}/Patches/SagePatch/libsage_patch.dylib" +SAGE_PATCH_OVERRIDE="${PROJECT_ROOT}/Patches/SagePatch/resources/Override.ini" +if [[ -f "${SAGE_PATCH_LIB}" ]]; then + echo " + libsage_patch (SagePatch QoL)" + cp "${SAGE_PATCH_LIB}" "${LIB_DIR}/libsage_patch.dylib" + if [[ -f "${SAGE_PATCH_OVERRIDE}" ]]; then + mkdir -p "${RESOURCES_DIR}/Data/INI/Default/GameData" + cp "${SAGE_PATCH_OVERRIDE}" \ + "${RESOURCES_DIR}/Data/INI/Default/GameData/SagePatch.ini" + fi +fi + if [[ "${INCLUDE_EXTERNAL_DYLIBS}" == "1" ]]; then echo " + scanning for external dylibs (Homebrew/system extras)" collect_external_dylibs "${LIB_DIR}" \ @@ -365,9 +378,27 @@ LIB_DIR="${RESOURCES_DIR}/lib" export DYLD_LIBRARY_PATH="${LIB_DIR}:${BIN_DIR}:${DYLD_LIBRARY_PATH:-}" +# SagePatch (optional QoL: F11 screenshot, Scroll Lock cursor lock, Ctrl+PgUp/Dn +# brightness, Ctrl+1..5 window snap). Only loaded when the bundled dylib is +# present and SAGE_PATCH_DISABLED is not set. Also seeds the engine INI loader +# via Resources/Data/INI/Default/GameData/SagePatch.ini. +if [[ -f "${LIB_DIR}/libsage_patch.dylib" && "${SAGE_PATCH_DISABLED:-0}" != "1" ]]; then + if [[ -n "${DYLD_INSERT_LIBRARIES:-}" ]]; then + export DYLD_INSERT_LIBRARIES="${LIB_DIR}/libsage_patch.dylib:${DYLD_INSERT_LIBRARIES}" + else + export DYLD_INSERT_LIBRARIES="${LIB_DIR}/libsage_patch.dylib" + fi +fi + # GeneralsX @bugfix fbraz3 20/03/2026 DXVK requires this env var on non-Win32; SDL3 matches game windowing layer export DXVK_WSI_DRIVER="SDL3" +# DXVK HUD: when SagePatch is bundled and active, default to "fps" so casual +# users see a frame counter without extra config. Set DXVK_HUD=0 to disable. +if [[ -f "${LIB_DIR}/libsage_patch.dylib" && "${SAGE_PATCH_DISABLED:-0}" != "1" ]]; then + export DXVK_HUD="${DXVK_HUD:-fps}" +fi + if [[ -f "${RESOURCES_DIR}/MoltenVK_icd.json" ]]; then export VK_ICD_FILENAMES="${RESOURCES_DIR}/MoltenVK_icd.json" # GeneralsX @bugfix fbraz3 20/03/2026 Vulkan Loader 1.3.236+ uses VK_DRIVER_FILES; keep VK_ICD_FILENAMES for older loaders @@ -407,6 +438,16 @@ fi # Run from the detected Zero Hour asset root when available. if [[ -d "${CNC_GENERALS_ZH_PATH}" ]]; then cd "${CNC_GENERALS_ZH_PATH}" + + # SagePatch INI override: the engine reads INIs from the cwd, not from + # inside the .app bundle. On first launch (or if the user deleted it), + # seed the override into the asset directory so casual QoL takes effect. + SAGE_INI_SRC="${RESOURCES_DIR}/Data/INI/Default/GameData/SagePatch.ini" + SAGE_INI_DST="${CNC_GENERALS_ZH_PATH}/Data/INI/Default/GameData/SagePatch.ini" + if [[ -f "${SAGE_INI_SRC}" && ! -f "${SAGE_INI_DST}" && "${SAGE_PATCH_DISABLED:-0}" != "1" ]]; then + mkdir -p "$(dirname "${SAGE_INI_DST}")" + cp "${SAGE_INI_SRC}" "${SAGE_INI_DST}" + fi fi exec "${BIN_DIR}/GeneralsXZH" "$@" From 55f06b77c92ff00db0b9d688e8e53edb96dbd38c Mon Sep 17 00:00:00 2001 From: Erasmo Bellumat Date: Sat, 25 Apr 2026 21:07:11 -0300 Subject: [PATCH 5/9] fix(sagepatch,macos): default DXVK_HUD off; MoltenVK can't compile DrawIndex The wrapper scripts shipped DXVK_HUD=fps when SagePatch was active. On macOS 26 (MoltenVK 1.4.1, current SDK), DXVK's HUD pipeline shader uses gl_DrawID, which lowers to SPIR-V DrawIndex. SPIRV-Cross to MSL has no equivalent for that decoration and aborts conversion: [mvk-error] SPIR-V to MSL conversion error: DrawIndex is not supported in MSL. err: Failed to create swap chain blit pipeline: VK_ERROR_INITIALIZATION_FAILED The blit pipeline failure means DXVK can never present a frame; the game hangs at the EA Games logo (last thing the engine drew before DXVK started needing the blit pipeline). Revert the default to DXVK_HUD=0 on the three macOS wrapper scripts. Users who want the FPS overlay can still opt in with DXVK_HUD=fps. Linux and Flatpak wrappers are unchanged: native Vulkan drivers handle DrawIndex correctly there. --- scripts/build/macos/bundle-macos-generals.sh | 5 ++--- scripts/build/macos/bundle-macos-zh.sh | 10 +++++----- scripts/build/macos/deploy-macos-zh.sh | 11 +++++------ 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/scripts/build/macos/bundle-macos-generals.sh b/scripts/build/macos/bundle-macos-generals.sh index 53aefe16243..b96892d75f1 100755 --- a/scripts/build/macos/bundle-macos-generals.sh +++ b/scripts/build/macos/bundle-macos-generals.sh @@ -372,9 +372,8 @@ fi # GeneralsX @bugfix fbraz3 20/03/2026 DXVK requires this env var on non-Win32; SDL3 matches game windowing layer export DXVK_WSI_DRIVER="SDL3" -if [[ -f "${LIB_DIR}/libsage_patch.dylib" && "${SAGE_PATCH_DISABLED:-0}" != "1" ]]; then - export DXVK_HUD="${DXVK_HUD:-fps}" -fi +# DXVK HUD: kept opt-in. MoltenVK on macOS 26 can't compile DXVK's HUD shader. +export DXVK_HUD="${DXVK_HUD:-0}" if [[ -f "${RESOURCES_DIR}/MoltenVK_icd.json" ]]; then export VK_ICD_FILENAMES="${RESOURCES_DIR}/MoltenVK_icd.json" diff --git a/scripts/build/macos/bundle-macos-zh.sh b/scripts/build/macos/bundle-macos-zh.sh index 635425f29f8..d4333797189 100755 --- a/scripts/build/macos/bundle-macos-zh.sh +++ b/scripts/build/macos/bundle-macos-zh.sh @@ -393,11 +393,11 @@ fi # GeneralsX @bugfix fbraz3 20/03/2026 DXVK requires this env var on non-Win32; SDL3 matches game windowing layer export DXVK_WSI_DRIVER="SDL3" -# DXVK HUD: when SagePatch is bundled and active, default to "fps" so casual -# users see a frame counter without extra config. Set DXVK_HUD=0 to disable. -if [[ -f "${LIB_DIR}/libsage_patch.dylib" && "${SAGE_PATCH_DISABLED:-0}" != "1" ]]; then - export DXVK_HUD="${DXVK_HUD:-fps}" -fi +# DXVK HUD: kept opt-in. MoltenVK on macOS 26 cannot compile DXVK's HUD +# pipeline shader (gl_DrawID / SPIR-V DrawIndex has no MSL equivalent yet). +# Defaulting it on causes the swap chain blit pipeline to fail. Users wanting +# an FPS overlay set DXVK_HUD=fps themselves. +export DXVK_HUD="${DXVK_HUD:-0}" if [[ -f "${RESOURCES_DIR}/MoltenVK_icd.json" ]]; then export VK_ICD_FILENAMES="${RESOURCES_DIR}/MoltenVK_icd.json" diff --git a/scripts/build/macos/deploy-macos-zh.sh b/scripts/build/macos/deploy-macos-zh.sh index 3610e730401..21a7661138b 100755 --- a/scripts/build/macos/deploy-macos-zh.sh +++ b/scripts/build/macos/deploy-macos-zh.sh @@ -196,12 +196,11 @@ fi # GeneralsX @bugfix fbraz3 20/03/2026 DXVK requires DXVK_WSI_DRIVER on non-Win32; must match game windowing (SDL3) export DXVK_WSI_DRIVER="SDL3" -# DXVK HUD: SagePatch builds default to "fps" so casual users see frame rate -# without extra config. Set DXVK_HUD=0 explicitly to disable, or anything else -# (e.g. fps,memory,version) to customize. -if [[ -f "\${SCRIPT_DIR}/libsage_patch.dylib" && "\${SAGE_PATCH_DISABLED:-0}" != "1" ]]; then - export DXVK_HUD="\${DXVK_HUD:-fps}" -fi +# DXVK HUD: kept opt-in. MoltenVK on macOS 26 cannot compile DXVK's HUD +# pipeline shader (uses gl_DrawID / SPIR-V DrawIndex which has no MSL +# equivalent yet), so defaulting it on breaks the swap chain blit pipeline. +# Users wanting an FPS overlay set DXVK_HUD=fps themselves. +export DXVK_HUD="\${DXVK_HUD:-0}" # MoltenVK ICD manifest — deployed alongside the binary by deploy-macos-zh.sh if [[ -f "\${SCRIPT_DIR}/MoltenVK_icd.json" ]]; then From 22d9696e1037bacb105e2810cd8b0d7e95bfd037 Mon Sep 17 00:00:00 2001 From: Erasmo Bellumat Date: Sat, 25 Apr 2026 21:28:13 -0300 Subject: [PATCH 6/9] fix(deploy): cd to script dir in run.sh wrapper before exec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine resolves Local FS lookups (Data/INI/Default//*.ini overrides, loose Data/ assets, etc.) relative to the binary's cwd, never the binary's location. Without an explicit cd, launching the wrapper via absolute path, Finder, gtimeout, or any other invocation that does not happen to start in the asset dir caused the engine to miss every loose INI on disk — including SagePatch.ini — while still loading the BIG-archived defaults via the archive file system. Symptom: the game runs but the override apparently does nothing. Wrapper now cds to the script's own directory (which deploy puts the binary, the dylibs, the override INI, and the .big assets into) and execs the binary relative to that. Matches the bundle-script wrappers which already did this. --- scripts/build/linux/deploy-linux-zh.sh | 7 ++++++- scripts/build/macos/deploy-macos-zh.sh | 8 +++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/scripts/build/linux/deploy-linux-zh.sh b/scripts/build/linux/deploy-linux-zh.sh index 4bd1d238037..8ea78858341 100755 --- a/scripts/build/linux/deploy-linux-zh.sh +++ b/scripts/build/linux/deploy-linux-zh.sh @@ -207,8 +207,13 @@ if [[ -z "${ALSOFT_DRIVERS:-}" ]]; then echo "INFO: OpenAL: ALSOFT_DRIVERS=$ALSOFT_DRIVERS (pipewire excluded)" fi +# The engine resolves Local FS lookups (Data/INI/Default/... overrides, etc.) +# relative to the binary's cwd. Without this cd, anything launched via absolute +# path misses every loose INI / asset and only sees BIG-archived data. +cd "${SCRIPT_DIR}" + # Run game with all arguments -exec "${SCRIPT_DIR}/GeneralsXZH" "$@" +exec "./GeneralsXZH" "$@" EOF chmod +x "${RUNTIME_DIR}/run.sh" diff --git a/scripts/build/macos/deploy-macos-zh.sh b/scripts/build/macos/deploy-macos-zh.sh index 21a7661138b..f5448db3d37 100755 --- a/scripts/build/macos/deploy-macos-zh.sh +++ b/scripts/build/macos/deploy-macos-zh.sh @@ -220,7 +220,13 @@ if [[ -z "\${CNC_GENERALS_INSTALLPATH:-}" && -d "\${SCRIPT_DIR}/../Generals" ]]; export CNC_GENERALS_INSTALLPATH="\${SCRIPT_DIR}/../Generals/" fi -exec "\${SCRIPT_DIR}/GeneralsXZH" "\$@" +# The engine resolves Local FS lookups (e.g. INI overrides under +# Data/INI/Default/...) relative to the binary's cwd. Without this cd, anything +# launched via absolute path (Finder, gtimeout, full-path invocation) misses +# every loose INI / asset and only sees what is bundled inside the BIG files. +cd "\${SCRIPT_DIR}" + +exec "./GeneralsXZH" "\$@" WRAPPER chmod +x "${RUNTIME_DIR}/run.sh" From 535c8a12a3341a5452b71889960afc85fece4591 Mon Sep 17 00:00:00 2001 From: Erasmo Bellumat Date: Sun, 26 Apr 2026 14:57:48 -0300 Subject: [PATCH 7/9] docs(sagepatch): drop FPS-counter feature claim; engine already has one The engine ships a native FPS overlay at the top-left via W3DDisplay::drawFPSStats(), gated by #ifdef RTS_DEBUG plus the runtime -benchmark CLI flag. The previous SagePatch revision defaulted DXVK_HUD=fps in the run wrapper as a release-build alternative, but that default was already reverted in 55f06b77c because MoltenVK on macOS 26 cannot compile DXVK's HUD pipeline shader (DrawIndex has no MSL equivalent) and the resulting blit-pipeline failure hangs the game at the EA logo. Removing the FPS row from the feature table and replacing it with a small 'About FPS counters' section that explains the native option and why our DXVK_HUD shortcut is parked. --- docs/PATCHES/SAGEPATCH.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/PATCHES/SAGEPATCH.md b/docs/PATCHES/SAGEPATCH.md index 7224de1f7d4..1a780692405 100644 --- a/docs/PATCHES/SAGEPATCH.md +++ b/docs/PATCHES/SAGEPATCH.md @@ -36,11 +36,24 @@ the original GenTool available. | Camera zoom range | (passive) | `MaxCameraHeight=800`, `MinCameraHeight=60`, `EnforceMaxCameraHeight=No`. | | Camera pitch | (passive) | `CameraPitch=50` (vanilla ~63). | | Keyboard scroll speed | (passive) | `KeyboardScrollSpeedFactor=1.0` (vanilla 0.5). | -| FPS counter | (passive) | DXVK HUD pre-set to `fps` in the run wrapper when SagePatch is active. | Hot-key collisions: SagePatch eats the events it handles, so they do not also reach the game. +### About FPS counters + +The engine already ships a native FPS overlay at the top-left +(`W3DDisplay::drawFPSStats()`), gated by `#ifdef RTS_DEBUG` plus the runtime +`-benchmark ` CLI flag. SagePatch does not duplicate it. An earlier +revision of this patch defaulted `DXVK_HUD=fps` in the run wrapper as a +release-build alternative, but on macOS 26 the current MoltenVK SPIRV-Cross +back-end cannot translate DXVK's HUD pipeline shader (uses `DrawIndex`, no +MSL equivalent), causing the swap-chain blit pipeline to fail and the game +to hang at the EA logo. The default is now `DXVK_HUD=0` again. Users who +want a frame counter on macOS should either build with `RTS_DEBUG=ON` and +launch with `-benchmark 9999`, or set `DXVK_HUD=fps` themselves once +upstream MoltenVK ships the DrawIndex emulation patch. + ## How to enable ### macOS From 598d8fd57829f35ddefe3f44512547749e240863 Mon Sep 17 00:00:00 2001 From: Erasmo Bellumat Date: Sun, 26 Apr 2026 15:34:22 -0300 Subject: [PATCH 8/9] fix(sagepatch,deploy): drop Override.ini in path2 subdir so values stick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine subsystem init for TheWritableGlobalData scans two parent dirs: path1 = Data/INI/Default/GameData (loaded first) path2 = Data/INI/GameData (loaded second) Each parsed GameData block overwrites prior values in TheWritableGlobalData (INI_LOAD_OVERWRITE semantics). The vanilla camera defaults (MaxCameraHeight = 310, MinCameraHeight = 120, CameraPitch = 37.5) live in the BIG-archived Data/INI/GameData.ini and are parsed in the SECOND pass, so an override placed under Data/INI/Default/GameData/ — which is parsed in the FIRST pass — is silently undone right after. Verified via temporary instrumentation in parseGameDataDefinition: pass 1: file=Data/INI/Default/GameData.ini max=300/min=100 (debug-only block) pass 2: file=Data/INI/GameData.ini line=464 max=310/min=120 (vanilla, was last-write) pass 3: file=Data/INI/GameData/SagePatch.ini max=800/min=60 (now winning) Deploy scripts on both platforms now write to Data/INI/GameData/SagePatch.ini (path2 subdir, parsed last) instead of Data/INI/Default/GameData/SagePatch.ini, and clean up any prior misplaced copy. SAGEPATCH.md gains a short section explaining the load order so future contributors do not repeat the mistake. --- docs/PATCHES/SAGEPATCH.md | 17 ++++++++++++++++- scripts/build/linux/deploy-linux-zh.sh | 8 ++++++-- scripts/build/macos/deploy-macos-zh.sh | 11 +++++++++-- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/docs/PATCHES/SAGEPATCH.md b/docs/PATCHES/SAGEPATCH.md index 1a780692405..27d50757ecf 100644 --- a/docs/PATCHES/SAGEPATCH.md +++ b/docs/PATCHES/SAGEPATCH.md @@ -186,6 +186,21 @@ Patches/SagePatch/ Brightness_linux.cpp windows/ # Windows: stubs only (Phase 2) Stubs_windows.cpp - resources/Override.ini # engine-side INI override + resources/Override.ini # engine-side INI override (deployed to Data/INI/GameData/SagePatch.ini) docs/PATCHES/SAGEPATCH.md # this file ``` + +### A note on the INI override path + +The engine loads `GameData` INIs in two passes: +`Data/INI/Default/GameData/...` first, then `Data/INI/GameData/...`. Each +`GameData` block parsed overwrites the previous values in `TheWritableGlobalData` +(`INI_LOAD_OVERWRITE` semantics). The vanilla camera defaults +(`MaxCameraHeight = 310`, etc.) live in the BIG-archived `Data/INI/GameData.ini` +which is parsed in the **second** pass, so an override placed in +`Data/INI/Default/GameData/` is silently undone by the second pass. The deploy +scripts therefore drop our `SagePatch.ini` into `Data/INI/GameData/` so it is +loaded last and the values stick. + +```text +``` diff --git a/scripts/build/linux/deploy-linux-zh.sh b/scripts/build/linux/deploy-linux-zh.sh index 8ea78858341..3452204a25e 100755 --- a/scripts/build/linux/deploy-linux-zh.sh +++ b/scripts/build/linux/deploy-linux-zh.sh @@ -113,9 +113,13 @@ if [[ -f "${SAGE_PATCH_LIB}" ]]; then echo " Deploying SagePatch (libsage_patch.so)..." cp -v "${SAGE_PATCH_LIB}" "${RUNTIME_DIR}/" if [[ -f "${SAGE_PATCH_OVERRIDE}" ]]; then - mkdir -p "${RUNTIME_DIR}/Data/INI/Default/GameData" + # path2 (Data/INI/GameData) is loaded after path1 (Data/INI/Default/GameData) + # and the path2 BIG file is what carries the real camera/scroll defaults. + # Our override must live in path2's subdir to win the last-write race. + mkdir -p "${RUNTIME_DIR}/Data/INI/GameData" cp -v "${SAGE_PATCH_OVERRIDE}" \ - "${RUNTIME_DIR}/Data/INI/Default/GameData/SagePatch.ini" + "${RUNTIME_DIR}/Data/INI/GameData/SagePatch.ini" + rm -f "${RUNTIME_DIR}/Data/INI/Default/GameData/SagePatch.ini" fi fi diff --git a/scripts/build/macos/deploy-macos-zh.sh b/scripts/build/macos/deploy-macos-zh.sh index f5448db3d37..4047492a5d1 100755 --- a/scripts/build/macos/deploy-macos-zh.sh +++ b/scripts/build/macos/deploy-macos-zh.sh @@ -149,9 +149,16 @@ if [[ -f "${SAGE_PATCH_LIB}" ]]; then echo " Deploying SagePatch (libsage_patch.dylib)..." cp -v "${SAGE_PATCH_LIB}" "${RUNTIME_DIR}/" if [[ -f "${SAGE_PATCH_OVERRIDE}" ]]; then - mkdir -p "${RUNTIME_DIR}/Data/INI/Default/GameData" + # The engine loads GameData INIs in two passes: path1=Data/INI/Default/GameData + # then path2=Data/INI/GameData. Path2 is loaded LAST and contains the actual + # camera/scroll defaults (MaxCameraHeight, etc.) in BIG-archived GameData.ini. + # Our override must therefore go in path2's subdir to take effect, since each + # parse of a GameData block overwrites prior values in TheWritableGlobalData. + mkdir -p "${RUNTIME_DIR}/Data/INI/GameData" cp -v "${SAGE_PATCH_OVERRIDE}" \ - "${RUNTIME_DIR}/Data/INI/Default/GameData/SagePatch.ini" + "${RUNTIME_DIR}/Data/INI/GameData/SagePatch.ini" + # Clean up any prior misplaced copy from earlier deploy versions. + rm -f "${RUNTIME_DIR}/Data/INI/Default/GameData/SagePatch.ini" fi fi From 508839d62276812572d9cd35de58935d302587dc Mon Sep 17 00:00:00 2001 From: Erasmo Bellumat Date: Sun, 26 Apr 2026 16:51:37 -0300 Subject: [PATCH 9/9] tune(sagepatch): tone down camera defaults (500/80, drop pitch override) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MaxCameraHeight=800 was too aggressive — on small maps it pushed the orthographic frustum past the playable border, exposing void/cull at the screen edges. Drop to 500 (~1.6x vanilla 310) so the extra range is useful without breaking small skirmish maps. Also raise MinCameraHeight back to 80 (slightly closer than vanilla 120 but not as tight as the previous 60), and remove the CameraPitch override entirely — letting the engine keep its vanilla ~37.5 pitch instead of forcing 50, which was an arbitrary choice and not a published GenTool default. Scroll factor stays at 1.0 (2x vanilla). Reaffirms that no published GenTool tuning numbers are public; these are conservative casual-friendly bumps. --- Patches/SagePatch/resources/Override.ini | 36 +++++++++++------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/Patches/SagePatch/resources/Override.ini b/Patches/SagePatch/resources/Override.ini index 6983b796faa..6b1bc9b0517 100644 --- a/Patches/SagePatch/resources/Override.ini +++ b/Patches/SagePatch/resources/Override.ini @@ -1,30 +1,26 @@ ; ----------------------------------------------------------------------------- -; SagePatch — Casual QoL overrides for GeneralsX +; SagePatch - Casual QoL overrides for GeneralsX ; -; Extends camera bounds, pitch range, and scroll speed to values closer to -; what casual players want. Loaded by the engine after the default -; GameData.ini, so values here override (don't add to) the originals. +; Conservative camera/scroll bumps so casual players get a slightly wider +; field of view without breaking small maps (orthographic cull at the edge). ; -; Vanilla values (from GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp): -; MaxCameraHeight = 300, MinCameraHeight = 100 -; CameraPitch = ~63 -; KeyboardScrollSpeedFactor = 0.5 +; Loaded by the engine after the BIG-archived Data/INI/GameData.ini, so values +; here override (not append to) the originals. ; -; To disable the override: remove this file from the game directory or build -; with -DRTS_BUILD_OPTION_SAGE_PATCH=OFF. +; Vanilla values (from BIG-archived Data/INI/GameData.ini): +; MaxCameraHeight = 310 +; MinCameraHeight = 120 +; KeyboardScrollSpeedFactor = 0.5 +; CameraPitch = ~37.5 ; ----------------------------------------------------------------------------- GameData - ; Camera zoom range — much wider than vanilla so casual players can survey - ; entire bases or look up close at units. - MaxCameraHeight = 800.0 - MinCameraHeight = 60.0 + ; ~1.6x vanilla; further out without seeing past the map border on small maps. + MaxCameraHeight = 500.0 + ; Slightly closer than vanilla so casual zoom-in feels useful. + MinCameraHeight = 80.0 + ; Still soft-disabled so the user can push past max without a hard clamp. EnforceMaxCameraHeight = No - - ; Camera pitch — vanilla pins around 63. We let players go more top-down or - ; flatter without breaking terrain rendering. - CameraPitch = 50.0 - - ; Keyboard scroll speed — vanilla was sluggish. Doubled. + ; Keyboard scroll - vanilla 0.5 is sluggish, double it. KeyboardScrollSpeedFactor = 1.0 End