Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/instructions/generalsx.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ cmake --build build/mingw-w64-i686 --target z_generals
- **Terminal hygiene**: No emojis or exclamation marks in terminal commands
- **C++ heritage**: Maintain consistency with surrounding legacy code patterns
- **Change annotation**: Every user-facing code change needs `// GeneralsX @keyword author DD/MM/YYYY Description` above it. Keywords: `@bugfix` / `@feature` / `@performance` / `@refactor` / `@tweak` / `@build`
- **Upstream PR attribution**: When implementing work derived from a specific upstream PR, add an adjacent comment with original author and PR link (for example: `// Upstream reference: <author>, PR #<id>` and the full GitHub URL).

### Platform Isolation Patterns

Expand Down
6 changes: 3 additions & 3 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
[submodule "GeneralsReplays"]
path = GeneralsReplays
url = https://github.com/TheSuperHackers/GeneralsReplays
[submodule "references/fighter19-dxvk-port"]
path = references/fighter19-dxvk-port
url = https://github.com/Fighter19/CnC_Generals_Zero_Hour.git
Expand All @@ -17,3 +14,6 @@
path = references/fbraz3-dxvk
url = https://github.com/fbraz3/dxvk.git
branch = generalsx-macos-v2.6
[submodule "GeneralsReplays"]
path = GeneralsReplays
url = git@github.com:fbraz3/GeneralsXReplays.git
7 changes: 7 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ include(cmake/openal.cmake)
# curl.cmake is self-guarded with if(SAGE_UPDATE_CHECK), so always safe to include.
include(cmake/curl.cmake)

# GeneralsX @feature fbraz 03/05/2026 Phase 4: Deterministic math library integration
# gamemath.cmake integrates fdlibm-based GameMath for cross-platform replay validation.
# Upstream reference: Okladnoj, PR #2670
# https://github.com/TheSuperHackers/GeneralsGameCode/pull/2670
# Self-guarded with if(SAGE_USE_DETERMINISTIC_MATH), so always safe to include.
include(cmake/gamemath.cmake)

if (IS_VS6_BUILD)
# The original max sdk does not compile against a modern compiler.
# If there is a desire to make this work, then a fixed max sdk needs to be created.
Expand Down
2 changes: 2 additions & 0 deletions Core/GameEngine/Include/GameClient/ParticleSys.h
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,8 @@ class ParticleSystemManager : public SubsystemInterface,
class ParticleSystemManagerDummy : public ParticleSystemManager
{
public:
// GeneralsX @bugfix fbraz 04/05/2026 Prevent headless replay from entering full particle update path.
virtual void update() override {}
virtual Int getOnScreenParticleCount() override { return 0; }
virtual void doParticles(RenderInfoClass &rinfo) override {}
virtual void queueParticleRender() override {}
Expand Down
6 changes: 4 additions & 2 deletions Core/GameEngine/Source/GameClient/MapUtil.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,8 @@ AsciiString MapCache::getMapExtension() const
void MapCache::writeCacheINI( const AsciiString &mapDir )
{
AsciiString filepath = mapDir;
filepath.concat('\\');
// GeneralsX @bugfix fbraz 06/05/2026 Use portable separator for host filesystem paths to avoid creating literal "\\" filenames on POSIX.
filepath.concat('/');

TheFileSystem->createDirectory(mapDir);

Expand Down Expand Up @@ -531,7 +532,8 @@ void MapCache::loadMapsFromMapCacheINI( const AsciiString &mapDir )
{
INI ini;
AsciiString fname;
fname.format("%s\\%s", mapDir.str(), m_mapCacheName);
// GeneralsX @bugfix fbraz 06/05/2026 Keep MapCache INI path consistent with writeCacheINI() on POSIX hosts.
fname.format("%s/%s", mapDir.str(), m_mapCacheName);

if (TheFileSystem->doesFileExist(fname.str()))
{
Expand Down
8 changes: 7 additions & 1 deletion Core/GameEngine/Source/GameClient/System/ParticleSys.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2998,14 +2998,20 @@ void ParticleSystemManager::update()
// TheSuperHackers @info Must increment the list iterator before potential element erasure from the list.
ParticleSystem* sys = *it++;
DEBUG_ASSERTCRASH(sys != nullptr, ("ParticleSystemManager::update: ParticleSystem is null"));
// GeneralsX @bugfix fbraz 04/05/2026 Avoid release crash when a stale null particle system entry is present.
if (sys == nullptr)
{
continue;
}

if (sys->update(m_localPlayerIndex) == false)
{
deleteInstance(sys);
}
}

const Bool drawSmudge = TheSmudgeManager && TheSmudgeManager->getHardwareSupport() && TheGlobalData->m_useHeatEffects;
// GeneralsX @bugfix fbraz 04/05/2026 Skip smudge rendering path in headless replay simulation.
const Bool drawSmudge = !TheGlobalData->m_headless && TheSmudgeManager && TheSmudgeManager->getHardwareSupport() && TheGlobalData->m_useHeatEffects;

if (drawSmudge)
{
Expand Down
139 changes: 124 additions & 15 deletions Core/GameEngine/Source/GameNetwork/GameInfo.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@

GameInfo *TheGameInfo = nullptr;

static AsciiString percentEncodeMapName(const AsciiString& mapName);
static AsciiString percentDecodeMapName(const AsciiString& encodedMapName);

// GameSlot ----------------------------------------

GameSlot::GameSlot()
Expand Down Expand Up @@ -537,7 +540,8 @@ void GameInfo::setMap( AsciiString mapName )
// directory name, we can do this since the filename
// is just the directory name with the file extention
// added onto it.
while (mapName.find('\\') != nullptr)
// GeneralsX @bugfix fbraz 05/05/2026 Handle both forward and backward separators correctly when building map-sidecar lookup paths
while (!mapName.isEmpty() && (mapName.find('\\') != nullptr || mapName.find('/') != nullptr))
{
if (!newMapName.isEmpty())
{
Expand Down Expand Up @@ -908,7 +912,8 @@ AsciiString GameInfoToAsciiString( const GameInfo *game )
// directory name, we can do this since the filename
// is just the directory name with the file extention
// added onto it.
while (mapName.find('\\') != nullptr)
// GeneralsX @bugfix fbraz 05/05/2026 Handle both forward and backward separators correctly when building map-sidecar lookup paths
while (!mapName.isEmpty() && (mapName.find('\\') != nullptr || mapName.find('/') != nullptr))
{
if (!newMapName.isEmpty())
{
Expand All @@ -922,10 +927,10 @@ AsciiString GameInfoToAsciiString( const GameInfo *game )

AsciiString optionsString;
#if RTS_GENERALS
optionsString.format("M=%2.2x%s;MC=%X;MS=%d;SD=%d;C=%d;", game->getMapContentsMask(), newMapName.str(),
optionsString.format("M=%2.2x%s;MC=%X;MS=%d;SD=%d;C=%d;", game->getMapContentsMask(), percentEncodeMapName(newMapName).str(),
game->getMapCRC(), game->getMapSize(), game->getSeed(), game->getCRCInterval());
#else
optionsString.format("US=%d;M=%2.2x%s;MC=%X;MS=%d;SD=%d;C=%d;SR=%u;SC=%u;O=%c;", game->getUseStats(), game->getMapContentsMask(), newMapName.str(),
optionsString.format("US=%d;M=%2.2x%s;MC=%X;MS=%d;SD=%d;C=%d;SR=%u;SC=%u;O=%c;", game->getUseStats(), game->getMapContentsMask(), percentEncodeMapName(newMapName).str(),
game->getMapCRC(), game->getMapSize(), game->getSeed(), game->getCRCInterval(), game->getSuperweaponRestriction(),
game->getStartingCash().countMoney(), game->oldFactionsOnly() ? 'Y' : 'N' );
#endif
Expand Down Expand Up @@ -995,6 +1000,59 @@ AsciiString GameInfoToAsciiString( const GameInfo *game )
return optionsString;
}

// GeneralsX @feature fbraz 05/05/2026 Support special characters in map names via percent encoding.
// This allows map names with brackets, spaces, and other chars commonly used by C&C community mapmakers.
static AsciiString percentEncodeMapName(const AsciiString& mapName)
{
// Characters that MUST be encoded in replay header field values:
// % (0x25) - escape indicator; MUST be encoded first to avoid double-encoding
// [ (0x5B) - INI section marker
// ] (0x5D) - INI section marker
// ; (0x3B) - field separator in replay header
// = (0x3D) - key-value separator in replay header
// Space (0x20) - for safety with paths
AsciiString result;
const char* src = mapName.str();

for (int i = 0; src[i] != '\0'; ++i) {
unsigned char c = (unsigned char)src[i];
switch (c) {
case '%': result.concat("%25"); break; // Escape indicator FIRST
case '[': result.concat("%5B"); break;
case ']': result.concat("%5D"); break;
case ';': result.concat("%3B"); break;
case '=': result.concat("%3D"); break;
case ' ': result.concat("%20"); break;
default: result.concat(src[i]); break;
}
}
return result;
}

// GeneralsX @feature fbraz 05/05/2026 Decode percent-encoded map names (inverse of percentEncodeMapName).
static AsciiString percentDecodeMapName(const AsciiString& encodedMapName)
{
AsciiString result;
const char* src = encodedMapName.str();
int len = encodedMapName.getLength();

for (int i = 0; i < len; ++i) {
if (src[i] == '%' && i + 2 < len) {
// Parse two hex digits
char hex[3] = { src[i+1], src[i+2], '\0' };
int value = -1;
if (sscanf(hex, "%x", &value) == 1 && value >= 0 && value <= 255) {
result.concat((char)value);
i += 2; // Skip the two hex digits
continue;
}
}
// If not a valid escape sequence, just copy the character
result.concat(src[i]);
}
return result;
}

static Int grabHexInt(const char *s)
{
char tmp[5] = "0xff";
Expand Down Expand Up @@ -1072,29 +1130,45 @@ Bool ParseAsciiStringToGameInfo(GameInfo *game, AsciiString options)
break;
}
mapContentsMask = grabHexInt(val.str());
AsciiString tempstr;

AsciiString portableMapPath = val.str() + 2;
// GeneralsX @feature fbraz 05/05/2026 Decode percent-encoded map names to support special characters (brackets, spaces, etc).
portableMapPath = percentDecodeMapName(portableMapPath);

AsciiString legacyPortableMapPath;
AsciiString token;
tempstr = val.str()+2;
AsciiString tempstr = portableMapPath;
tempstr.nextToken(&token, "\\/");
while (!tempstr.isEmpty())
{
mapName.concat(token);
mapName.concat('\\');
legacyPortableMapPath.concat(token);
legacyPortableMapPath.concat('\\');
tempstr.nextToken(&token, "\\/");
}
mapName.concat(token);
mapName.concat('\\');
mapName.concat(token);
mapName.concat('.');
mapName.concat(TheMapCache->getMapExtension());
AsciiString realMapName = TheGameState->portableMapPathToRealMapPath(mapName);
legacyPortableMapPath.concat(token);
legacyPortableMapPath.concat('\\');
legacyPortableMapPath.concat(token);
legacyPortableMapPath.concat('.');
legacyPortableMapPath.concat(TheMapCache->getMapExtension());

AsciiString realMapName = TheGameState->portableMapPathToRealMapPath(legacyPortableMapPath);

// GeneralsX @bugfix fbraz 05/05/2026 Recover from malformed replay map fields generated from mixed separator custom map paths.
if (realMapName.isEmpty())
{
AsciiString flatPortableMapPath = portableMapPath;
flatPortableMapPath.concat('.');
flatPortableMapPath.concat(TheMapCache->getMapExtension());
realMapName = TheGameState->portableMapPathToRealMapPath(flatPortableMapPath);
}

if (realMapName.isEmpty())
{
// TheSuperHackers @security slurmlord 18/06/2025 As the map file name/path from the AsciiString failed to normalize,
// in other words is bogus and points outside of the approved target directory for maps, avoid an arbitrary file overwrite vulnerability
// if the save or network game embeds a custom map to store at the location, by flagging the options as not OK and rejecting the game.
optionsOk = FALSE;
DEBUG_LOG(("ParseAsciiStringToGameInfo - saw bogus map name ('%s'); quitting", mapName.str()));
DEBUG_LOG(("ParseAsciiStringToGameInfo - saw bogus map name ('%s'); quitting", legacyPortableMapPath.str()));
break;
}
mapName = realMapName;
Expand Down Expand Up @@ -1487,6 +1561,41 @@ Bool ParseAsciiStringToGameInfo(GameInfo *game, AsciiString options)
// In Generals they never were.
if (optionsOk && sawMap && sawMapCRC && sawMapSize && sawSeed && sawSlotlist && sawCRC)
{
// GeneralsX @bugfix fbraz 05/05/2026 Recover old malformed custom-map replay headers by selecting map from cache via CRC/size.
if ((!mapName.isEmpty()) && TheMapCache->findMap(mapName) == nullptr)
{
const MapMetaData* crcMatch = nullptr;
const MapMetaData* exactMatch = nullptr;

for (std::map<AsciiString, MapMetaData>::const_iterator it = TheMapCache->begin(); it != TheMapCache->end(); ++it)
{
if (it->second.m_CRC != mapCRC)
{
continue;
}

if (crcMatch == nullptr)
{
crcMatch = &it->second;
}

if (it->second.m_filesize == mapSize)
{
exactMatch = &it->second;
break;
}
}

const MapMetaData* selected = (exactMatch != nullptr) ? exactMatch : crcMatch;
if (selected != nullptr)
{
mapName = selected->m_fileName;
DEBUG_LOG(("ParseAsciiStringToGameInfo - map path recovered by CRC: %s", mapName.str()));
// GeneralsX @bugfix fbraz 05/05/2026 Use stderr so the CRC-fallback resolution is visible in headless replay runs where DEBUG_LOG may be suppressed.
fprintf(stderr, "[GeneralsX] Replay map resolved via CRC fallback: CRC=0x%08X size=%d -> '%s'\n", mapCRC, mapSize, mapName.str());
}
}

// We were setting the Global Data directly here, but Instead, I'm now
// first setting the data in game. We'll set the global data when
// we start a game.
Expand Down
13 changes: 8 additions & 5 deletions Core/Libraries/Include/Lib/BaseType.h
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,10 @@ struct Coord2D
return x == value && y == value;
}

Real length() const { return (Real)sqrt( x*x + y*y ); }
// GeneralsX @refactor fbraz 03/05/2026 Route BaseType length math through shared Sqrt gateway.
// Upstream reference: Okladnoj, PR #2670
// https://github.com/TheSuperHackers/GeneralsGameCode/pull/2670
Real length() const { return (Real)Sqrt( x*x + y*y ); }
Real lengthSqr() const { return x*x + y*y; }

void normalize()
Expand All @@ -273,7 +276,7 @@ inline Real Coord2D::toAngle() const
vector.x = x;
vector.y = y;

Real dist = (Real)sqrt(vector.x * vector.x + vector.y * vector.y);
Real dist = (Real)Sqrt(vector.x * vector.x + vector.y * vector.y);

// normalize
if (dist == 0.0f)
Expand Down Expand Up @@ -340,7 +343,7 @@ struct ICoord2D
return x == value && y == value;
}

Int length() const { return (Int)sqrt( (double)(x*x + y*y) ); }
Int length() const { return (Int)Sqrt( (double)(x*x + y*y) ); }
};

struct Region2D
Expand Down Expand Up @@ -388,7 +391,7 @@ struct Coord3D
{
Real x, y, z;

Real length() const { return (Real)sqrt( x*x + y*y + z*z ); }
Real length() const { return (Real)Sqrt( x*x + y*y + z*z ); }
Real lengthSqr() const { return ( x*x + y*y + z*z ); }

void normalize()
Expand Down Expand Up @@ -476,7 +479,7 @@ struct ICoord3D
{
Int x, y, z;

Int length() const { return (Int)sqrt( (double)(x*x + y*y + z*z) ); }
Int length() const { return (Int)Sqrt( (double)(x*x + y*y + z*z) ); }

void zero()
{
Expand Down
4 changes: 4 additions & 0 deletions Core/Libraries/Include/Lib/trig.h
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,7 @@ Real Cos(Real);
Real Tan(Real);
Real ACos(Real);
Real ASin(Real x);
// GeneralsX @feature fbraz 03/05/2026 Add shared Sqrt gateway to route core geometry math.
// Upstream reference: Okladnoj, PR #2670
// https://github.com/TheSuperHackers/GeneralsGameCode/pull/2670
double Sqrt(double x);
15 changes: 11 additions & 4 deletions Core/Libraries/Source/WWVegas/WW3D2/dx8wrapper.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2719,6 +2719,13 @@ IDirect3DTexture8 * DX8Wrapper::_Create_DX8_Texture
DX8_THREAD_ASSERT();
DX8_Assert();
IDirect3DTexture8 *texture = nullptr;
IDirect3DDevice8 *d3d_device = DX8Wrapper::_Get_D3D_Device8();

// GeneralsX @bugfix fbraz 04/05/2026 Avoid null device calls during headless replay texture requests.
if (d3d_device == nullptr)
{
return nullptr;
}

// Paletted textures not supported!
WWASSERT(format!=D3DFMT_P8);
Expand All @@ -2730,7 +2737,7 @@ IDirect3DTexture8 * DX8Wrapper::_Create_DX8_Texture
// which case we return null.
if (rendertarget) {
unsigned ret=D3DXCreateTexture(
DX8Wrapper::_Get_D3D_Device8(),
d3d_device,
width,
height,
mip_level_count,
Expand All @@ -2754,7 +2761,7 @@ IDirect3DTexture8 * DX8Wrapper::_Create_DX8_Texture
WW3D::_Invalidate_Mesh_Cache();

ret=D3DXCreateTexture(
DX8Wrapper::_Get_D3D_Device8(),
d3d_device,
width,
height,
mip_level_count,
Expand Down Expand Up @@ -2785,7 +2792,7 @@ IDirect3DTexture8 * DX8Wrapper::_Create_DX8_Texture
// However, it seems to happen sometimes when there are a lot of textures in memory and so
// if it happens we'll release assets and try again (anything is better than crashing).
unsigned ret=D3DXCreateTexture(
DX8Wrapper::_Get_D3D_Device8(),
d3d_device,
width,
height,
mip_level_count,
Expand All @@ -2804,7 +2811,7 @@ IDirect3DTexture8 * DX8Wrapper::_Create_DX8_Texture
WW3D::_Invalidate_Mesh_Cache();

ret=D3DXCreateTexture(
DX8Wrapper::_Get_D3D_Device8(),
d3d_device,
width,
height,
mip_level_count,
Expand Down
Loading
Loading