From a43ebcdd642437078e7fdefa8e2eeeed5e95180f Mon Sep 17 00:00:00 2001 From: Mark Kropf Date: Thu, 18 Jun 2026 19:58:36 +0100 Subject: [PATCH 01/25] SolLua: use sol::lua_nil instead of the sol::nil alias A handful of SolLua bindings still referenced sol::nil / sol::type::nil while the rest of the same files already use sol::lua_nil. sol::nil is a thin alias for lua_nil (see lib/sol2/sol.hpp) and collides with the `nil` macro that Objective-C headers define, so lua_nil is the portable spelling. This brings the remaining spots in line with the prevailing convention. Extracted from cc3cad907f in #2991; cross-platform, no behavior change. --- rts/Rml/SolLua/bind/Context.cpp | 6 +++--- rts/Rml/SolLua/bind/Element.cpp | 2 +- rts/Rml/SolLua/bind/bind.cpp | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/rts/Rml/SolLua/bind/Context.cpp b/rts/Rml/SolLua/bind/Context.cpp index db0dcb8960b..bd40fd4e535 100644 --- a/rts/Rml/SolLua/bind/Context.cpp +++ b/rts/Rml/SolLua/bind/Context.cpp @@ -131,7 +131,7 @@ struct lua_iterator_state sol::state_view l{s}; int index = 0; int count = keytable.size(); - while (keytable.get(++index).get_type() != sol::type::nil && index <= count) { + while (keytable.get(++index).get_type() != sol::type::lua_nil && index <= count) { this->keys.emplace_back(sol::object(l, sol::in_place, index)); } } else { @@ -199,7 +199,7 @@ createNewIndexFunction(std::shared_ptr data, const } if (value.is()) { auto value_raw = value.as().raw_get("__raw"); - if (value_raw != sol::nil && value_raw.is()) { + if (value_raw != sol::lua_nil && value_raw.is()) { // new value is a datamodel proxy, so get the underlying table to assign prop.as().raw_set(solkey, value_raw.as().call(value)); } else { @@ -290,7 +290,7 @@ sol::table openDataModel(Rml::Context& self, const Rml::String& name, sol::objec } if (value.is()) { auto value_raw = value.as().raw_get("__raw"); - if (value_raw != sol::nil && value_raw.is()) { + if (value_raw != sol::lua_nil && value_raw.is()) { // new value is a datamodel proxy, so get the underlying table to assign data->Table.raw_set(key, value_raw.as().call(value)); } else { diff --git a/rts/Rml/SolLua/bind/Element.cpp b/rts/Rml/SolLua/bind/Element.cpp index e2edf19159a..3bf3ecc7fea 100644 --- a/rts/Rml/SolLua/bind/Element.cpp +++ b/rts/Rml/SolLua/bind/Element.cpp @@ -158,7 +158,7 @@ namespace Rml::SolLua void Set(const sol::this_state L, const std::string& name, const sol::object& value) { - if (value.get_type() == sol::type::nil) { + if (value.get_type() == sol::type::lua_nil) { m_element->RemoveProperty(name); return; } diff --git a/rts/Rml/SolLua/bind/bind.cpp b/rts/Rml/SolLua/bind/bind.cpp index 21eabe9d46c..98ce9fec37c 100644 --- a/rts/Rml/SolLua/bind/bind.cpp +++ b/rts/Rml/SolLua/bind/bind.cpp @@ -39,7 +39,7 @@ namespace Rml::SolLua sol::object makeObjectFromVariant(const Rml::Variant* variant, sol::state_view s) { - if (!variant) return sol::make_object(s, sol::nil); + if (!variant) return sol::make_object(s, sol::lua_nil); switch (variant->GetType()) { @@ -69,10 +69,10 @@ namespace Rml::SolLua case Rml::Variant::VOIDPTR: return sol::make_object(s, variant->Get()); default: - return sol::make_object(s, sol::nil); + return sol::make_object(s, sol::lua_nil); } - return sol::make_object(s, sol::nil); + return sol::make_object(s, sol::lua_nil); } } // end namespace Rml::SolLua From 3e83e20c2805b09c1165442e4df428fcb935a7a5 Mon Sep 17 00:00:00 2001 From: Tom J Nowell Date: Thu, 18 Jun 2026 20:17:54 +0100 Subject: [PATCH 02/25] float3: drop redundant direct streflop_cond.h include float3.h included lib/streflop/streflop_cond.h directly, ahead of FastMath.h. FastMath.h defines MATH_SQRT_OVERRIDE before it includes streflop_cond.h (so streflop does not define its own math::sqrt(float) -- FastMath provides a faster one). The direct include meant streflop_cond.h could be processed before that override was set. Drop the direct include; FastMath.h pulls in streflop_cond.h transitively with the override in place. creg_cond.h keeps its original position -- it does not pull in streflop, so it does not need to move. Surfaced by the macOS port (#2991, commit cc3cad907f). Co-authored-by: Mark Kropf --- rts/System/float3.h | 1 - 1 file changed, 1 deletion(-) diff --git a/rts/System/float3.h b/rts/System/float3.h index f096c112c0d..bf47dd90c2a 100644 --- a/rts/System/float3.h +++ b/rts/System/float3.h @@ -9,7 +9,6 @@ #include #include "System/BranchPrediction.h" -#include "lib/streflop/streflop_cond.h" #include "System/creg/creg_cond.h" #include "System/FastMath.h" #include "System/type2.h" From 517608f4076b2071742badd6a90eab3dc6262828 Mon Sep 17 00:00:00 2001 From: Tom J Nowell Date: Fri, 19 Jun 2026 00:58:16 +0100 Subject: [PATCH 03/25] SafeUtil: include and directly (#3025) `SafeUtil.h` uses `` for `std::addressof`, and `` for `std::is_trivially_copyable` / `std::is_trivially_constructible_v`, but pulled them in only transitively. Include them directly so the header is self-contained and does not rely on include order elsewhere. Also use `std::is_trivially_default_constructible` for the default-construction. Extracted from cc3cad907f in #2991; cross-platform, no behavior change. Co-authored-by: Mark Kropf Co-authored-by: sprunk --- rts/System/SafeUtil.h | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rts/System/SafeUtil.h b/rts/System/SafeUtil.h index ce19b3f9738..9fbf946cc82 100644 --- a/rts/System/SafeUtil.h +++ b/rts/System/SafeUtil.h @@ -5,6 +5,8 @@ #include #include +#include +#include namespace spring { template inline void SafeDestruct(T*& p) @@ -112,8 +114,8 @@ namespace spring { static_assert(sizeof(TIn) == sizeof(TOut), "Types must match sizes"); static_assert(std::is_trivially_copyable::value , "Requires TriviallyCopyable input"); static_assert(std::is_trivially_copyable::value, "Requires TriviallyCopyable output"); - static_assert(std::is_trivially_constructible_v, - "This implementation additionally requires destination type to be trivially constructible"); + static_assert(std::is_trivially_default_constructible::value, + "This implementation additionally requires destination type to be trivially default-constructible"); TOut t2; std::memcpy(std::addressof(t2), std::addressof(t1), sizeof(TIn)); From 6318fb6c445daddeb1c9f4dea27e69bfb13847e1 Mon Sep 17 00:00:00 2001 From: iamaperson000 Date: Thu, 18 Jun 2026 19:49:28 +0100 Subject: [PATCH 04/25] LuaTextures: log glTexImage failures instead of returning nil silently LuaTextures::Create returned an empty string on glTexImage failure with no diagnostics. Log target/size/format/dataFormat/dataType/glError so texture-creation failures can be diagnosed. Extracted from 1e75080731 in #2991; cross-platform, no behavior change beyond the added log. --- rts/Lua/LuaTextures.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rts/Lua/LuaTextures.cpp b/rts/Lua/LuaTextures.cpp index 8d3a083dbef..0ae691d63c0 100644 --- a/rts/Lua/LuaTextures.cpp +++ b/rts/Lua/LuaTextures.cpp @@ -90,7 +90,9 @@ std::string LuaTextures::Create(const Texture& tex) } break; } - if (glGetError() != GL_NO_ERROR) { + if (const GLenum texErr = glGetError(); texErr != GL_NO_ERROR) { + LOG_L(L_ERROR, "[LuaTextures::%s] glTexImage failed: target=0x%x size=%dx%d fmt=0x%x dataFmt=0x%x dataType=0x%x border=%d glError=0x%x", + __func__, tex.target, tex.xsize, tex.ysize, tex.format, dataFormat, dataType, tex.border, texErr); glDeleteTextures(1, &texID); glBindTexture(tex.target, currentBinding); return ""; From 54348ff04d3fa6d1583224d3568cd2cf5825a2ab Mon Sep 17 00:00:00 2001 From: bruno-dasilva <8520801+bruno-dasilva@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:11:02 -0700 Subject: [PATCH 05/25] fix: guard against already-dead reclaim targets (#3020) Engine crash thread: https://discordapp.com/channels/549281623154229250/1516994684591931535 Bugged Scenario: A reclaimer A is guarding another reclaimer B, while both reclaiming the same wreck. A can be notified of wreck death BEFORE the guarded unit B is notified, leading to trying to reclaim the same (actively-deleting) wreck because B's reference hasn't been cleaned up yet. This leads to an eventual segfault. So: the short term/easy fix is to skip reclaiming of a dead/dying target. --- rts/Sim/Units/CommandAI/BuilderCAI.cpp | 4 ++-- rts/Sim/Units/UnitTypes/Builder.cpp | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/rts/Sim/Units/CommandAI/BuilderCAI.cpp b/rts/Sim/Units/CommandAI/BuilderCAI.cpp index b747e342615..e1faddb7dd6 100644 --- a/rts/Sim/Units/CommandAI/BuilderCAI.cpp +++ b/rts/Sim/Units/CommandAI/BuilderCAI.cpp @@ -891,13 +891,13 @@ void CBuilderCAI::ExecuteGuard(Command& c) StopSlowGuard(); } return; - } else if (b->curReclaim && owner->unitDef->canReclaim) { + } else if (b->curReclaim && !b->curReclaim->detached && owner->unitDef->canReclaim) { StopSlowGuard(); if (!ReclaimObject(b->curReclaim)) { StopMove(); } return; - } else if (b->curResurrect && owner->unitDef->canResurrect) { + } else if (b->curResurrect && !b->curResurrect->detached && owner->unitDef->canResurrect) { StopSlowGuard(); if (!ResurrectObject(b->curResurrect)) { StopMove(); diff --git a/rts/Sim/Units/UnitTypes/Builder.cpp b/rts/Sim/Units/UnitTypes/Builder.cpp index 4cf3240b1cd..deb8c846b73 100644 --- a/rts/Sim/Units/UnitTypes/Builder.cpp +++ b/rts/Sim/Units/UnitTypes/Builder.cpp @@ -605,6 +605,15 @@ void CBuilder::SetRepairTarget(CUnit* target) void CBuilder::SetReclaimTarget(CSolidObject* target) { RECOIL_DETAILED_TRACY_ZONE; + // A target already being destroyed (detached) cannot have a death dependence + // registered (AddDeathDependence no-ops on detached objects), which would leave + // curReclaim dangling. This happens when ~CObject's DependentDied cascade re-enters + // reclaim logic mid-deletion (e.g. CBuilderCAI::ExecuteGuard reading a guardee's + // stale curReclaim) on the very feature being freed. Refuse the dead target. + assert(target != nullptr); + if (target->detached) + return; + if (dynamic_cast(target) != nullptr && !static_cast(target)->def->reclaimable) return; @@ -630,6 +639,11 @@ void CBuilder::SetReclaimTarget(CSolidObject* target) void CBuilder::SetResurrectTarget(CFeature* target) { RECOIL_DETAILED_TRACY_ZONE; + // see SetReclaimTarget: never depend on an object that is already being destroyed + assert(target != nullptr); + if (target->detached) + return; + if (curResurrect == target || target->udef == nullptr) return; @@ -646,6 +660,11 @@ void CBuilder::SetResurrectTarget(CFeature* target) void CBuilder::SetCaptureTarget(CUnit* target) { RECOIL_DETAILED_TRACY_ZONE; + // see SetReclaimTarget: never depend on an object that is already being destroyed + assert(target != nullptr); + if (target->detached) + return; + if (target == curCapture) return; From e91a58f5c57dcdd25df3e51aac911ce3b481179f Mon Sep 17 00:00:00 2001 From: Tom J Nowell Date: Sat, 20 Jun 2026 14:14:07 +0100 Subject: [PATCH 06/25] CUtils: use const dirent* for the scandir selector on all platforms util_fileSelector was declared with a non-const struct dirent* on __APPLE__ and const elsewhere. macOS scandir() expects the selector argument as int(*)(const struct dirent*), so the non-const Apple variant failed to compile under GCC: Util.c:500: error: passing argument 3 of 'scandir' from incompatible pointer type Both branches were otherwise identical, so drop the __APPLE__ split and use const struct dirent* unconditionally (matches POSIX scandir). No effect on Linux/Windows. Assisted by Claude Code; verified by compiling the macOS headless build. --- AI/Wrappers/CUtils/Util.c | 4 ---- 1 file changed, 4 deletions(-) diff --git a/AI/Wrappers/CUtils/Util.c b/AI/Wrappers/CUtils/Util.c index c33d2e418e8..4764a619900 100644 --- a/AI/Wrappers/CUtils/Util.c +++ b/AI/Wrappers/CUtils/Util.c @@ -487,11 +487,7 @@ static void util_initFileSelector(const char* suffix) { fileSelectorSuffix = suffix; } -#if defined(__APPLE__) -static int util_fileSelector(struct dirent* fileDesc) { -#else static int util_fileSelector(const struct dirent* fileDesc) { -#endif return util_endsWith(fileDesc->d_name, fileSelectorSuffix); } From 580100a0dfeb3fb2f6203ad0cf7bf077f212b6d4 Mon Sep 17 00:00:00 2001 From: Tom J Nowell Date: Sat, 20 Jun 2026 14:12:15 +0100 Subject: [PATCH 07/25] legacy: only require X11 on non-Apple UNIX The legacy build did find_package(X11 REQUIRED) under a plain if(UNIX) guard. macOS is UNIX in CMake but does not use X11 (it uses Cocoa), so configuring the engine on macOS failed at find_package(X11). An if(APPLE) block already follows for Foundation, so exclude Apple from the X11 branch: if(UNIX AND NOT APPLE). Surfaced configuring the spring-headless target on macOS (which reuses the legacy Game target). No effect on Linux/Windows. Assisted by Claude Code; verified by configuring the macOS build. --- rts/builds/legacy/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rts/builds/legacy/CMakeLists.txt b/rts/builds/legacy/CMakeLists.txt index cbcc906f83d..f1d19ede7e3 100644 --- a/rts/builds/legacy/CMakeLists.txt +++ b/rts/builds/legacy/CMakeLists.txt @@ -49,7 +49,7 @@ find_freetype_hack() # hack to find different named freetype.dll find_package_static(Freetype 2.8.1 REQUIRED) list(APPEND engineLibraries Freetype::Freetype) -if (UNIX) +if (UNIX AND NOT APPLE) find_package(X11 REQUIRED) target_link_libraries(Game PRIVATE X11::Xcursor) list(APPEND engineLibraries ${X11_Xcursor_LIB} ${X11_X11_LIB}) From d13dcd341361637cdf1ab4e65b0e8d6fa7b5ca21 Mon Sep 17 00:00:00 2001 From: Tom J Nowell Date: Sat, 20 Jun 2026 13:10:24 +0100 Subject: [PATCH 08/25] FindSevenZip: also accept the 7zz binary name Modern 7-Zip ships its CLI as '7zz' (Homebrew's 'sevenzip' formula installs /opt/homebrew/bin/7zz; recent Linux distros likewise package '7zz'). FindSevenZip only searched for '7z'/'7za', so configure failed with 'Could NOT find SevenZip (missing: SEVENZIP_BIN)' on such systems. Add '7zz' to the searched NAMES. No effect where 7z/7za already exist. --- rts/build/cmake/FindSevenZip.cmake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rts/build/cmake/FindSevenZip.cmake b/rts/build/cmake/FindSevenZip.cmake index 1077ec7b17e..0e3e4002a67 100644 --- a/rts/build/cmake/FindSevenZip.cmake +++ b/rts/build/cmake/FindSevenZip.cmake @@ -23,7 +23,7 @@ ENDIF (SEVENZIP_BIN) set(progfilesx86 "ProgramFiles(x86)") find_program(SEVENZIP_BIN - NAMES 7z 7za + NAMES 7z 7za 7zz HINTS "${MINGWDIR}" "${MINGWLIBS}/bin" "$ENV{${progfilesx86}}/7-zip" "$ENV{ProgramFiles}/7-zip" "$ENV{ProgramW6432}/7-zip" PATH_SUFFIXES bin DOC "7zip executable" From 0da81b433a8c4e228a4ac02ac2e943bd0c5aa078 Mon Sep 17 00:00:00 2001 From: Tom J Nowell Date: Fri, 19 Jun 2026 23:55:16 +0100 Subject: [PATCH 09/25] macOS: remove dead SDL 1.2-era SDLMain.{m,h} rts/System/Platform/Mac/SDLMain.m and SDLMain.h are the classic SDL 1.2 Cocoa main wrapper (the Darrell Walisser / Max Horn template). They are: - not listed in any CMakeLists (never compiled) - not #included by any source file - built on Carbon (), which is 32-bit-only and unavailable on modern macOS / Apple Silicon SDL2 provides its own SDL_main, so this wrapper is obsolete. Remove the dead files so the macOS platform layer reflects what is actually built. --- rts/System/Platform/Mac/SDLMain.h | 18 -- rts/System/Platform/Mac/SDLMain.m | 299 ------------------------------ 2 files changed, 317 deletions(-) delete mode 100644 rts/System/Platform/Mac/SDLMain.h delete mode 100644 rts/System/Platform/Mac/SDLMain.m diff --git a/rts/System/Platform/Mac/SDLMain.h b/rts/System/Platform/Mac/SDLMain.h deleted file mode 100644 index 995d036c3f0..00000000000 --- a/rts/System/Platform/Mac/SDLMain.h +++ /dev/null @@ -1,18 +0,0 @@ -/* This file is part of the Spring engine (GPL v2 or later), see LICENSE.html */ - -/* - * SDLMain.m - main entry point for our Cocoa-ized SDL app - * Initial Version: Darrell Walisser - * Non-NIB-Code & other changes: Max Horn - * Feel free to customize this file to suit your needs. - */ - -#ifdef __APPLE__ - -#import - -@interface SDLMain : NSObject -@end - -#endif - diff --git a/rts/System/Platform/Mac/SDLMain.m b/rts/System/Platform/Mac/SDLMain.m deleted file mode 100644 index 2fea69074ad..00000000000 --- a/rts/System/Platform/Mac/SDLMain.m +++ /dev/null @@ -1,299 +0,0 @@ -/* SDLMain.m - main entry point for our Cocoa-ized SDL app - Initial Version: Darrell Walisser - Non-NIB-Code & other changes: Max Horn - - Feel free to customize this file to suit your needs -*/ - -#import "SDL.h" -#import "SDLMain.h" -#import /* for MAXPATHLEN */ -#import -#import - -/* Use this flag to determine whether we use SDLMain.nib or not */ -#define SDL_USE_NIB_FILE 0 - - -static int gArgc; -static char **gArgv; -static BOOL gFinderLaunch; - -//extern NSAutoreleasePool *pool; -//void PreInitMac(); - -void MacMessageBox(const char *msg, const char *caption, unsigned int flags){ - NSAlert *alert = [[[NSAlert alloc] init] autorelease]; - [alert addButtonWithTitle:@"OK"]; - [alert setMessageText:[NSString stringWithCString:caption]]; - [alert setInformativeText:[NSString stringWithCString:msg]]; - [alert setAlertStyle:NSWarningAlertStyle]; - [alert runModal]; -} - -#if SDL_USE_NIB_FILE -/* A helper category for NSString */ -@interface NSString (ReplaceSubString) -- (NSString *)stringByReplacingRange:(NSRange)aRange with:(NSString *)aString; -@end -#else -/* An internal Apple class used to setup Apple menus */ -@interface NSAppleMenuController:NSObject {} -- (void)controlMenu:(NSMenu *)aMenu; -@end -#endif - -@interface SDLApplication : NSApplication -@end - -@implementation SDLApplication -/* Invoked from the Quit menu item */ -- (void)terminate:(id)sender -{ - /* Post a SDL_QUIT event */ - SDL_Event event; - event.type = SDL_QUIT; - SDL_PushEvent(&event); -} -@end - - -/* The main class of the application, the application's delegate */ -@implementation SDLMain - -/* Set the working directory to the .app's parent directory */ -- (void) setupWorkingDirectory:(BOOL)shouldChdir -{ - char parentdir[MAXPATHLEN]; - char *c; - - strncpy ( parentdir, gArgv[0], sizeof(parentdir) ); - c = (char*) parentdir; - - while (*c != '\0') /* go to end */ - c++; - - while (*c != '/') /* back up to parent */ - c--; - - *c++ = '\0'; /* cut off last part (binary name) */ - - if (shouldChdir) - { - assert ( chdir (parentdir) == 0 ); /* chdir to the binary app's parent */ - assert ( chdir ("../../../") == 0 ); /* chdir to the .app's parent */ - } -} - -#if SDL_USE_NIB_FILE - -/* Fix menu to contain the real app name instead of "SDL App" */ -- (void)fixMenu:(NSMenu *)aMenu withAppName:(NSString *)appName -{ - NSRange aRange; - NSEnumerator *enumerator; - NSMenuItem *menuItem; - - aRange = [[aMenu title] rangeOfString:@"SDL App"]; - if (aRange.length != 0) - [aMenu setTitle: [[aMenu title] stringByReplacingRange:aRange with:appName]]; - - enumerator = [[aMenu itemArray] objectEnumerator]; - while ((menuItem = [enumerator nextObject])) - { - aRange = [[menuItem title] rangeOfString:@"SDL App"]; - if (aRange.length != 0) - [menuItem setTitle: [[menuItem title] stringByReplacingRange:aRange with:appName]]; - if ([menuItem hasSubmenu]) - [self fixMenu:[menuItem submenu] withAppName:appName]; - } - [ aMenu sizeToFit ]; -} - -#else - -void setupAppleMenu(void) -{ - /* warning: this code is very odd */ - NSAppleMenuController *appleMenuController; - NSMenu *appleMenu; - NSMenuItem *appleMenuItem; - - appleMenuController = [[NSAppleMenuController alloc] init]; - appleMenu = [[NSMenu alloc] initWithTitle:@""]; - appleMenuItem = [[NSMenuItem alloc] initWithTitle:@"" action:nil keyEquivalent:@""]; - - [appleMenuItem setSubmenu:appleMenu]; - - /* yes, we do need to add it and then remove it -- - if you don't add it, it doesn't get displayed - if you don't remove it, you have an extra, titleless item in the menubar - when you remove it, it appears to stick around - very, very odd */ - [[NSApp mainMenu] addItem:appleMenuItem]; - [appleMenuController controlMenu:appleMenu]; - [[NSApp mainMenu] removeItem:appleMenuItem]; - [appleMenu release]; - [appleMenuItem release]; -} - -/* Create a window menu */ -void setupWindowMenu(void) -{ - NSMenu *windowMenu; - NSMenuItem *windowMenuItem; - NSMenuItem *menuItem; - - - windowMenu = [[NSMenu alloc] initWithTitle:@"Window"]; - - /* "Minimize" item */ - menuItem = [[NSMenuItem alloc] initWithTitle:@"Minimize" action:@selector(performMiniaturize:) keyEquivalent:@"m"]; - [windowMenu addItem:menuItem]; - [menuItem release]; - - /* Put menu into the menubar */ - windowMenuItem = [[NSMenuItem alloc] initWithTitle:@"Window" action:nil keyEquivalent:@""]; - [windowMenuItem setSubmenu:windowMenu]; - [[NSApp mainMenu] addItem:windowMenuItem]; - - /* Tell the application object that this is now the window menu */ - [NSApp setWindowsMenu:windowMenu]; - - /* Finally give up our references to the objects */ - [windowMenu release]; - [windowMenuItem release]; -} - -/* Replacement for NSApplicationMain */ -void CustomApplicationMain () -{ - NSAutoreleasePool* pool = [[NSAutoreleasePool alloc] init]; - SDLMain *sdlMain; - //PreInitMac(); - - /* Ensure the application object is initialised */ - [SDLApplication sharedApplication]; - - /* Set up the menubar */ - [NSApp setMainMenu:[[NSMenu alloc] init]]; - setupAppleMenu(); - setupWindowMenu(); - - /* Create SDLMain and make it the app delegate */ - sdlMain = [[SDLMain alloc] init]; - [NSApp setDelegate:sdlMain]; - - /* Bring the app to foreground */ - ProcessSerialNumber psn; - GetCurrentProcess(&psn); - TransformProcessType(&psn,kProcessTransformToForegroundApplication); - - /* Start the main event loop */ - [NSApp run]; - - [sdlMain release]; - [pool release]; -} - -#endif - -/* Called when the internal event loop has just started running */ -- (void) applicationDidFinishLaunching: (NSNotification *) note -{ - int status; - - /* Set the working directory to the .app's parent directory */ - [self setupWorkingDirectory:gFinderLaunch]; - -#if SDL_USE_NIB_FILE - /* Set the main menu to contain the real app name instead of "SDL App" */ - [self fixMenu:[NSApp mainMenu] withAppName:[[NSProcessInfo processInfo] processName]]; -#endif - - /* Hand off to main application code */ - status = SDL_main (gArgc, gArgv); - - /* We're done, thank you for playing */ - exit(status); -} -@end - - -@implementation NSString (ReplaceSubString) - -- (NSString *)stringByReplacingRange:(NSRange)aRange with:(NSString *)aString -{ - unsigned int bufferSize; - unsigned int selfLen = [self length]; - unsigned int aStringLen = [aString length]; - unichar *buffer; - NSRange localRange; - NSString *result; - - bufferSize = selfLen + aStringLen - aRange.length; - buffer = NSAllocateMemoryPages(bufferSize*sizeof(unichar)); - - /* Get first part into buffer */ - localRange.location = 0; - localRange.length = aRange.location; - [self getCharacters:buffer range:localRange]; - - /* Get middle part into buffer */ - localRange.location = 0; - localRange.length = aStringLen; - [aString getCharacters:(buffer+aRange.location) range:localRange]; - - /* Get last part into buffer */ - localRange.location = aRange.location + aRange.length; - localRange.length = selfLen - localRange.location; - [self getCharacters:(buffer+aRange.location+aStringLen) range:localRange]; - - /* Build output string */ - result = [NSString stringWithCharacters:buffer length:bufferSize]; - - NSDeallocateMemoryPages(buffer, bufferSize); - - return result; -} - -@end - - - -#ifdef main -# undef main -#endif - - -/* Main entry point to executable - should *not* be SDL_main! */ -int main (int argc, char **argv) -{ - - /* Copy the arguments into a global variable */ - int i; - - /* This is passed if we are launched by double-clicking */ - if ( argc >= 2 && strncmp (argv[1], "-psn", 4) == 0 ) { - gArgc = 1; - gFinderLaunch = YES; - } else { - gArgc = argc; - gFinderLaunch = NO; - } - gArgv = (char**) malloc (sizeof(*gArgv) * (gArgc+1)); - assert (gArgv != NULL); - for (i = 0; i < gArgc; i++) - gArgv[i] = argv[i]; - gArgv[i] = NULL; - -#if SDL_USE_NIB_FILE - [SDLApplication poseAsClass:[NSApplication class]]; - NSApplicationMain (); -#else - CustomApplicationMain (); -#endif - return 0; -} - - From 83642b066f755ac182ef97ea540569c07e59d2ff Mon Sep 17 00:00:00 2001 From: Scary le Poo Date: Sun, 21 Jun 2026 13:12:50 -0700 Subject: [PATCH 10/25] Fix SplinterFaction card link format Updated the link for the SplinterFaction card to include the 'https://' prefix. This has been broken for a very long time. I've asked Skyrbunny to fix it multiple times, but it has never gotten fixed. --- doc/site/content/_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/site/content/_index.md b/doc/site/content/_index.md index fcbbe83428f..0d0a8d3b14b 100644 --- a/doc/site/content/_index.md +++ b/doc/site/content/_index.md @@ -19,7 +19,7 @@ draft = false {{< /cards >}} {{< cards >}} {{< card title="Tech Annihilation" image="/showcase/ta.jpeg" link="https://github.com/techannihilation/TA" >}} -{{< card title="SplinterFaction" image="showcase/splinter_faction.jpg" link="splinterfaction.info" >}} +{{< card title="SplinterFaction" image="showcase/splinter_faction.jpg" link="https://splinterfaction.info" >}} {{< card title="Mechcommander: Legacy" image="/showcase/mcl.jpg" link="https://github.com/SpringMCLegacy/SpringMCLegacy/wiki" >}} {{< /cards >}} From 13b6804534543c061d0eb8c06af438cb7d2a1c64 Mon Sep 17 00:00:00 2001 From: bruno-dasilva <8520801+bruno-dasilva@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:38:03 -0700 Subject: [PATCH 11/25] fix: update section min-level in place instead of appending duplicates (#3052) setMinLevel() only searched for an existing entry on the "set back to default" (erase) path. Setting a section to a *non-default* level appended a new row unconditionally, so repeatedly changing one section's level (e.g. via Spring.SetLogSectionFilterLevel) accumulated duplicate entries and eventually filled the fixed 64-slot sectionMinLevels table -- after which every section-level change silently failed with "too many section-levels". Fix: Look the section up before appending and, if it already has an entry, update it in place. This bounds the table at one entry per section. This likely never happens in practice but this was found while writing other tests Co-authored-by: Bruno Da Silva Co-authored-by: Claude Opus 4.8 (1M context) --- rts/System/Log/DefaultFilter.cpp | 21 +++++++++++++------ test/engine/System/Log/TestILog.cpp | 31 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/rts/System/Log/DefaultFilter.cpp b/rts/System/Log/DefaultFilter.cpp index cf0c3939d29..3ec33410cf1 100644 --- a/rts/System/Log/DefaultFilter.cpp +++ b/rts/System/Log/DefaultFilter.cpp @@ -157,13 +157,15 @@ void log_filter_section_setMinLevel(int level, const char* section) // (same string but will not become garbage) section = *registeredSection; - if (level == log_filter_section_getDefaultMinLevel(section)) { - using P = decltype(log_filter::sectionMinLevels)::value_type; - - const auto sectionComparer = [](const P& a, const P& b) { return (log_filter_section_compare()(a.first, b.first)); }; - const auto sectionMinLevel = std::lower_bound(secLvls.begin(), secLvls.begin() + log_filter::numLevels, P{section, 0}, sectionComparer); + // locate any existing override for this section (the array is kept sorted) + using P = decltype(log_filter::sectionMinLevels)::value_type; + const auto sectionComparer = [](const P& a, const P& b) { return (log_filter_section_compare()(a.first, b.first)); }; + const auto sectionMinLevel = std::lower_bound(secLvls.begin(), secLvls.begin() + log_filter::numLevels, P{section, 0}, sectionComparer); + const bool exists = (sectionMinLevel != (secLvls.begin() + log_filter::numLevels) && strcmp(sectionMinLevel->first, section) == 0); - if (sectionMinLevel == (secLvls.begin() + log_filter::numLevels) || strcmp(sectionMinLevel->first, section) != 0) + if (level == log_filter_section_getDefaultMinLevel(section)) { + // back to default: drop any existing override (nothing to do otherwise) + if (!exists) return; // erase @@ -175,6 +177,13 @@ void log_filter_section_setMinLevel(int level, const char* section) return; } + // non-default: update any existing override in-place + if (exists) { + sectionMinLevel->second = level; + return; + } + + // add a net new override secLvls[log_filter::numLevels++] = {section, level}; // swap into position diff --git a/test/engine/System/Log/TestILog.cpp b/test/engine/System/Log/TestILog.cpp index cb8d4b3e4a1..7b7d1345f0c 100644 --- a/test/engine/System/Log/TestILog.cpp +++ b/test/engine/System/Log/TestILog.cpp @@ -3,6 +3,7 @@ #include "System/Log/FileSink.h" #include "System/Log/StreamSink.h" #include "System/Log/LogUtil.h" +#include "System/Log/DefaultFilter.h" #include @@ -231,3 +232,33 @@ TEST_CASE("IsEnabled") TLOG_SL( "other-one-time-section", L_DEBUG, "Testing LOG_IS_ENABLED_S"); } + +// Regression for the duplicate-entry leak in log_filter_section_setMinLevel. +// Setting a section to a non-default level used to *append* a new row every call +// instead of updating the existing one, so repeated changes to one section filled +// the fixed-size sectionMinLevels table and then made *all* section-level changes +// silently fail ("too many section-levels"). +TEST_CASE("SectionMinLevelNoDuplicateLeak") +{ + // non-default levels for these (non-default) sections; restored at the end + const int savedDefined = log_filter_section_getMinLevel(LOG_SECTION_DEFINED); + const int savedOneTime = log_filter_section_getMinLevel(LOG_SECTION_ONE_TIME_0); + + // hammer one section far more than the table could ever hold + for (int i = 0; i < 300; ++i) + log_filter_section_setMinLevel((i & 1) ? LOG_LEVEL_WARNING : LOG_LEVEL_ERROR, LOG_SECTION_DEFINED); + + // the most recent value wins (a single, updated-in-place entry) + log_filter_section_setMinLevel(LOG_LEVEL_ERROR, LOG_SECTION_DEFINED); + CHECK(log_filter_section_getMinLevel(LOG_SECTION_DEFINED) == LOG_LEVEL_ERROR); + + // and a *different* section must still be settable: with the old append bug + // the table is saturated by now and this set would be dropped + log_filter_section_setMinLevel(LOG_LEVEL_WARNING, LOG_SECTION_ONE_TIME_0); + CHECK(log_filter_section_getMinLevel(LOG_SECTION_ONE_TIME_0) == LOG_LEVEL_WARNING); + + // restore original levels (setting back to default takes the erase path) + log_filter_section_setMinLevel(savedDefined, LOG_SECTION_DEFINED); + log_filter_section_setMinLevel(savedOneTime, LOG_SECTION_ONE_TIME_0); +} + From 1caa3c52612c9741238f7a086e6d8696c7e77289 Mon Sep 17 00:00:00 2001 From: devgit283 Date: Tue, 23 Jun 2026 09:29:18 +0200 Subject: [PATCH 12/25] Convert `u8string_view` to `string` using explicit size The original code relied on implicit null-termination which is not guaranteed. --- rts/System/FileSystem/FileSystem.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rts/System/FileSystem/FileSystem.cpp b/rts/System/FileSystem/FileSystem.cpp index d1471810f48..f319d2199ca 100644 --- a/rts/System/FileSystem/FileSystem.cpp +++ b/rts/System/FileSystem/FileSystem.cpp @@ -61,7 +61,7 @@ namespace Impl { return std::string(reinterpret_cast(utf8.c_str())); } RECOIL_FORCE_INLINE std::string StoreUTF8AsString(const std::u8string_view& utf8) { - return std::string(reinterpret_cast(utf8.data())); + return std::string(reinterpret_cast(utf8.data()), utf8.size()); } RECOIL_FORCE_INLINE std::string StorePathAsString(const fs::path& path) { return StoreUTF8AsString(path.u8string()); From 2186ee490c027eb2993ce2f67b98d4978ded142e Mon Sep 17 00:00:00 2001 From: devgit283 Date: Fri, 5 Jun 2026 21:57:29 +0200 Subject: [PATCH 13/25] Fix inconsistent class/struct forward declarations Aligns forward declarations with definitions to silence MSVC warnings. --- rts/Rendering/IconHandler.h | 2 +- rts/Sim/Misc/SmoothHeightMesh.h | 2 +- rts/Sim/MoveTypes/Components/MoveTypesComponents.h | 4 ++-- rts/Sim/MoveTypes/MoveDefHandler.h | 2 +- rts/Sim/MoveTypes/Utils/UnitTrapCheckUtils.h | 4 ++-- rts/Sim/Path/HAPFS/PathSearch.h | 2 +- rts/Sim/Path/QTPFS/Components/PathSpeedModInfo.h | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/rts/Rendering/IconHandler.h b/rts/Rendering/IconHandler.h index 6c67749d739..ec0e5bce6e3 100644 --- a/rts/Rendering/IconHandler.h +++ b/rts/Rendering/IconHandler.h @@ -13,7 +13,7 @@ #include "Rendering/GL/RenderBuffersFwd.h" #include "Rendering/Textures/TextureAtlas.h" -class UnitDef; +struct UnitDef; class CTextureRenderAtlas; namespace icon { diff --git a/rts/Sim/Misc/SmoothHeightMesh.h b/rts/Sim/Misc/SmoothHeightMesh.h index 888686af138..91866ddfb6c 100644 --- a/rts/Sim/Misc/SmoothHeightMesh.h +++ b/rts/Sim/Misc/SmoothHeightMesh.h @@ -22,7 +22,7 @@ namespace SmoothHeightMeshNamespace { */ class SmoothHeightMesh { - friend class SmoothHeightMeshDrawer; + friend struct SmoothHeightMeshDrawer; public: diff --git a/rts/Sim/MoveTypes/Components/MoveTypesComponents.h b/rts/Sim/MoveTypes/Components/MoveTypesComponents.h index b0d1d8b4265..fa7380707bb 100644 --- a/rts/Sim/MoveTypes/Components/MoveTypesComponents.h +++ b/rts/Sim/MoveTypes/Components/MoveTypesComponents.h @@ -7,8 +7,8 @@ #include "System/Ecs/Components/BaseComponents.h" #include -struct CUnit; -struct CFeature; +class CUnit; +class CFeature; namespace MoveTypes { diff --git a/rts/Sim/MoveTypes/MoveDefHandler.h b/rts/Sim/MoveTypes/MoveDefHandler.h index fbec124d13c..9c8d1db5601 100644 --- a/rts/Sim/MoveTypes/MoveDefHandler.h +++ b/rts/Sim/MoveTypes/MoveDefHandler.h @@ -18,7 +18,7 @@ class CUnit; class LuaTable; namespace MoveTypes { - class CheckCollisionQuery; + struct CheckCollisionQuery; } namespace MoveDefs { diff --git a/rts/Sim/MoveTypes/Utils/UnitTrapCheckUtils.h b/rts/Sim/MoveTypes/Utils/UnitTrapCheckUtils.h index 7db24ab3835..da7354b01fb 100644 --- a/rts/Sim/MoveTypes/Utils/UnitTrapCheckUtils.h +++ b/rts/Sim/MoveTypes/Utils/UnitTrapCheckUtils.h @@ -3,8 +3,8 @@ #ifndef UNIT_TRAP_CHECK_UTILS_H__ #define UNIT_TRAP_CHECK_UTILS_H__ -struct CFeature; -struct CUnit; +class CFeature; +class CUnit; namespace MoveTypes { void RegisterFeatureForUnitTrapCheck(CFeature* object); diff --git a/rts/Sim/Path/HAPFS/PathSearch.h b/rts/Sim/Path/HAPFS/PathSearch.h index 1fae37cc5dd..fdb3bfc7d85 100644 --- a/rts/Sim/Path/HAPFS/PathSearch.h +++ b/rts/Sim/Path/HAPFS/PathSearch.h @@ -6,7 +6,7 @@ #include "System/float3.h" class CSolidObject; -class MoveDef; +struct MoveDef; namespace HAPFS { struct PathSearch { diff --git a/rts/Sim/Path/QTPFS/Components/PathSpeedModInfo.h b/rts/Sim/Path/QTPFS/Components/PathSpeedModInfo.h index 19f7c9285cc..4bde1a878b5 100644 --- a/rts/Sim/Path/QTPFS/Components/PathSpeedModInfo.h +++ b/rts/Sim/Path/QTPFS/Components/PathSpeedModInfo.h @@ -12,7 +12,7 @@ namespace QTPFS { -class INode; +struct INode; struct NodeLayerSpeedInfoSweep { static constexpr std::size_t page_size = MoveDefHandler::MAX_MOVE_DEFS; From a854046cef9082d73bac86d19c020e4babac4592 Mon Sep 17 00:00:00 2001 From: Tom J Nowell Date: Thu, 25 Jun 2026 15:56:49 +0100 Subject: [PATCH 14/25] macOS: guard X11/Xlib.h include for non-X11 platforms SpringApp.cpp included for every non-Windows platform, but macOS has no X11 headers by default, breaking the native build. The only user of it, XInitThreads(), is already excluded on __APPLE__ at its call site, so guard the include the same way. --- rts/System/SpringApp.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rts/System/SpringApp.cpp b/rts/System/SpringApp.cpp index e5a011b19c3..a2274b6f9e5 100644 --- a/rts/System/SpringApp.cpp +++ b/rts/System/SpringApp.cpp @@ -13,7 +13,9 @@ #undef KeyRelease #else #include // isatty +#ifndef __APPLE__ #include // XInitThreads +#endif #undef KeyPress #undef KeyRelease From ab68d6fc27bcf3a25e06684506b17fb4f9d3bae0 Mon Sep 17 00:00:00 2001 From: bruno-dasilva <8520801+bruno-dasilva@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:26:11 -0700 Subject: [PATCH 15/25] fix: address sse2neon/streflop macro redefinition warning (#2999) --- rts/System/simd_compat.h | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/rts/System/simd_compat.h b/rts/System/simd_compat.h index a405e02f587..c68b59e3484 100644 --- a/rts/System/simd_compat.h +++ b/rts/System/simd_compat.h @@ -3,6 +3,19 @@ #ifdef SSE2NEON #include "lib/sse2neon/sse2neon.h" + // sse2neon leaks 's FE_XXX macros, which collide with the ones streflop + // redefines and trigger a #warning. Undef them here so streflop gets a clean slate. + #undef FE_INVALID + #undef FE_DENORMAL + #undef FE_DIVBYZERO + #undef FE_OVERFLOW + #undef FE_UNDERFLOW + #undef FE_INEXACT + #undef FE_ALL_EXCEPT + #undef FE_TONEAREST + #undef FE_DOWNWARD + #undef FE_UPWARD + #undef FE_TOWARDZERO #else #ifdef _MSC_VER #include // MSVC umbrella From 52b55cf38ac65a4b30257ccc8d0815693da39cac Mon Sep 17 00:00:00 2001 From: Tom J Nowell Date: Mon, 29 Jun 2026 11:34:36 +0100 Subject: [PATCH 16/25] glad: do not build the GLX loader on macOS glad_glx.c includes X11/X.h to provide the GLX windowing-system bindings, but macOS has no X11/GLX. The Glad CMakeLists added glad_glx.c for every UNIX-and-not-MinGW platform, so building any GL-enabled target (e.g. engine-legacy) on macOS failed: fatal error: X11/X.h: No such file or directory macOS resolves GL entry points without GLX (the engine's glxHandler is already #ifdef'd out on __APPLE__), so exclude glad_glx.c on Apple and build only the core glad.c there. --- rts/lib/glad/CMakeLists.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rts/lib/glad/CMakeLists.txt b/rts/lib/glad/CMakeLists.txt index a67e710dd36..7f7391dd7d3 100644 --- a/rts/lib/glad/CMakeLists.txt +++ b/rts/lib/glad/CMakeLists.txt @@ -1,10 +1,10 @@ cmake_minimum_required(VERSION 3.5) project(Glad) -if (UNIX AND NOT MINGW) +if (UNIX AND NOT MINGW AND NOT APPLE) add_library(glad glad.c glad_glx.c) -else (UNIX AND NOT MINGW) +else (UNIX AND NOT MINGW AND NOT APPLE) add_library(glad glad.c) -endif (UNIX AND NOT MINGW) +endif (UNIX AND NOT MINGW AND NOT APPLE) target_include_directories(glad PUBLIC /) \ No newline at end of file From 4034d5a13273ae1078c3aa8b59c525bd6f51c052 Mon Sep 17 00:00:00 2001 From: bruno-dasilva <8520801+bruno-dasilva@users.noreply.github.com> Date: Mon, 29 Jun 2026 05:41:52 -0700 Subject: [PATCH 17/25] docs: add ENGINE_PERFORMANCE.md with some notes on engine internals (#2919) Co-Authored-By: Claude Opus 4.6 (1M context) --- AGENTS.md | 4 +++ coding-agents/ENGINE_PERFORMANCE.md | 55 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 coding-agents/ENGINE_PERFORMANCE.md diff --git a/AGENTS.md b/AGENTS.md index 0f87ef9915c..63780f55c46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -388,6 +388,10 @@ The engine uses custom thread pools. See `THREADPOOL` define and related code. 4. Follow the workflow in `contributing.md` 5. Disclose any AI assistance used +### Additional docs +Please see @coding-agents/ for additional documentation: +- coding-agents/ENGINE_PERFORMANCE.md — notes on scale targets and engine performance internals. Useful for performance related changes. + ## Additional Resources - Official website: https://recoilengine.org diff --git a/coding-agents/ENGINE_PERFORMANCE.md b/coding-agents/ENGINE_PERFORMANCE.md new file mode 100644 index 00000000000..f3bb67f5d77 --- /dev/null +++ b/coding-agents/ENGINE_PERFORMANCE.md @@ -0,0 +1,55 @@ +# Engine performance + +Recoil is an RTS engine built for large-scale games — designed to handle thousands of units at once. + +## Scale target + +- **Target:** ~10k concurrent units, *including buildings*. +- Mobile units tend to be ~40% of that late-game total. +- Largest seen in a real game: ~17.7k units. That's a data point, not a design target. + +## Sim, draw, and update frames + +The main loop is `Update → Draw`, repeating — see the diagram and table below for the per-phase breakdown. Each iteration first drains any queued sim-frame packets (0..N per iteration), then renders one draw frame. `CGame::Update` dispatches `SimFrame()` calls as `NETMSG_NEWFRAME` packets arrive; `CGame::Draw` runs the unsynced update phase and then renders. The sim burst is capped at ~500 ms (`minDrawFPS`) so draw always gets to run, and it's all one thread — sim and rendering are **not concurrent**; parallelism only happens *inside* a phase. + +Conversely, if no sim frames are in the queue the main loop runs `Draw`/`UpdateUnsynced` as fast as possible — many draw iterations can pass between successive sim frames, with visuals interpolating smoothly in between via `globalRendering->timeOffset`. + +``` +main-loop iteration (repeats as fast as possible) +├── CGame::Update (mostly synced) +│ └── SimFrame × 0..N ← processes queued sim frames capped at +| ~500ms per iteration +└── CGame::Draw (unsynced) + ├── UpdateUnsynced ← unsynced update phase + └── render world + screen ← Draw::World + Draw::Screen +``` + +| Phase | Rate | Synced? | Responsibility | +|---|---|---|---| +| **Sim frame** — `CGame::SimFrame` | fixed 30 Hz (`GAME_SPEED`) | mostly yes | advance deterministic state: units, pathing, projectiles, line-of-sight, scripts, Lua `GameFrame` | +| **Draw frame** — `CGame::Draw` | variable | no | update phase (see below) + render world/screen | +| **Update phase** — `CGame::UpdateUnsynced` *(inside draw frame)* | per draw frame | no | timings, interpolation, camera, GUI, sound, world-drawer prep | + +### Profiler buckets + +The engine `CTimeProfiler` (and the `benchmark` tool) report three peer buckets: `Sim` (the whole synced step), `Update` (`CGame::UpdateUnsynced`), and `Draw` (rendering only, *excluding* the Update that runs first). + +`Sim` is **"mostly" synced**: it also bills unsynced work that runs inline during `SimFrame`. +- **Explicit Lua callins** — `GameFrame`/`GameFramePost` run near the start of each sim frame. +- **Event-driven Lua callins** — unsynced widgets can subscribe to synced game events, so their handlers run inline as those events fire during the frame. +- **C++-only unsynced sections** — e.g. the MT projectile visual pass (`Sim::Projectiles::UpdateUnsyncedMT`) and ghosted-building updates (`CUnitDrawer::UpdateGhostedBuildings`). + +### Scheduling and CPU budget + +- Sim has a target rate set by the server; draw is as fast as the hardware allows. The sim target is `30 Hz × speedFactor`; at a speed factor of 1x, in-game time tracks real-world time 1:1, and at 2x speed the server fires twice as many sim frames per real-world second so the world evolves twice as fast. +- **Zero, one, or many** sim frames per draw frame — if the client falls behind, pending sim frames burst in the next iteration to catch up. +- Visuals interpolate between sim frames, so draw rate can exceed sim rate without stutter. +- Sim time is carefully budgeted and scheduled against draw frames (because they run serially) so there's always a minimum fps for the player + +## Multi-threading + +The engine runs one **main thread** plus a pool of **worker threads**, all pinned to distinct cores. We typically aim for 6-8 worker threads. The main thread drives the sim/draw loop; workers pick up parallel work dispatched from the main thread (via `for_mt` and friends in `rts/System/Threading/ThreadPool.h`). The main thread also participates in draining the task queue while it waits. + +Most parallel work in the engine is **homogeneous** — the same operation applied over many items (unit updates, projectile steps, etc.) via `for_mt`. Keeping parallel work homogeneous is a deliberate discipline: it makes determinism easier to reason about and keeps sim output independent of how work happens to land across threads. + +**QTPFS is the one heterogeneous exception.** The quad-tree pathfinder maintains its own per-worker search state (`SearchThreadData`, `SparseData`) independent of engine sim state, which lets it safely run path searches on the worker pool *in the background* via `for_mt_background`. Background tasks yield to higher-priority work by rescheduling themselves when other jobs arrive, so QTPFS soaks up idle worker capacity without preempting foreground parallelism. From 7e07543f7fd14c11ac8df2ef0e4785e081a07aef Mon Sep 17 00:00:00 2001 From: Tom J Nowell Date: Mon, 29 Jun 2026 13:58:30 +0100 Subject: [PATCH 18/25] System: add missing includes for libc++ These three headers use std:: algorithms but rely on being pulled in transitively. libstdc++ does so; libc++ (clang/macOS) does not, so they can fail to compile under libc++ depending on the version. Symbols that require the include: - rts/System/Matrix44f.h -> std::copy - rts/System/SpringHashMap.hpp -> std::fill_n - rts/System/SpringHashSet.hpp -> std::fill_n Adding the include is "include what you use" correctness and is a no-op on toolchains that already provide it transitively. No functional change. Cherry-picked from ExaDev/RecoilEngine (0ed29d550e, 0b4bd10a1e, 3adabd005a). AI assistance: changes identified and applied with Claude (Anthropic); verified by a human (compiled under clang/libc++, no regression). --- rts/System/Matrix44f.h | 1 + rts/System/SpringHashMap.hpp | 1 + rts/System/SpringHashSet.hpp | 1 + 3 files changed, 3 insertions(+) diff --git a/rts/System/Matrix44f.h b/rts/System/Matrix44f.h index 3dd6767dd33..bd5e6d1fc5b 100644 --- a/rts/System/Matrix44f.h +++ b/rts/System/Matrix44f.h @@ -2,6 +2,7 @@ #pragma once +#include #include #include #include diff --git a/rts/System/SpringHashMap.hpp b/rts/System/SpringHashMap.hpp index 1bf487a6111..f6a359cb14d 100644 --- a/rts/System/SpringHashMap.hpp +++ b/rts/System/SpringHashMap.hpp @@ -6,6 +6,7 @@ #pragma once +#include #include #include #include diff --git a/rts/System/SpringHashSet.hpp b/rts/System/SpringHashSet.hpp index 3375e12520c..366a5d35211 100644 --- a/rts/System/SpringHashSet.hpp +++ b/rts/System/SpringHashSet.hpp @@ -6,6 +6,7 @@ #pragma once +#include #include #include // malloc #include From cf3b083040550e9cb6047839624fea829f3ca0c0 Mon Sep 17 00:00:00 2001 From: Tom J Nowell Date: Mon, 29 Jun 2026 10:40:36 +0100 Subject: [PATCH 19/25] DemoTool: link nowide and fmt for their include directories DemoTool compiles engine FileSystem sources (FileHandler, FileSystem) that include nowide/fstream.hpp and fmt/printf.h, but its target_link_libraries omitted the nowide and fmt targets. The engine builds obtain those include directories transitively through the nowide::nowide and fmt::fmt INTERFACE targets; DemoTool linked neither, so the build failed when the headers were not on a default search path (observed building the demotool target on macOS). Link nowide::nowide and fmt::fmt so their INTERFACE include directories propagate to the demotool target. Co-authored-by: Robert Burnham --- tools/DemoTool/CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/DemoTool/CMakeLists.txt b/tools/DemoTool/CMakeLists.txt index dc9cbf66ecf..fdc47e0461d 100644 --- a/tools/DemoTool/CMakeLists.txt +++ b/tools/DemoTool/CMakeLists.txt @@ -70,6 +70,8 @@ target_link_libraries(demotool gflags_nothreads_static 7zip ${ZLIB_LIBRARY} + nowide::nowide + fmt::fmt ${PLATFORM_LIBS} Tracy::TracyClient ) From f898efc6c31b3871cc8f60ed2c567a5de41db696 Mon Sep 17 00:00:00 2001 From: Robert Burnham Date: Thu, 18 Jun 2026 23:30:55 -0500 Subject: [PATCH 20/25] Fix truncated sync checksum in demotool dump --- tools/DemoTool/DemoTool.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/DemoTool/DemoTool.cpp b/tools/DemoTool/DemoTool.cpp index 96bb8930344..d26b0c9b955 100644 --- a/tools/DemoTool/DemoTool.cpp +++ b/tools/DemoTool/DemoTool.cpp @@ -1,5 +1,6 @@ /* This file is part of the Spring engine (GPL v2 or later), see LICENSE.html */ +#include #include #include #include @@ -417,7 +418,7 @@ void TrafficDump(CDemoReader& reader, bool trafficStats) //uchar myPlayerNum; int frameNum; uint checksum; std::cout << "NETMSG_SYNCRESPONSE: Playernum: "<< (unsigned)buffer[1]; std::cout << " Framenum: " << *(int*)(buffer+2); - std::cout << " Checksum: " << (unsigned)buffer[6]; + std::cout << " Checksum: " << *(uint32_t*)(buffer+6); std::cout << std::endl; break; case NETMSG_DIRECT_CONTROL: From 8141bf23dc42630bb235c155ca7db8ae56ba7610 Mon Sep 17 00:00:00 2001 From: Tom J Nowell Date: Tue, 30 Jun 2026 10:53:26 +0100 Subject: [PATCH 21/25] Portability in archive handlers * avoid parenthesised aggregate init and name the type explicitly (not supported everywhere) * avoid narrowing conversions. --- rts/System/FileSystem/Archives/DirArchive.cpp | 2 +- rts/System/FileSystem/Archives/SevenZipArchive.cpp | 8 ++++---- rts/System/FileSystem/Archives/ZipArchive.cpp | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/rts/System/FileSystem/Archives/DirArchive.cpp b/rts/System/FileSystem/Archives/DirArchive.cpp index 829a56c4251..55242d39db0 100644 --- a/rts/System/FileSystem/Archives/DirArchive.cpp +++ b/rts/System/FileSystem/Archives/DirArchive.cpp @@ -42,7 +42,7 @@ CDirArchive::CDirArchive(const std::string& archiveName) // all variables here will use forward slashes, no need for conversion std::string rawFileName = dataDirsAccess.LocateFile(dirName + origName); - files.emplace_back(origName, std::move(rawFileName), -1, 0); + files.emplace_back(Files{origName, std::move(rawFileName), -1, 0}); // convert to lowercase and store lcNameIndex[StringToLower(std::move(origName))] = static_cast(files.size() - 1); diff --git a/rts/System/FileSystem/Archives/SevenZipArchive.cpp b/rts/System/FileSystem/Archives/SevenZipArchive.cpp index e40ce9e798d..d5994770067 100644 --- a/rts/System/FileSystem/Archives/SevenZipArchive.cpp +++ b/rts/System/FileSystem/Archives/SevenZipArchive.cpp @@ -147,12 +147,12 @@ CSevenZipArchive::CSevenZipArchive(const std::string& name) continue; } - const auto& fd = fileEntries.emplace_back( - i, //fp - SzArEx_GetFileSize(&db, i), // size + const auto& fd = fileEntries.emplace_back(FileEntry{ + static_cast(i), //fp + static_cast(SzArEx_GetFileSize(&db, i)), // size db.MTime.Vals ? static_cast(CTimeUtil::NTFSTimeToTime64(db.MTime.Vals[i].Low, db.MTime.Vals[i].High)) : 0, // modtime std::move(fileName.value()) // origName - ); + }); lcNameIndex.emplace(StringToLower(fd.origName), fileEntries.size() - 1); } diff --git a/rts/System/FileSystem/Archives/ZipArchive.cpp b/rts/System/FileSystem/Archives/ZipArchive.cpp index 37914269c49..e7b13e3e790 100644 --- a/rts/System/FileSystem/Archives/ZipArchive.cpp +++ b/rts/System/FileSystem/Archives/ZipArchive.cpp @@ -58,13 +58,13 @@ CZipArchive::CZipArchive(const std::string& archiveName) unz_file_pos fp{}; unzGetFilePos(zip, &fp); - const auto& fd = fileEntries.emplace_back( + const auto& fd = fileEntries.emplace_back(FileEntry{ std::move(fp), //fp - info.uncompressed_size, //size + static_cast(info.uncompressed_size), //size fName, //origName - info.crc, //crc + static_cast(info.crc), //crc static_cast(CTimeUtil::DosTimeToTime64(info.dosDate)) //modTime - ); + }); lcNameIndex.emplace(StringToLower(fd.origName), fileEntries.size() - 1); } From 898dd6cf869d7c590f7f1bd906fdd40e92f414e3 Mon Sep 17 00:00:00 2001 From: Bruno Da Silva Date: Sat, 11 Apr 2026 17:23:07 +0000 Subject: [PATCH 22/25] docs: add BACKWARDS_COMPATIBILITY.md guidance doc for coding agents --- AGENTS.md | 2 +- coding-agents/BACKWARDS_COMPATIBILITY.md | 25 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 coding-agents/BACKWARDS_COMPATIBILITY.md diff --git a/AGENTS.md b/AGENTS.md index 63780f55c46..f62bffeb42e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -391,7 +391,7 @@ The engine uses custom thread pools. See `THREADPOOL` define and related code. ### Additional docs Please see @coding-agents/ for additional documentation: - coding-agents/ENGINE_PERFORMANCE.md — notes on scale targets and engine performance internals. Useful for performance related changes. - +- coding-agents/BACKWARDS_COMPATIBILITY.md - notes on when we should strive to be backwards compatible. Reference it for any major reworks or api changes. ## Additional Resources - Official website: https://recoilengine.org diff --git a/coding-agents/BACKWARDS_COMPATIBILITY.md b/coding-agents/BACKWARDS_COMPATIBILITY.md new file mode 100644 index 00000000000..d36c3775a78 --- /dev/null +++ b/coding-agents/BACKWARDS_COMPATIBILITY.md @@ -0,0 +1,25 @@ +# Backwards compatibility + +Recoil isn't 100% beholden to backwards compatibility, but breaking changes are weighed carefully against their benefit. Backwards compatiblity is a constraint, not a veto. + +## Why the bar is high + +Recoil games aren't short-cycle Unreal/Unity titles — they're lifetime hobby projects. There is no steady stream of new games picking up the latest engine; the games we have are the games we have. They fall into two camps, and neither absorbs churn well: + +- **Mature games** need stability above all else. +- **Games still in active development** have the flexibility, but rarely the volunteer bandwidth to chase significant engine breakage. + +## How to weigh a change + +- Quantify the benefit (perf, correctness, maintainability) concretely, not in the abstract. +- Identify which games or content would break, and how mechanical the fix is on their side. "Rename a call site" is very different from "rearchitect your gadget." +- Prefer changes whose blast radius is contained or whose adaptation is mechanical. Avoid changes that force games to rethink core logic with no real mitigation path. + +## Precedents + +- **Multi-threaded unit movement & collision** — landed with a large perf win and effectively no game-side impact (ignoring incidentally-fixed bugs). This is the shape of change to look for. +- **Multi-threading `Unit::Update`, `Unit::SlowUpdate`, or projectiles** — don't. The impact on games would be huge and there isn't much that can be done to mitigate it. Not a path worth proposing. + +## The upshot + +Backwards compatiblity constraints don't close the door on performance work — they just point it at the areas where the blast radius is small. Plenty of wins are still on the table; pick the ones games don't have to pay for. From 487fd436e756bdaa5ad4469adbef3f9638bf3101 Mon Sep 17 00:00:00 2001 From: bruno-dasilva <8520801+bruno-dasilva@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:21:24 -0700 Subject: [PATCH 23/25] docs: improve running/testing instructions in AGENTS.md (#2917) --- AGENTS.md | 160 ++++++++++++++++++++++++++------------- docker-build-v2/build.sh | 11 ++- test/AGENTS.md | 128 +++++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+), 54 deletions(-) create mode 100644 test/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index f62bffeb42e..4d346e24500 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,18 +8,49 @@ RecoilEngine is an open-source real-time strategy (RTS) game engine written in C ## Build Commands +### Submodules + +The repo uses git submodules for vendored libraries (`rts/lib/*`, `tools/pr-downloader`, AI skirmish bots, etc.). If you cloned without `--recurse-submodules`, initialize them before building: +```bash +git submodule update --init --recursive +``` + ### Building the Engine **Using Docker (Recommended):** ```bash -# Build for Linux +# Full build (default: RELWITHDEBINFO, -O3 -g -DNDEBUG, Ninja) +# Output lands in build--/ (e.g. build-amd64-linux/) and the +# ready-to-use install in build-amd64-linux/install/ docker-build-v2/build.sh linux -# Build for Windows +# Parallelism +docker-build-v2/build.sh -j 8 linux + +# Windows cross-build docker-build-v2/build.sh windows -# Build with custom CMake options +# Change optimization level — trailing -D… is forwarded to configure.sh and +# overrides the baked-in RELWITHDEBINFO default. docker-build-v2/build.sh linux -DCMAKE_BUILD_TYPE=DEBUG +docker-build-v2/build.sh linux -DCMAKE_BUILD_TYPE=RELEASE +docker-build-v2/build.sh linux -DCMAKE_BUILD_TYPE=PROFILE + +# Combine cmake options (configure phase) +docker-build-v2/build.sh linux -DBUILD_spring-headless=OFF -DTRACY_ENABLE=ON + +# List all available cmake options and their current values +docker-build-v2/build.sh --configure linux -LH + +# Build a specific target — use --compile so args flow to `cmake --build`, +# not to configure. Without --compile, `-t …` would be rejected by configure. +docker-build-v2/build.sh --compile linux -t engine-headless +docker-build-v2/build.sh --compile linux -t engine-legacy +docker-build-v2/build.sh --compile linux -t tests --verbose + +# Split the phases +docker-build-v2/build.sh --configure linux # configure only +docker-build-v2/build.sh --compile linux # compile only (reuses existing config) ``` **Without Docker:** @@ -27,16 +58,44 @@ docker-build-v2/build.sh linux -DCMAKE_BUILD_TYPE=DEBUG # Create build directory mkdir -p build && cd build -# Configure +# Configure — project requires C++23 (clang ≥ 17 or gcc ≥ 13 on PATH). +# CMAKE_BUILD_TYPE defaults to RELWITHDEBINFO when omitted. cmake .. -# Build specific target -cmake --build . --target engine-headless -j$(nproc) - -# Build all -cmake --build . -j$(nproc) +# Optional: Default generator is Unix Makefiles; add `-G Ninja` for faster builds if ninja is installed. +cmake -G Ninja .. + +# Optional: pin to gcc-13 + gold linker via the in-repo toolchain file +# (tracked under docker-build-v2/; same compiler the docker build uses). +cmake \ + -DCMAKE_TOOLCHAIN_FILE=../docker-build-v2/images/all-linux/toolchain.cmake .. + +# Optional: speed up incremental builds with ccache +cmake \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache \ + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache .. + +# Change optimization level by re-running cmake (no wipe required): +cmake -DCMAKE_BUILD_TYPE=DEBUG .. # no optimization, full symbols +cmake -DCMAKE_BUILD_TYPE=RELEASE .. # optimized, no debug info +cmake -DCMAKE_BUILD_TYPE=RELWITHDEBINFO .. # optimized + debug info (default) +cmake -DCMAKE_BUILD_TYPE=PROFILE .. # optimized + profiling hooks + +# Build (generator-agnostic — works under Ninja or Make) +cmake --build . + +# Build a specific target +cmake --build . --target engine-headless +cmake --build . --target engine-legacy +cmake --build . --target engine-dedicated +cmake --build . --target tests ``` +> The docker flow writes to **`build--/`** (e.g. `build-amd64-linux/` +> or `build-amd64-windows/`), which is a different directory than the `build/` +> used by this flow. When running tests, point commands at whichever build +> directory you populated. + ### Build Types - `DEBUG` - Debug build with full symbols and no optimization - `RELEASE` - Optimized release build @@ -44,55 +103,64 @@ cmake --build . -j$(nproc) - `PROFILE` - Profiling build ### Build Targets -- `engine-legacy` - Main engine build -- `engine-headless` - Headless server build -- `engine-dedicated` - Dedicated server build -- `tests` - Build all test executables -- `check` - Build and run all tests -- `spring-content` - Build game content packages +- `engine-legacy` — main interactive engine build +- `engine-headless` — headless engine (no graphics) +- `engine-dedicated` — dedicated server +- `unitsync` — unitsync shared library +- `pr-downloader` — content downloader tool +- `tests` — phony; builds every `test_*` executable under `build/test/` +- `check` — phony; depends on `engine-headless` + all `test_*` executables, then runs ctest with `--output-on-failure -V` +- `install` — install into `CMAKE_INSTALL_PREFIX` ## Testing -### Test Framework -The project uses **Catch2** for unit testing. Test files are located in the `test/` directory. +### Writing Tests +See `test/AGENTS.md` for details on writing tests, available compile flags, patterns, and test helpers. ### Running Tests **Build and run all tests:** ```bash -# From build directory -make tests # Build all test executables -make check # Build and run all tests via CTest -make test # Alternative: run via CTest +# From build/ — ctest / check recipes below assume a non-docker build. +# For a docker build, run `docker-build-v2/build.sh --compile linux -t check` +# (runs ctest inside the container) or invoke the binaries in +# build-amd64-linux/test/ directly. +cmake --build . --target tests # build all test executables (no run) +ctest # run all tests (does not rebuild) +# OR +cmake --build . --target check # rebuild engine-headless + all tests, then run ctest -V ``` -**Run a single test:** +`check` is the safe default when iterating; bare `ctest` is faster when nothing relevant has changed since the last build. + +**Run a single test (from repo root):** ```bash -# Tests are built as executable binaries in the build directory -# Pattern: test_ +# Tests are built as executable binaries under /test/ +# Pattern: /test/test_ +# where depends on if you built in docker or not (see above). -# Run specific test executable -./test_Float3 -./test_Matrix44f -./test_SyncedPrimitive -./test_UDPListener +./build/test/test_Float3 +./build/test/test_Matrix44f +./build/test/test_SyncedPrimitive +./build/test/test_UDPListener -# Run with verbose output -./test_Float3 -s +# Catch2: show passing assertions too +./build/test/test_Float3 -s -# Run specific test case -./test_Float3 "TestSection" +# Run a specific test case by name (positional arg matches TEST_CASE name, supports wildcards) +./build/test/test_Float3 "Float3" +./build/test/test_Float3 "Float34_*" ``` -**Run via CTest:** +**Run via CTest (from inside build/):** ```bash -# Run specific test by name -ctest -R Float3 -V +# Filter by regex, show output only on failure +ctest -R Float3 --output-on-failure -# Run with regex pattern -ctest -R Matrix -V +# Same, but verbose (full stdout regardless of result) +ctest -R Float3 -V -# List all available tests +# List all registered tests without running ctest -N ``` @@ -325,20 +393,6 @@ Use preprocessor directives for platform-specific code: - Use tabs for indentation in CMake files - Keep lines reasonably short -### Adding Tests -In `test/CMakeLists.txt`: -```cmake -set(test_name TestName) -set(test_src - "${CMAKE_CURRENT_SOURCE_DIR}/path/to/TestFile.cpp" - ${test_Common_sources} -) -set(test_libs - library_name -) -add_spring_test(${test_name} "${test_src}" "${test_libs}" "${test_flags}") -``` - ## Project Structure - `rts/` - Main engine source code diff --git a/docker-build-v2/build.sh b/docker-build-v2/build.sh index 765ba4a1495..6bded8b8b57 100755 --- a/docker-build-v2/build.sh +++ b/docker-build-v2/build.sh @@ -179,7 +179,16 @@ if [[ "$GIT_DIR" != "$GIT_COMMON_DIR" ]]; then WORKTREE_MOUNTS="-v $GIT_COMMON_DIR:$GIT_COMMON_DIR:ro" fi -$RUNTIME run --platform=linux/$ARCH -it --rm \ +# Docker's -t requires stdin AND stdout to be TTYs; in CI, pipes, or agent +# contexts one or both are missing and docker errors out with "the input +# device is not a TTY". Only add -t when it's safe; -i is harmless either +# way (non-interactive stdin just sees EOF). +TTY_FLAG= +if [[ -t 0 && -t 1 ]]; then + TTY_FLAG=-t +fi + +$RUNTIME run --platform=linux/$ARCH -i $TTY_FLAG --rm \ -v "$CWD${P}":/build/src:z,ro \ -v "$CWD${P}.cache${P}ccache-$PLATFORM":/build/cache:z,rw \ -v "$CWD${P}build-$PLATFORM":/build/out:z,rw \ diff --git a/test/AGENTS.md b/test/AGENTS.md new file mode 100644 index 00000000000..17fe17159f0 --- /dev/null +++ b/test/AGENTS.md @@ -0,0 +1,128 @@ +# AGENTS.md + +This file provides guidance to coding agents when working with code in this repository's test folder. + +## Build & Run Tests + +```bash +# From build/: build all test executables +cmake --build . --target tests + +# From build/: run tests. ctest/check recipes here assume a non-docker +# build. For a docker build, run `docker-build-v2/build.sh --compile linux -t check` +# (runs ctest inside the container) or invoke binaries in +# build-amd64-linux/test/ directly (see below). +ctest # run already-built tests; does not rebuild +cmake --build . --target check # rebuild engine-headless + all tests first, then ctest -V + +# From build/: run ctest with filters +ctest --output-on-failure # concise; only shows failing output +ctest -R Float3 --output-on-failure # filter by name regex +ctest -R Float3 -V # same, verbose +ctest -N # list all registered tests without running + +# From repo root: run a single test binary directly (fastest iteration). +# Use build-amd64-linux/ instead of build/ if you built via docker. +./build/test/test_Float3 +./build/test/test_Float3 -s # Catch2: show passing assertions too +./build/test/test_Float3 "Float3" # filter by TEST_CASE name (supports wildcards) +``` + +`cmake --build . --target check` is the full-fat target: it depends on `engine-headless` and +every `test_*` executable, so it relinks anything stale before running ctest with +`--output-on-failure -V`. Use bare `ctest` when you want to skip the rebuild. + +## Framework + +**Catch2** (amalgamated single-header version) in `lib/catch2/`. Custom main in `lib/catch2/catch_main.cpp` with leak detection enabled via `CATCH_AMALGAMATED_CUSTOM_MAIN`. + +## Test Organization + +``` +engine/System/ # Core system tests (math, threading, I/O, serialization) +engine/Sim/Misc/ # Simulation tests (QuadField, Ellipsoid) +lib/luasocket/ # Lua socket restriction tests +other/ # Mutex benchmarks, memory pool tests +unitsync/ # UnitSync API tests +validation/ # Integration tests (shell scripts that run full game simulation) +tools/CompileFailTest/ # Negative test framework (tests that must NOT compile) +headercheck/ # Header isolation tests (cmake -DHEADERCHECK=ON) +``` + +## Adding a New Test + +1. Create test source in the appropriate subdirectory under `engine/`, `other/`, etc. +2. In `test/CMakeLists.txt`, add a block using the `add_spring_test` macro: +```cmake +set(test_name MyTest) +set(test_src + "${CMAKE_CURRENT_SOURCE_DIR}/engine/System/testMyTest.cpp" + ${test_Common_sources} +) +set(test_libs "") +set(test_flags "-DNOT_USING_CREG -DNOT_USING_STREFLOP -DBUILDING_AI") +add_spring_test(${test_name} "${test_src}" "${test_libs}" "${test_flags}") +``` +3. The macro creates executable `test_` and registers it with ctest as `test`. + +## Common Compile Flags + +| Flag | Purpose | +|------|---------| +| `-DUNIT_TEST` | Always set for all tests (global) | +| `-DSYNCCHECK` | Always set for all tests (global) | +| `-DNOT_USING_CREG` | Stubs out `CR_*` macros. Default unless the test exercises save/load serialization. | +| `-DNOT_USING_STREFLOP` | Falls back to ``. Default unless the test verifies synced floating-point determinism. | +| `-DBUILDING_AI` | Makes engine headers skip engine-only paths. Pair with `NOT_USING_CREG` and `NOT_USING_STREFLOP`. | +| `-DTHREADPOOL` | Selects the real thread pool over the stub. Set only when the test needs real parallelism. | +| `-DUNITSYNC` | Marks the file as part of unitsync. Only needed for tests that link the unitsync library. | + +## Patterns + +### Basic test file +```cpp +#include +#include "System/Log/ILog.h" + +TEST_CASE("MyFeature") { + CHECK(1 + 1 == 2); + SECTION("sub-case") { + CHECK(true); + } +} +``` + +### Tests that need timing +```cpp +#include "System/Misc/SpringTime.h" +TEST_CASE("TimingTest") { + InitSpringTime ist; // RAII - must be instantiated before using spring_time + // ... +} +``` + +### Thread-safe assertions +Catch2 is NOT thread-safe. Multi-threaded tests must guard assertions: +```cpp +static spring::mutex m; +#define SAFE_CHECK(expr) { std::lock_guard lk(m); CHECK(expr); } +``` + +### Compile-fail tests +Tests that verify code correctly fails to compile. Source uses `#ifdef FAIL` guards: +```cpp +#ifdef FAIL +#ifdef TEST1 + int x = someStronglyTypedEnum; // must not compile +#endif +#endif +``` +Registered in CMakeLists.txt via: +```cmake +spring_test_compile_fail(testName_fail1 ${test_src} "-DTEST1") +``` + +## Test Helpers (mock/stub files) + +- `engine/System/NullGlobalConfig.cpp` — provides default `globalConfig` without full engine init +- `engine/System/Nullerrorhandler.cpp` — stubs `ErrorMessageBox()` to prevent GUI popups From d313449ce9fc71b65280285651162f4061fdf171 Mon Sep 17 00:00:00 2001 From: sprunk Date: Thu, 2 Jul 2026 10:47:46 +0200 Subject: [PATCH 24/25] Move `isHeadless` from `Engine` to `Platform`. (#3067) --- doc/site/content/changelogs/_index.markdown | 2 +- rts/Lua/LuaConstEngine.cpp | 3 --- rts/Lua/LuaConstPlatform.cpp | 4 ++++ 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/doc/site/content/changelogs/_index.markdown b/doc/site/content/changelogs/_index.markdown index 55096582c3a..0e073538cb7 100644 --- a/doc/site/content/changelogs/_index.markdown +++ b/doc/site/content/changelogs/_index.markdown @@ -74,7 +74,7 @@ This is the bleeding-edge changelog since version 2025.06, for **pre-release 202 - add `Spring.GetClosestEnemyUnit(x, y, z, range = inf, allyTeamID, bool useLoS = true, bool spherical = false, bool requireEnemyToSeePos = false) → unitID?` to LuaRules. - large QTPFS perf improvements. - always output logs to stdout. -- add `Engine.isHeadless`, available in unsynced only. +- add boolean `Platform.isHeadless`. - archive cache version 20 → 21. ## Fixes diff --git a/rts/Lua/LuaConstEngine.cpp b/rts/Lua/LuaConstEngine.cpp index 068a26d0ecf..0d38c1ffbf0 100644 --- a/rts/Lua/LuaConstEngine.cpp +++ b/rts/Lua/LuaConstEngine.cpp @@ -59,9 +59,6 @@ bool LuaConstEngine::PushEntries(lua_State* L) LuaPushNamedString(L, "buildFlags" , SpringVersion::GetAdditional()); LuaPushNamedNumber(L, "wordSize", (!CLuaHandle::GetHandleSynced(L))? Platform::NativeWordSize() * 8: 0); - if (!CLuaHandle::GetHandleSynced(L)) - LuaPushNamedBool(L, "isHeadless", SpringVersion::IsHeadless()); - LuaPushNamedNumber(L, "gameSpeed", GAME_SPEED); LuaPushNamedNumber(L, "maxCustomPaletteID", MAX_CUSTOM_COLORS - 1); diff --git a/rts/Lua/LuaConstPlatform.cpp b/rts/Lua/LuaConstPlatform.cpp index 0a5f667ed80..7262bd7d5a5 100644 --- a/rts/Lua/LuaConstPlatform.cpp +++ b/rts/Lua/LuaConstPlatform.cpp @@ -2,6 +2,7 @@ #include "LuaConstPlatform.h" #include "LuaUtils.h" +#include "Game/GameVersion.h" #include "System/Platform/Hardware.h" #include "System/Platform/Misc.h" #include "Rendering/GlobalRendering.h" @@ -147,5 +148,8 @@ bool LuaConstPlatform::PushEntries(lua_State* L) /*** @field Platform.macAddrHash string */ LuaPushNamedString(L, "macAddrHash", Platform::GetMacAddrHash()); + /*** @field Platform.isHeadless boolean Is this a headless build which only simulates and doesnt offer interactive IO? */ + LuaPushNamedBool(L, "isHeadless", SpringVersion::IsHeadless()); + return true; } From 092cb0a04ef4db8230bf781fbdc2a01d74a0c6a3 Mon Sep 17 00:00:00 2001 From: Alexander Heistermann Date: Thu, 2 Jul 2026 18:04:17 -0500 Subject: [PATCH 25/25] Add Lua trace ray functions (#1624) * adds `Spring.TraceRayBetweenPositions(xA, yA, zA, xB, yB, zB, type)` * adds `Spring.TraceRayInDirection(x, y, z, dx, dy, dz, length, type)` * type is a string, "unit", "feature", or "both" * both return an array of `{distance, objID, objType}` sorted by increasing distance --- rts/Lua/LuaSyncedRead.cpp | 138 ++++++++++++++++++++++++++++++++++++++ rts/Lua/LuaSyncedRead.h | 5 +- 2 files changed, 140 insertions(+), 3 deletions(-) diff --git a/rts/Lua/LuaSyncedRead.cpp b/rts/Lua/LuaSyncedRead.cpp index 40b25741b52..3983a71f3ff 100644 --- a/rts/Lua/LuaSyncedRead.cpp +++ b/rts/Lua/LuaSyncedRead.cpp @@ -399,6 +399,8 @@ bool LuaSyncedRead::PushEntries(lua_State* L) REGISTER_LUA_CFUNC(TraceRayGroundInDirection); REGISTER_LUA_CFUNC(TraceRayGroundBetweenPositions); + REGISTER_LUA_CFUNC(TraceRayInDirection); + REGISTER_LUA_CFUNC(TraceRayBetweenPositions); REGISTER_LUA_CFUNC(GetRadarErrorParams); @@ -9011,6 +9013,142 @@ int LuaSyncedRead::GetUnitScriptNames(lua_State* L) return 1; } + +static int TraceRayImpl(lua_State *const L, const float3 &pos, const float3 &dir, const float maxLen, std::string_view type) +{ + if (type != "unit" && type != "feature" && type != "both") + return luaL_error(L, "invalid type '%s', expected 'unit', 'feature', or 'both'", type.data()); + + const bool testUnits = (type == "unit" || type == "both"); + const bool testFeatures = (type == "feature" || type == "both"); + + QuadFieldQuery qfQuery; + quadField.GetQuadsOnRay(qfQuery, pos, dir, maxLen); + + spring::unordered_set testedUnitIDs; + spring::unordered_set testedFeatureIDs; + std::vector > hits; + + for (const int quadIdx : *qfQuery.quads) { + const CQuadField::Quad& quad = quadField.GetQuad(quadIdx); + + if (testUnits) { + for (const auto *unit : quad.units) { + if (!unit->HasCollidableStateBit(CSolidObject::CSTATE_BIT_QUADMAPRAYS)) + continue; + + if (!testedUnitIDs.insert(unit->id).second) + continue; + + if (!LuaUtils::IsUnitInLos(L, unit)) + continue; + + CollisionQuery cq; + if (CCollisionHandler::DetectHit(unit, unit->GetTransformMatrix(true), pos, pos + dir * maxLen, &cq, true)) { + const float len = cq.GetHitPosDist(pos, dir); + if (len > maxLen) // possibly a bug in CCollisionHandler::DetectHit? + continue; + hits.emplace_back(len, unit->id, "unit"); + } + } + } + + if (testFeatures) { + for (const auto *feature : quad.features) { + if (!feature->HasCollidableStateBit(CSolidObject::CSTATE_BIT_QUADMAPRAYS)) + continue; + + if (!testedFeatureIDs.insert(feature->id).second) + continue; + + if (!LuaUtils::IsFeatureVisible(L, feature)) + continue; + + CollisionQuery cq; + if (CCollisionHandler::DetectHit(feature, feature->GetTransformMatrix(true), pos, pos + dir * maxLen, &cq, true)) { + const float len = cq.GetHitPosDist(pos, dir); + if (len > maxLen) + continue; + hits.emplace_back(len, feature->id, "feature"); + } + } + } + } + + std::stable_sort(hits.begin(), hits.end(), [] (const auto& a, const auto& b) { + return std::get<0>(a) < std::get<0>(b); + }); + + lua_createtable(L, hits.size(), 0); + + int num = 0; + for (const auto& [hitLength, objectID, objectType] : hits) { + lua_createtable(L, 3, 0); + + lua_pushnumber(L, hitLength); + lua_rawseti(L, -2, 1); + lua_pushnumber(L, objectID); + lua_rawseti(L, -2, 2); + lua_pushstring(L, objectType); + lua_rawseti(L, -2, 3); + + lua_rawseti(L, -2, ++num); + } + + return 1; +} + +/*** Traces a ray from a position in a direction + * + * @function Spring.TraceRayInDirection + * + * Returns all unit and/or feature hits along a ray, sorted by distance + * from the start position. + * + * @param posX number + * @param posY number + * @param posZ number + * @param dirX number + * @param dirY number + * @param dirZ number + * @param maxLength number + * @param type string Object type to test: `"unit"`, `"feature"`, or `"both"` + * @return table[] hits Array of `{hitLength, objectID, objectType}` entries + */ +int LuaSyncedRead::TraceRayInDirection(lua_State* L) +{ + float3 pos(luaL_checkfloat(L, 1), luaL_checkfloat(L, 2), luaL_checkfloat(L, 3)); + float3 dir(luaL_checkfloat(L, 4), luaL_checkfloat(L, 5), luaL_checkfloat(L, 6)); + const float maxLen = luaL_optfloat(L, 7, 999999.f); + const char* type = luaL_checkstring(L, 8); + return TraceRayImpl(L, pos, dir, maxLen, type); +} + +/*** Traces a ray between two positions + * + * @function Spring.TraceRayBetweenPositions + * + * Checks for unit and/or feature collisions between two positions + * and returns all hits sorted by distance from the start position. + * + * @param startX number + * @param startY number + * @param startZ number + * @param endX number + * @param endY number + * @param endZ number + * @param type string Object type to test: `"unit"`, `"feature"`, or `"both"` + * @return table[] hits Array of `{hitLength, objectID, objectType}` entries + */ +int LuaSyncedRead::TraceRayBetweenPositions(lua_State* L) +{ + float3 start(luaL_checkfloat(L, 1), luaL_checkfloat(L, 2), luaL_checkfloat(L, 3)); + float3 end(luaL_checkfloat(L, 4), luaL_checkfloat(L, 5), luaL_checkfloat(L, 6)); + const char* type = luaL_checkstring(L, 7); + const auto [dir, length] = (end - start).GetNormalized(); + return TraceRayImpl(L, start, dir, length, type); +} + static int TraceRayGroundImpl(lua_State *const L, const float3 &pos, const float3 &dir, const float maxLen, const bool testWater) { const float rayLength = CGround::LineGroundWaterCol(pos, dir, maxLen, testWater, CLuaHandle::GetHandleSynced(L)); diff --git a/rts/Lua/LuaSyncedRead.h b/rts/Lua/LuaSyncedRead.h index 98c632ad9b0..dbec13c0ab7 100644 --- a/rts/Lua/LuaSyncedRead.h +++ b/rts/Lua/LuaSyncedRead.h @@ -302,9 +302,8 @@ class LuaSyncedRead { static int GetRadarErrorParams(lua_State* L); - static int TraceRay(lua_State* L); //TODO: not implemented - static int TraceRayUnits(lua_State* L); //TODO: not implemented - static int TraceRayFeatures(lua_State* L); //TODO: not implemented + static int TraceRayInDirection(lua_State* L); + static int TraceRayBetweenPositions(lua_State* L); static int TraceRayGroundBetweenPositions(lua_State* L); static int TraceRayGroundInDirection(lua_State* L); };