diff --git a/CREDITS.md b/CREDITS.md
index ecf82be902..23eaba7f0c 100644
--- a/CREDITS.md
+++ b/CREDITS.md
@@ -736,4 +736,6 @@ This page lists all the individual contributions to the project by their author.
- **Damfoos** - extensive and thorough testing
- **Dmitry Volkov** - extensive and thorough testing
- **Rise of the East community** - extensive playtesting of in-dev features
-- **11EJDE11** - Prevent mpdebug number from being drawn when visibility toggled off
+- **11EJDE11**:
+ - Prevent mpdebug number from being drawn when visibility toggled off
+ - Allow customising FPS values
diff --git a/docs/Miscellanous.md b/docs/Miscellanous.md
index f953b34bb8..70f6593882 100644
--- a/docs/Miscellanous.md
+++ b/docs/Miscellanous.md
@@ -95,26 +95,9 @@ InsigniaType.PassengersN= ; InsigniaType
## Game Speed
-### Single player game speed
+### Campaign default game speed
- It is now possible to change the default (GS4/Fast/30FPS) campaign game speed with `CampaignDefaultGameSpeed`.
-- It is now possible to change the *values* of single player game speed, by inputing a pair of values. This feature must be enabled with `CustomGS=true`. **Only values between 10 and 60 FPS can be consistently achieved.**
- - Custom game speed is achieved by periodically manipulating the delay between game frames, thus increasing or decreasing FPS.
- - `CustomGSN.ChangeInterval` describes the frame interval between applying the effect. A value of 2 means "every other frame", 3 means "every 3 frames" etc. Increase of speedup/slowdown is approximately logarithmic.
- - `CustomGSN.ChangeDelay` sets the delay (game speed number) to use every `CustomGSN.ChangeInterval` frames.
- - `CustomGSN.DefaultDelay` sets the delay (game speed number) to use on other frames.
- - Using game speed 6 (Fastest) in either `CustomGSN.ChangeDelay` or `CustomGSN.DefaultDelay` allows to set FPS above 60.
- - **However, the resulting FPS may vary on different machines.**
-
-In `rulesmd.ini`:
-```ini
-[General]
-CustomGS=false ; boolean
-CustomGSN.ChangeInterval=-1 ; integer >= 1
-CustomGSN.ChangeDelay=N ; integer between 0 and 6
-CustomGSN.DefaultDelay=N ; integer between 0 and 6
-; where N = 0, 1, 2, 3, 4, 5, 6
-```
In `RA2MD.INI`:
```ini
@@ -122,68 +105,26 @@ In `RA2MD.INI`:
CampaignDefaultGameSpeed=4 ; integer
```
-```{note}
-Currently there is no way to set desired FPS directly. Use the generator below to get required values. The generator supports values from 10 to 60.
-```
-
-```{dropdown} Click to show the generator
-Enter desired FPS
-
-
-
+### Custom game speed
-Results (remember to replace N with your game speed number!):
+- Each of the 7 game speed slider positions (GameSpeed 0-6) can have a custom target FPS set independently. A value of `0` keeps that position at its vanilla FPS.
+- When used, **game speeds are unified across all game modes** - skirmish, campaign, and multiplayer all use the same FPS values for each speed position.
+- Per-speed keys (`CustomGameSpeedFPS.N`) set individual positions.
+- Practical maximum is ~1000 FPS (limited by `timeGetTime()` resolution).
-
-
-
-
+In `rulesmd.ini`:
+```ini
+[General]
+EnableCustomFPS=false ; boolean (default: false)
+CustomGameSpeedFPS.0=0 ; integer, GameSpeed 0 target FPS (default: 0 = vanilla 60 FPS)
+CustomGameSpeedFPS.1=0 ; integer, GameSpeed 1 target FPS (default: 0 = vanilla 45 FPS)
+CustomGameSpeedFPS.2=0 ; integer, GameSpeed 2 target FPS (default: 0 = vanilla 30 FPS)
+CustomGameSpeedFPS.3=0 ; integer, GameSpeed 3 target FPS (default: 0 = vanilla 20 FPS)
+CustomGameSpeedFPS.4=0 ; integer, GameSpeed 4 target FPS (default: 0 = vanilla 15 FPS)
+CustomGameSpeedFPS.5=0 ; integer, GameSpeed 5 target FPS (default: 0 = vanilla 12 FPS)
+CustomGameSpeedFPS.6=0 ; integer, GameSpeed 6 target FPS (default: 0 = vanilla 10 FPS)
```
-
## INI
diff --git a/docs/User-Interface.md b/docs/User-Interface.md
index 1801e5c9b8..bd1329caaa 100644
--- a/docs/User-Interface.md
+++ b/docs/User-Interface.md
@@ -288,9 +288,8 @@ ShowPlacementPreview=yes ; boolean
### Real time timers
- Timers can now display values in real time, taking game speed into account. This can be enabled with `RealTimeTimers=true`.
-- By default, time is calculated relative to desired framerate. Enabling `RealTimeTimers.Adaptive` (always true for unlimited FPS and custom speeds) will calculate time relative to *current* FPS, accounting for lag.
- - When playing with unlimited FPS (or custom speed above 60 FPS), the timers might constantly change value because of the unstable nature.
-- This option respects custom game speeds.
+- By default, time is calculated relative to the desired framerate for the current GameSpeed. Enabling `RealTimeTimers.Adaptive` (always forced on when GameSpeed is 0/Fastest or a custom game speed is active) will calculate time relative to *current* FPS, accounting for lag.
+ - When playing with a custom FPS above 60, the timers might constantly fluctuate due to frame-to-frame variance.
- This behavior is designed to be toggleable by users. For now you can only do that externally via client or manually.
diff --git a/docs/Whats-New.md b/docs/Whats-New.md
index 975aeed572..082ef34c03 100644
--- a/docs/Whats-New.md
+++ b/docs/Whats-New.md
@@ -32,6 +32,7 @@ You can use the migration utility (can be found on [Phobos supplementaries repo]
#### From post-0.3 devbuilds
+- `CustomGS` and related keys (`CustomGSN.ChangeInterval`, `CustomGSN.ChangeDelay`, `CustomGSN.DefaultDelay`) have been deprecated and replaced by the new direct FPS control system. Use `EnableCustomFPS=true` and `CustomGameSpeedFPS.N` keys instead to set target FPS for each game speed position. The new system works across skirmish, campaign, and multiplayer modes. See [Custom game speed](Miscellanous.md#custom-game-speed) for details.
- Ivan bombs no longer automatically center on building when attached. Set `[CombatDamage] -> IvanBombAttachToCenter` to true to restore this behaviour. Due to technical constraints this cannot be customized per WeaponType.
- `AlternateFLH` no longer affects vehicle passengers by default. To re-enable it, set `AlternateFLH.ApplyVehicle=true` on the transport unit.
- Parsing priority of `ShowBriefing` and `BriefingTheme` between map file and `missionmd.ini` has been switched (from latter taking priority over former to vice-versa) due to technical limitations and compatibility issues with spawner DLL.
@@ -535,6 +536,7 @@ New:
- Option to scale `PowerSurplus` setting if enabled to current power drain with `PowerSurplus.ScaleToDrainAmount` (by Starkku)
- Global default value for `DefaultToGuardArea` (by TaranDahl)
- [Weapon range finding in cylinder](New-or-Enhanced-Logics.md#range-finding-in-cylinder) (by TaranDahl)
+- [Enhanced custom game speed with direct FPS control and multiplayer support](Miscellanous.md#custom-game-speed) (by 11EJDE11)
Vanilla fixes:
- Fixed sidebar not updating queued unit numbers when adding or removing units when the production is on hold (by CrimRecya)
diff --git a/src/Misc/Hooks.Gamespeed.cpp b/src/Misc/Hooks.Gamespeed.cpp
index 9a64c6afc8..c88f0889c5 100644
--- a/src/Misc/Hooks.Gamespeed.cpp
+++ b/src/Misc/Hooks.Gamespeed.cpp
@@ -1,97 +1,115 @@
#include
-#include
+#include
+#include
#include
#include
-namespace GameSpeedTemp
-{
- static int counter = 0;
-}
-
DEFINE_HOOK(0x69BAE7, SessionClass_Resume_CampaignGameSpeed, 0xA)
{
GameOptionsClass::Instance.GameSpeed = Phobos::Config::CampaignDefaultGameSpeed;
return 0x69BAF1;
}
-DEFINE_REFERENCE(CDTimerClass, FrameTimer, 0x887348)
+// For custom game speeds:
+// Add to rulesmd.ini under [General]:
+// EnableCustomFPS=yes ; Enable/disable custom FPS
+// CustomGameSpeedFPS.0=120 ; Per-speed FPS. 0 = vanilla. Vanilla: 0=60, 1=45, 2=30, 3=20, 4=15, 5=12, 6=10
+//
+// -Each speed slot with a non-zero CustomGameSpeedFPS.N runs at that target FPS
+// -Slots with 0 (or unset) use vanilla FPS for that position
+// -Practical max is ~1000 FPS (timeGetTime() resolution)
+
+// Queue_AI_Multiplayer
+// Patch v26 to INT_MAX disables the 60fps cap on multiplayer.
+DEFINE_PATCH(0x647C28, 0xBE, 0xFF, 0xFF, 0xFF, 0x7F); // mov esi, INT_MAX
-DEFINE_HOOK(0x55E160, SyncDelay_Start, 0x6)
+// Queue_AI_Multiplayer
+// Override the GameSpeed-to-fps calculation in multiplayer.
+DEFINE_HOOK(0x647C4D, Queue_AI_Multiplayer_CustomFPSCalculation, 0x1F)
{
- //DEFINE_NONSTATIC_REFERENCE(CDTimerClass, NFTTimer, 0x887328);
- if (!Phobos::Misc::CustomGS || SessionClass::IsMultiplayer())
- return 0;
- if ((Phobos::Misc::CustomGS_ChangeInterval[FrameTimer.TimeLeft] > 0)
- && (GameSpeedTemp::counter % Phobos::Misc::CustomGS_ChangeInterval[FrameTimer.TimeLeft] == 0))
+ int gameSpeed = GameOptionsClass::Instance.GameSpeed;
+ int calculatedFPS;
+
+ if (Phobos::Misc::EnableCustomFPS && Phobos::Misc::CustomGameSpeedFPS[gameSpeed] > 0)
+ {
+ calculatedFPS = Phobos::Misc::CustomGameSpeedFPS[gameSpeed];
+ }
+ else if (gameSpeed == 0)
{
- FrameTimer.TimeLeft = Phobos::Misc::CustomGS_ChangeDelay[FrameTimer.TimeLeft];
- GameSpeedTemp::counter = 1;
+ // Vanilla: GameSpeed 0 = 60 FPS
+ calculatedFPS = 60;
+ }
+ else if (gameSpeed == 1)
+ {
+ // Vanilla: GameSpeed 1 = 45 FPS
+ calculatedFPS = 45;
}
else
{
- FrameTimer.TimeLeft = Phobos::Misc::CustomGS_DefaultDelay[FrameTimer.TimeLeft];
- GameSpeedTemp::counter++;
+ // Vanilla: GameSpeed 2+ = 60 / GameSpeed
+ calculatedFPS = 60 / gameSpeed;
}
- return 0;
-}
+ R->EAX(calculatedFPS);
-DEFINE_HOOK(0x55E33B, SyncDelay_End, 0x6)
-{
- if (Phobos::Misc::CustomGS && SessionClass::IsSingleplayer())
- FrameTimer.TimeLeft = GameOptionsClass::Instance.GameSpeed;
- return 0;
+ return 0x647C6C;
}
-// note: currently vanilla code, doesn't do anything, changing PrecalcDesiredFrameRate doesn't effect anything either
-/*
-void SetNetworkFrameRate()
+// Hook MainLoop skirmish/campaign FPS calculation
+// The normal route FrameTimer.TimeLeft rounds to 0 for >60 FPS (tick-based timeGetTime >> 4),
+// NetworkFrameTimer (ms-based timeGetTime) provides the frame timing that SyncDelay's NetworkFrameTimer loop uses.
+// We need to set up NetworkFrameTimer like multiplayer mode does for >60 FPS support.
+DEFINE_HOOK(0x55D7B6, MainLoop_SkirmishFPSFix, 0xC)
{
- DEFINE_REFERENCE(int, PrecalcDesiredFrameRate, 0xA8B550u)
- switch (GameOptionsClass::Instance.GameSpeed)
+ const DWORD timerValue = R->ECX();
+ const int gameSpeed = R->ESI();
+
+ const bool shouldUseCustomFPS = Phobos::Misc::EnableCustomFPS
+ && Phobos::Misc::CustomGameSpeedFPS[gameSpeed] > 0
+ && (SessionClass::IsSkirmish() || SessionClass::IsCampaign());
+
+ if (!shouldUseCustomFPS)
{
- case 0:
- Game::Network.MaxAhead = 40;
- PrecalcDesiredFrameRate = 60;
- Game::Network.FrameSendRate = 10;
- break;
- case 1:
- Game::Network.MaxAhead = 40;
- PrecalcDesiredFrameRate = 45;
- Game::Network.FrameSendRate = 10;
- break;
- case 2:
- Game::Network.MaxAhead = 30;
- PrecalcDesiredFrameRate = 30;
- break;
- case 3:
- Game::Network.MaxAhead = 20;
- PrecalcDesiredFrameRate = 20;
- break;
- case 4:
- Game::Network.MaxAhead = 20;
- PrecalcDesiredFrameRate = 15;
- break;
- case 5:
- Game::Network.MaxAhead = 20;
- PrecalcDesiredFrameRate = 12;
- break;
- default:
- Game::Network.MaxAhead = 10;
- PrecalcDesiredFrameRate = 10;
- break;
+ // Use vanilla behavior
+ Unsorted::GameFrameTimer.CurrentTime = timerValue;
+ Unsorted::GameFrameTimer.TimeLeft = gameSpeed;
+ return 0x55D7C2;
}
-}
-DEFINE_HOOK(0x5C49A7, MPCooperative_5C46E0_FPS, 0x9)
-{
- SetNetworkFrameRate();
- return 0x5C4A40;
+ const int customFPS = Phobos::Misc::CustomGameSpeedFPS[gameSpeed];
+
+ // calc frame timings
+ const int targetFrameDelayTicks = 60 / customFPS;
+ const int targetFrameTimeMs = std::max(1, 1000 / customFPS); // 1ms floor = ~1000 FPS ceiling
+
+ const DWORD currentTime = timeGetTime();
+
+ // just in case
+ Unsorted::GameFrameTimer.CurrentTime = timerValue;
+ Unsorted::GameFrameTimer.TimeLeft = targetFrameDelayTicks;
+
+ // SyncDelay compares elapsed time since CurrentTime against TimeLeft.
+ Unsorted::NetworkFrameTimer.StartTime = currentTime;
+ Unsorted::NetworkFrameTimer.CurrentTime = currentTime;
+ Unsorted::NetworkFrameTimer.TimeLeft = targetFrameTimeMs;
+
+ // "Requested FPS"
+ SessionClass::Instance.DesiredFrameRate = customFPS;
+
+ return 0x55D7C2; // Past the two MOVs we replaced
}
-DEFINE_HOOK(0x794F7E, Start_Game_Now_FPS, 0x8)
+// SyncDelay
+// Redirect skirmish/campaign to the NetworkFrameTimer path
+DEFINE_HOOK(0x55E1B6, SyncDelay_RedirectSkirmishToNetworkFrameTimer, 0x6)
{
- SetNetworkFrameRate();
- return 0x79501F;
+ if (SessionClass::IsSkirmish() || SessionClass::IsCampaign())
+ {
+ if (Phobos::Misc::EnableCustomFPS && Phobos::Misc::CustomGameSpeedFPS[GameOptionsClass::Instance.GameSpeed] > 0)
+ return 0x55E1BC; // Custom FPS: use NetworkFrameTimer path like multiplayer
+
+ return 0x55E2B4; // Vanilla FrameTimer path
+ }
+
+ return 0x55E1BC;
}
-*/
diff --git a/src/Misc/Hooks.Timers.cpp b/src/Misc/Hooks.Timers.cpp
index df650e5b11..2436c31396 100644
--- a/src/Misc/Hooks.Timers.cpp
+++ b/src/Misc/Hooks.Timers.cpp
@@ -18,7 +18,7 @@ DEFINE_HOOK(0x6D4B50, PrintTimerOnTactical_Start, 0x6)
if (Phobos::Config::RealTimeTimers_Adaptive
|| GameOptionsClass::Instance.GameSpeed == 0
- || (Phobos::Misc::CustomGS && !SessionClass::IsMultiplayer()))
+ || (Phobos::Misc::EnableCustomFPS && Phobos::Misc::CustomGameSpeedFPS[GameOptionsClass::Instance.GameSpeed] > 0))
{
value = (int)((double)value / (std::max((double)FPSCounter::CurrentFrameRate, 1.0) / 15.0));
return 0;
diff --git a/src/Misc/PhobosToolTip.cpp b/src/Misc/PhobosToolTip.cpp
index aa30b9692c..ce4d031d37 100644
--- a/src/Misc/PhobosToolTip.cpp
+++ b/src/Misc/PhobosToolTip.cpp
@@ -109,7 +109,7 @@ inline static int TickTimeToSeconds(int tickTime)
if (Phobos::Config::RealTimeTimers_Adaptive
|| GameOptionsClass::Instance.GameSpeed == 0
- || (Phobos::Misc::CustomGS && !SessionClass::IsMultiplayer()))
+ || (Phobos::Misc::EnableCustomFPS && Phobos::Misc::CustomGameSpeedFPS[GameOptionsClass::Instance.GameSpeed] > 0))
{
return tickTime / std::max((int)FPSCounter::CurrentFrameRate, 1);
}
diff --git a/src/Phobos.INI.cpp b/src/Phobos.INI.cpp
index c9bc501bdc..6e584215f0 100644
--- a/src/Phobos.INI.cpp
+++ b/src/Phobos.INI.cpp
@@ -77,10 +77,8 @@ bool Phobos::Config::ShowFlashOnSelecting = false;
bool Phobos::Config::UnitPowerDrain = false;
int Phobos::Config::SuperWeaponSidebar_RequiredSignificance = 0;
-bool Phobos::Misc::CustomGS = false;
-int Phobos::Misc::CustomGS_ChangeInterval[7] = { -1, -1, -1, -1, -1, -1, -1 };
-int Phobos::Misc::CustomGS_ChangeDelay[7] = { 0, 1, 2, 3, 4, 5, 6 };
-int Phobos::Misc::CustomGS_DefaultDelay[7] = { 0, 1, 2, 3, 4, 5, 6 };
+bool Phobos::Misc::EnableCustomFPS = false;
+int Phobos::Misc::CustomGameSpeedFPS[7] = { 0, 0, 0, 0, 0, 0, 0 };
DEFINE_HOOK(0x5FACDF, OptionsClass_LoadSettings_LoadPhobosSettings, 0x5)
{
@@ -250,26 +248,16 @@ DEFINE_HOOK(0x52D21F, InitRules_ThingsThatShouldntBeSerailized, 0x6)
Phobos::Config::ArtImageSwap = pINI_RULESMD->ReadBool(GameStrings::General, "ArtImageSwap", false);
Phobos::Config::UnitPowerDrain = pINI_RULESMD->ReadBool(GameStrings::General, "UnitPowerDrain", false);
- Phobos::Misc::CustomGS = pINI_RULESMD->ReadBool(GameStrings::General, "CustomGS", false);
+ // Custom FPS settings
+ Phobos::Misc::EnableCustomFPS = pINI_RULESMD->ReadBool(GameStrings::General, "EnableCustomFPS", false);
- char tempBuffer[26];
- for (size_t i = 0; i <= 6; ++i)
+ char tempBuffer[32];
+ for (int i = 0; i < 7; ++i)
{
- int temp;
- _snprintf_s(tempBuffer, sizeof(tempBuffer), "CustomGS%d.ChangeDelay", 6 - i);
- temp = pINI_RULESMD->ReadInteger(GameStrings::General, tempBuffer, -1);
- if (temp >= 0 && temp <= 6)
- Phobos::Misc::CustomGS_ChangeDelay[i] = 6 - temp;
-
- _snprintf_s(tempBuffer, sizeof(tempBuffer), "CustomGS%d.DefaultDelay", 6 - i);
- temp = pINI_RULESMD->ReadInteger(GameStrings::General, tempBuffer, -1);
- if (temp >= 1)
- Phobos::Misc::CustomGS_DefaultDelay[i] = 6 - temp;
-
- _snprintf_s(tempBuffer, sizeof(tempBuffer), "CustomGS%d.ChangeInterval", 6 - i);
- temp = pINI_RULESMD->ReadInteger(GameStrings::General, tempBuffer, -1);
- if (temp >= 1)
- Phobos::Misc::CustomGS_ChangeInterval[i] = temp;
+ _snprintf_s(tempBuffer, sizeof(tempBuffer), "CustomGameSpeedFPS.%d", i);
+ Phobos::Misc::CustomGameSpeedFPS[i] = pINI_RULESMD->ReadInteger(GameStrings::General, tempBuffer, Phobos::Misc::CustomGameSpeedFPS[i]);
+ if (Phobos::Misc::CustomGameSpeedFPS[i] < 0)
+ Phobos::Misc::CustomGameSpeedFPS[i] = 0;
}
if (pINI_RULESMD->ReadBool(GameStrings::General, "FixTransparencyBlitters", true))
diff --git a/src/Phobos.h b/src/Phobos.h
index 2e794bd388..4fdbcb4cb0 100644
--- a/src/Phobos.h
+++ b/src/Phobos.h
@@ -117,10 +117,8 @@ class Phobos
class Misc
{
public:
- static bool CustomGS;
- static int CustomGS_ChangeInterval[7];
- static int CustomGS_ChangeDelay[7];
- static int CustomGS_DefaultDelay[7];
+ static int CustomGameSpeedFPS[7];
+ static bool EnableCustomFPS;
};
class Optimizations