Tom Clancy's EndWar

Tom Clancy's EndWar

An Explanation to EndWar's Locked Frame Rate
Quick disclaimers: This project is still a WORK IN PROGRESS and NOT DEFINITIVE. All findings are not final and not tested on computers of others. THERE IS A POSSIBILITY THAT THIS MIGHT NOT WORK ON YOUR COMPUTER! I am going at my own pace, whenever I feel like it. I am not a programmer and will never be one. Code looks like gibberish to me. I have used AI at every single step. The specific model used is ChatGPT 6.0 Astra (if that matters). I am publishing this to bring awareness so that anyone who is interested can take it and run their own experiments.

Since I have not found anything about this anywhere, be it on Steam or some Internet forum, I present to you the answer to End War's frame rate question:

End War's Timing Field
To begin, End War's game engine is coded to run at 30 intervels for each second, which is not exactly frames per second but nevertheless are interlinked. Within the game engine, there is a clock function that the engine reads constantly, which operates every 0.033333335 seconds, or 1/30th of a second. This function affects every process the game executes whenever you are playing it. Its effect forces every game simulation (i.e. unit movement, damage, animations, basically everything the game does) to adhere by this clock. The idea is a waiting loop; easy or simple processes are forced to wait for the longer, more complicated processes to finish. Meanwhile, the CPU checks for the loop to conclude then simulates the results together, all at once. Therefore, End War runs at a constant 30 FPS because the game engine limits its own processing ability to 1/30th of a second. Henceforth, it will only complete 30 intervals of game simulations in a second, lining up with 30 frames per seconds. Changing the values for FPSSycnInterval, FPSSycnMinFPS and FPSSycnMaxFPS in the DefaultEngine.ini and PCOfEngine.ini files does not change the waiting loop's clock. This loop is the IMPERATIVE function which commands the game engine. Simply put, the game runs at 30 FPS because the entire application runs at 30 Hertz.

This understanding was arrived to by having ChatGPT first capture the processes the executable requests in the computer's RAM, using a combination of Python scripts and Window's PresentMon. After cross referencing numorous lines of code, ChatGPT determined that the CPU thread running the game's code needed a thorough analysis using a debugger. So I proceeded to install x32dbg (End War is 32 bits thus needs a 32 bit debugger) and Ghidra. The first debugger, x32dbg, helped with the intial stages of discovering the application's functions, however, I started having issues with the debugger terminating/not working whenever alt tabbing to and from the game. Because those issue started hindering the discovery process, ChatGPT proposed to copy End War's code into a .bin file and run it through Ghidra. Ghidra allowed a deeper analysis of functions and their behaviors.
Eventually, an address - B+0x1BCD140 (B is the assigned address of the executable, given by your computer. 0x1BCD140 is a specific direction in the game engine) was located, leading to an engine object. When analized, its instructions basically say "read and load the bytes (a set of computer numbers and letters equaling a value) at this location - E+0x49C" (E = B+0x1BCD140, 0x49C = An offset of 1,180 bytes). This address was surrounded by functions that appeared to control an interval. That was the first hint to a theory that was proposed during the discovery process. When looking into and reading the bytes, they translated to 0.033333335, matching with the "locked" 30Hz frame rate, which was the second hint.
Here is how this looks inside the computer:
1. When EndWar.exe is launched, it gets loaded into the computer's available RAM and assigned a random address: B
2. Adding the executable's address (B) and the pointer (0x1BCD140) gives the location of the engine object's address (E)
3. The identified timing field bytes is located at this location: E+0x49C
4. 0x49C equals 1,180 bytes and the formula says that the relevant data is 1,180 bytes in front
5. As the game is running, the enigine object reads 89 88 08 3D, translating to 0.033333335 in IEEE 754 format, little-endian order
6. Boom! you get a waiting loop of 1/30th of a second, equaling to 30 intervals per second

All of the investigative work and heavy thinking was performed by ChatGPT. Every piece of data (screenshots of code lines, .bin files) was fed to and digested by ChatGPT. All I did was run End War and debuggers per the instructions. Bare in mind, I and ChatGPT found no source code, developer commentary or design documents of any kind relating to this game, and Ubisoft is down in the gutter. This was essentially done the hard way.
< >
Showing 1-3 of 3 comments
Combining both hints, the neighboring interval function and the byte read, it was determined that these functions and bytes needed a targeted experiment to confirm if this indeed was the waiting loop or some other function. Culminating in this:

endwar_clock.py
This is a Python script that targets the bytes 89 88 08 3D, located at E+0x49C, which are loaded and stored in the computer's RAM. It does not permanently change and save the new bytes in the game's executable (for a good reason):

"""EndWar 1/60-second live time-step experiment, v0.1.0. Windows x64 Python. No debugger, injection, executable edits, or background rewriting. """ import argparse import ctypes as C from ctypes import wintypes as W import hashlib from pathlib import Path import struct import sys import time BUILD_SHA256 = 'a0ffef7b68d009f5326c874dcc050278a2d0725c16ec258d0ffd4ffbcc657d4e' POINTER_RVA = 0x1BCD140 VTABLE_RVA = 0x154D5FC INTERVAL_OFFSET = 0x49C ORIGINAL = struct.pack('<f', 1 / 30) TARGET = struct.pack('<f', 1 / 60) SIGNATURES = { 0x142804: bytes.fromhex('f7414800010000'), 0x142817: bytes.fromhex('d9819c040000'), 0x142CBA: bytes.fromhex('d9819c040000'), 0x142DB8: bytes.fromhex('2bc73bc57316'), } class Refusal(Exception): pass def inspect_memory(mem, base, require_fixed=True): if mem.read(base, 2) != b'MZ': raise Refusal('Executable header mismatch.') pe = mem.u32(base + 0x3C) if pe > 0x1000 or mem.read(base + pe, 6) != b'PE\0\0\x4c\x01': raise Refusal('Expected the verified 32-bit EndWar executable.') for rva, signature in SIGNATURES.items(): if mem.read(base + rva, len(signature)) != signature: raise Refusal('Timing code does not match the captured build; no write performed.') obj = mem.u32(base + POINTER_RVA) if obj < 0x10000 or obj > 0xFFFFF000 or obj % 4: raise Refusal('Engine object is not ready. Load a single-player skirmish first.') if mem.u32(obj) != base + VTABLE_RVA: raise Refusal('Engine object type differs from the captured skirmish; no write performed.') flags = mem.u32(obj + 0x48) if require_fixed and not flags & 0x100: raise Refusal('Fixed-step mode is not enabled; no write performed.') return obj, flags, mem.read(obj + INTERVAL_OFFSET, 4) def change(mem, base, action): obj, flags, current = inspect_memory(mem, base, require_fixed=(action == 'apply')) expected, desired = (ORIGINAL, TARGET) if action == 'apply' else (TARGET, ORIGINAL) if current == desired: return 'Already at the requested interval; nothing written.' if current != expected: raise Refusal('Unexpected interval. Refusing to replace an unknown value. Restart EndWar to reset.') if action == 'apply': # These flags selected alternate/bypass timing paths in the captured code. for rva in (0xC1D9330, 0xC1DE8BC, 0x19EAC68): if mem.u32(base + rva): raise Refusal('An alternate timing path is enabled; no write performed.') address = obj + INTERVAL_OFFSET mem.ensure_writable(address, 4) # Recheck identity/value immediately before the single four-byte write. if inspect_memory(mem, base, require_fixed=(action == 'apply')) != (obj, flags, current): raise Refusal('Engine state changed during validation; no write performed. Try while paused in-game.') mem.write(address, desired) if mem.u32(base + POINTER_RVA) != obj or mem.read(address, 4) != desired: raise Refusal('Write verification failed. Do not retry repeatedly; restart EndWar to reset memory.') seconds = struct.unpack('<f', desired)[0] return f'Verified: interval = {seconds:.10f} seconds ({1 / seconds:.3f} updates/second target).' class MODULEENTRY32W(C.Structure): _fields_ = [('dwSize', W.DWORD), ('th32ModuleID', W.DWORD), ('th32ProcessID', W.DWORD), ('GlblcntUsage', W.DWORD), ('ProccntUsage', W.DWORD), ('modBaseAddr', C.c_void_p), ('modBaseSize', W.DWORD), ('hModule', W.HMODULE), ('szModule', W.WCHAR * 256), ('szExePath', W.WCHAR * 260)] class MEMORY_BASIC_INFORMATION(C.Structure): _fields_ = [('BaseAddress', C.c_void_p), ('AllocationBase', C.c_void_p), ('AllocationProtect', W.DWORD), ('PartitionId', W.WORD), ('RegionSize', C.c_size_t), ('State', W.DWORD), ('Protect', W.DWORD), ('Type', W.DWORD)] class WindowsMemory: def __init__(self, pid, write=False): self.k = C.WinDLL('kernel32', use_last_error=True) bindings = { 'OpenProcess': ([W.DWORD, W.BOOL, W.DWORD], W.HANDLE), 'CloseHandle': ([W.HANDLE], W.BOOL), 'GetExitCodeProcess': ([W.HANDLE, C.POINTER(W.DWORD)], W.BOOL), 'CheckRemoteDebuggerPresent': ([W.HANDLE, C.POINTER(W.BOOL)], W.BOOL), 'QueryFullProcessImageNameW': ([W.HANDLE, W.DWORD, W.LPWSTR, C.POINTER(W.DWORD)], W.BOOL), 'ReadProcessMemory': ([W.HANDLE, C.c_void_p, C.c_void_p, C.c_size_t, C.POINTER(C.c_size_t)], W.BOOL), 'WriteProcessMemory': ([W.HANDLE, C.c_void_p, C.c_void_p, C.c_size_t, C.POINTER(C.c_size_t)], W.BOOL), 'VirtualQueryEx': ([W.HANDLE, C.c_void_p, C.POINTER(MEMORY_BASIC_INFORMATION), C.c_size_t], C.c_size_t), 'CreateToolhelp32Snapshot': ([W.DWORD, W.DWORD], W.HANDLE), 'Module32FirstW': ([W.HANDLE, C.POINTER(MODULEENTRY32W)], W.BOOL), 'Module32NextW': ([W.HANDLE, C.POINTER(MODULEENTRY32W)], W.BOOL), } for name, (args, result) in bindings.items(): fn = getattr(self.k, name) fn.argtypes, fn.restype = args, result self.pid = pid self.handle = self.k.OpenProcess(0x400 | 0x10 | ((0x20 | 0x8) if write else 0), False, pid) if not self.handle: raise C.WinError(C.get_last_error()) def close(self): if self.handle: self.k.CloseHandle(self.handle) self.handle = None def alive(self): code = W.DWORD() if not self.k.GetExitCodeProcess(self.handle, C.byref(code)): raise C.WinError(C.get_last_error()) if code.value != 259: raise Refusal('EndWar has exited. No further memory access attempted.') def check_game(self): self.alive() debugged = W.BOOL() if not self.k.CheckRemoteDebuggerPresent(self.handle, C.byref(debugged)): raise C.WinError(C.get_last_error()) if debugged.value: raise Refusal('A debugger is attached. Restart the game normally through Steam first.') path = C.create_unicode_buffer(32768) length = W.DWORD(len(path)) if not self.k.QueryFullProcessImageNameW(self.handle, 0, path, C.byref(length)): raise C.WinError(C.get_last_error()) exe = Path(path.value) if exe.name.lower() != 'endwar.exe': raise Refusal('The selected process is not EndWar.exe.') digest = hashlib.sha256(exe.read_bytes()).hexdigest() if digest != BUILD_SHA256: raise Refusal(f'Executable SHA-256 mismatch ({digest}). This script supports only your captured build.') self.alive() def base(self): snapshot = None for _ in range(5): snapshot = self.k.CreateToolhelp32Snapshot(0x8 | 0x10, self.pid) if snapshot != C.c_void_p(-1).value: break if C.get_last_error() != 24: raise C.WinError(C.get_last_error()) time.sleep(0.1) else: raise Refusal('Module list is changing. Retry once the skirmish has loaded.') try: entry = MODULEENTRY32W() entry.dwSize = C.sizeof(entry) ok = self.k.Module32FirstW(snapshot, C.byref(entry)) while ok: if entry.szModule.lower() == 'endwar.exe': return entry.modBaseAddr ok = self.k.Module32NextW(snapshot, C.byref(entry)) raise Refusal('EndWar module not found.') finally: self.k.CloseHandle(snapshot) def read(self, address, size): self.alive() buffer = C.create_string_buffer(size) count = C.c_size_t() if not self.k.ReadProcessMemory(self.handle, address, buffer, size, C.byref(count)): raise C.WinError(C.get_last_error()) if count.value != size: raise Refusal('Incomplete memory read.') return buffer.raw def u32(self, address): return struct.unpack('<I', self.read(address, 4))[0] def ensure_writable(self, address, size): info = MEMORY_BASIC_INFORMATION() if not self.k.VirtualQueryEx(self.handle, address, C.byref(info), C.sizeof(info)): raise C.WinError(C.get_last_error()) if (info.State != 0x1000 or info.Protect & (0x100 | 0x01) or (info.Protect & 0xFF) not in (0x04, 0x08) or address + size > info.BaseAddress + info.RegionSize): raise Refusal('Interval is not in ordinary writable data memory; no protection changes attempted.') def write(self, address, data): self.alive() buffer = C.create_string_buffer(data, len(data)) count = C.c_size_t() if not self.k.WriteProcessMemory(self.handle, address, buffer, len(data), C.byref(count)): raise Refusal(f'Memory write failed: {C.WinError(C.get_last_error())}. Restart EndWar to reset if needed.') if count.value != len(data): raise Refusal('Incomplete write. Restart EndWar to reset before playing.') def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('action', choices=['status', 'apply', 'restore']) parser.add_argument('--delay', type=int, default=0, help='Seconds to wait before connecting (0-120).') args = parser.parse_args() if sys.platform != 'win32' or C.sizeof(C.c_void_p) != 8: raise Refusal('Use 64-bit Python 3 on Windows for this tool.') if not 0 <= args.delay <= 120: raise Refusal('--delay must be between 0 and 120 seconds.') try: import psutil except ImportError: raise Refusal('Install the dependency with: py -3 -m pip install psutil') if args.delay: print(f'Waiting {args.delay} seconds. Return to the game; keep the skirmish paused for the change.', flush=True) time.sleep(args.delay) matches = [] for proc in psutil.process_iter(['pid', 'name']): if (proc.info['name'] or '').lower() == 'endwar.exe': matches.append(proc.info['pid']) if len(matches) != 1: raise Refusal(f'Expected exactly one running EndWar.exe, found {len(matches)}. Start a skirmish normally.') mem = WindowsMemory(matches[0], write=args.action != 'status') try: mem.check_game() base = mem.base() obj, flags, raw = inspect_memory(mem, base, require_fixed=False) interval = struct.unpack('<f', raw)[0] print(f'Verified build. PID {matches[0]}; engine object 0x{obj:08X}.') print(f'Current interval: {interval:.10f} seconds. Fixed-step flag: {bool(flags & 0x100)}.') if args.action != 'status': print(change(mem, base, args.action)) print('Only the live interval was changed. Restarting EndWar also resets it.') print('This is a timing experiment; the target is not a measured FPS result.') finally: mem.close() if __name__ == '__main__': try: main() except (Refusal, OSError) as exc: print(f'ERROR: {exc}', file=sys.stderr) print('For access denied, use an Administrator PowerShell. Do not bypass a build/state mismatch.', file=sys.stderr) sys.exit(1) except KeyboardInterrupt: print('\nStopped. If a write already occurred, use restore or restart EndWar.', file=sys.stderr) sys.exit(130)
This script takes the bytes 89 88 08 3D and converts them to 89 88 88 3C. In other words, it converts the value 0.033333335 (1/30th) to 0.016666668 (1/60th). By halving the value, the waiting loop operates faster as the time to the next engine interval decreases. Thus, the resulting effect is the waiting loop, in conjuction with the application, completes 60 intervals per second, raising the locked FPS from 30 to 60.
This script has multiple checks when executing it like checking the SHA-256 (a serial number that I assume is created if the End War application is the current Steam version), the aforementioned addresses and functions around the target address. if these checks fail, the script terminates itself.

Can I Play End War With This? NO!
The Python script successfully changes the timing value and during its effect, End War runs at 60 FPS. Unfortunately, the entire game is engineered around this clock and when you change that single value, other reliant functions usually become bugged or behave not as intended.
Here are a couple of examples:
1. Helicopter turning and movement behavior becomes broken. Take the attack helicopters; when issuing move orders to those units, they always turn to face forward movement. When the timing value is changed to 1/60th, the helicopter turning velocity plummets due to timestep-dependent steering calculations and a damping calculation. The end results is: You order your attack helicopter to retreat as enemy transports start closing in, the helicopters screeches to a halt as it turns absurdly slow since there is no backward movement for them in End War, the unit becomes a pinata and dies. This applies to support helicopters that evacuate knocked out units as well. They can be damaged and destroyed but survive on their tankiness and speed. Now their speed is broken and it dies alongside the unit it evacuated.
2. Plenty of visual bugs like Airstrike planes loitering in place at the targeted area for a couple of seconds, sped up videos for battlefield call-ins, animation bugs for infantry, especially when disembarking vehicles and probably more bugs not yet discovered.
3. Some gameplay aspects, like camera movement or selecting units, requires faster clicking/inputs.
4. Potential balance issues regarding damage per second and speed calculations but I have not extensively played enough to verify if that is certainly true.

These are a few that were obvious from the start. The helicopter turning issue is by far the biggest problem and I am currently in the works of resolving it before moving to the other ones.

An Afterword And Conclusion
I never understood computer code nor do I have the time to learn a programming language or read debuggers. Yet I spend a substantial amount of time using computers and playing video games, which are, by themselves, extremely complicated computer programs. Sometimes, there is that itch to tinker with these programs, either to improve it, change it to your liking or for pure entertainment. However, you are hindered due to primarly two reasons: no time and/or know-how. With the advent of LLMs and AIs, this barrier can be broken down. So I thought that this would be a fun experiment to see if an LLM can crack End War the hard way. Once it did, I thought to share it since there is still some niche interest left with this 17 year old game, with the revival of ToW. End War at 60 FPS is definitely possible with extensive troubleshooting. Additionally, this experiment only accounts for singleplayer matches and I have no means to conduct multiplayer tests. Which is why those who are still active with End War should take endwar_clock.py and test it on their end. I am not a ChatGPT shill. It happened to be the one I chose for its generalist abilities. Although, I do believe that AIs and LLMs are the evolutionary conclusion for computer software, but those are just my general overview on them.
Nevertheless, the information that I presented is available for anyone to take and run wild with it. I will still continue its progress on my end.

Thank you for your time. o7
Running and Reversing the Experiment
1. Install 64-bit Python 3 on Windows. Place endwar_clock.py in a working folder such as C:\EndWar-Telemetry. Open PowerShell in that folder and install the dependency:

py -3 -m pip install psutil

2. Start EndWar normally through Steam with no debugger attached. Load an expendable single-player skirmish and use the game’s own pause menu. Avoid loading or unloading the match during the operation. Use Administrator PowerShell if process access is denied.

3. Check the original interval. The delay gives time to return to the paused game before memory access begins:

py -3 endwar_clock.py status --delay 20

4. Apply the experimental interval and return to the paused game during the delay:

py -3 endwar_clock.py apply --delay 20

The expected successful message is:

Verified: interval = 0.0166666675 seconds
(60.000 updates/second target).

The actual script prints that message on one line. It verifies the field, not displayed FPS. Resume and compare a short, repeatable action against a stopwatch and an FPS overlay, keeping frame generation off and graphics settings unchanged.

5. Pause in-game and restore the original interval when finished:

py -3 endwar_clock.py restore --delay 20
< >
Showing 1-3 of 3 comments
Per page: 1530 50