Game Vendor: Steam
Game Version: 261096 (3) | 557897 (4) [ check main menu, top-right ]
Game Process: Sam3.exe | Sam4.exe
Game File Version: -
Hello folks.
I've recently checked the Serious Engine in Serious Sam 4. Since I'm curious how things work, my analysis led to determining several critical functions one may use in reverse-engineering titles built with this Engine. First-up, the version used for 3 & 4 is Serious Engine 3.5. The only difference between them (aside from perhaps additional functions created specifically for 4) is the fact that the version in 3 is x86, while the version used in 4 is x64. I'm assuming all titles between Serious Sam 3 (2011) and Serious Sam 4 (2020) are x64 as well. Serious Sam Fusion 2017, for example, is x64.
Now I understand there are mixed opinions about the game itself, the characters, the story, etc. The goal here is not to open up the topic to shit-posting, but focus on Serious Engine and internals. So if you want to express yourself in the "why? game's shit anyway" manner, do that on another forum. I'll ban those who wanna fuck around and ignore what I just said.
I know you want to hear the thought process, how to do it to get to my results, so I'll try to explain as best as I can, without dragging this too long. I am using [Link] and [Link] combined, though you don't need both. It's easier for me to see a whole large function in one screen with x64dbg, rather than using a large font and scrolling 50 times to get to a function's epilogue.
The initial idea was to find a way to the game's console. Since I've not played the game thus far, the console is there. You can open it with the Tilde (~) key and enable various things, as per [Link]. Note that I've picked a certain article, as most results on google talk only of cht_bEnableCheats=1. There are Developer cheats which can only be activated if cht_bEnableCheats=3. Most of articles out there fail to specify this aspect, which kinda implies there've been copy-pastes of one another.
That being said, there was no point trying to have a go at the game/Engine. Then I noticed that if you enable cheat mode, achievements would be disabled for the current game session. It's annoying to see something like that and while I understand why developers do that, I don't really support it. Especially when this can be disabled. So that was the starting point: how to disable checking for enabled cheats.
Cheats Analysis
The first thing I noticed is when you enable cheats with bool value = 3 (cht_bEnableCheats=3), you see this in your right-hand side:
As you enable cheats, they will be listed in that box, under the header. So I looked for string references in x64dbg while attached to the game (you can also load the .exe directly and do static analysis) and found this:
Then checked out that function and applied knowledge I learned from other Engines. The first logic: cheats would toggle the same memory location with powers of 2. First cheat = 2^0 = 1. Second cheat = 2^1 = 2. And so on. Looked at my function and saw this:
While scrolling.. showed these:
And so on.. Full list being:
Code: Select all
000000014041F16A | F687 CC260000 04 | TEST BYTE PTR DS:[RDI+26CC],4 |
000000014041F171 | 74 49 | JE sam4.14041F1BC |
000000014041F173 | 48:8D0D 26463F01 | LEA RCX,QWORD PTR DS:[1418137A0] | 00000001418137A0:"ETRSHud.Cheats.God=God"
..
000000014041F1C9 | F787 CC260000 00010000 | TEST DWORD PTR DS:[RDI+26CC],100 |
000000014041F1D3 | 74 49 | JE sam4.14041F21E |
000000014041F1D5 | 48:8D0D DC453F01 | LEA RCX,QWORD PTR DS:[1418137B8] | 00000001418137B8:"ETRSHud.Cheats.InfiniteStamina=Infinite Stamina"
..
000000014041F21E | 8B87 CC260000 | MOV EAX,DWORD PTR DS:[RDI+26CC] |
000000014041F224 | A8 08 | TEST AL,8 |
000000014041F226 | 74 4F | JE sam4.14041F277 |
000000014041F228 | 48:8D0D B9453F01 | LEA RCX,QWORD PTR DS:[1418137E8] | 00000001418137E8:"ETRSHud.Cheats.Invisible=Invisible"
..
000000014041F271 | 8B87 CC260000 | MOV EAX,DWORD PTR DS:[RDI+26CC] |
000000014041F277 | A8 01 | TEST AL,1 |
000000014041F279 | 74 4F | JE sam4.14041F2CA |
000000014041F27B | 48:8D0D 8E453F01 | LEA RCX,QWORD PTR DS:[141813810] | 0000000141813810:"ETRSHud.Cheats.Ghost=Ghost"
..
000000014041F2C4 | 8B87 CC260000 | MOV EAX,DWORD PTR DS:[RDI+26CC] |
000000014041F2CA | A8 02 | TEST AL,2 |
000000014041F2CC | 74 4F | JE sam4.14041F31D |
000000014041F2CE | 48:8D0D 5B453F01 | LEA RCX,QWORD PTR DS:[141813830] | 0000000141813830:"ETRSHud.Cheats.Fly=Fly"
..
000000014041F317 | 8B87 CC260000 | MOV EAX,DWORD PTR DS:[RDI+26CC] |
000000014041F31D | A8 40 | TEST AL,40 |
000000014041F31F | 74 4F | JE sam4.14041F370 |
000000014041F321 | 48:8D0D 20453F01 | LEA RCX,QWORD PTR DS:[141813848] | 0000000141813848:"ETRSHud.Cheats.Turbo=Turbo"
..
000000014041F36A | 8B87 CC260000 | MOV EAX,DWORD PTR DS:[RDI+26CC] |
000000014041F370 | 84C0 | TEST AL,AL |
000000014041F372 | 79 49 | JNS sam4.14041F3BD |
000000014041F374 | 48:8D0D ED443F01 | LEA RCX,QWORD PTR DS:[141813868] | 0000000141813868:"ETRSHud.Cheats.AutoAiming=Auto aiming"
..
000000014041F46E | 8B87 CC260000 | MOV EAX,DWORD PTR DS:[RDI+26CC] |
000000014041F474 | A8 20 | TEST AL,20 |
000000014041F476 | 74 4F | JE sam4.14041F4C7 |
000000014041F478 | 48:8D0D 51443F01 | LEA RCX,QWORD PTR DS:[1418138D0] | 00000001418138D0:"ETRSHud.Cheats.InfiniteAmmo=InfiniteAmmo"
..
000000014041F4C1 | 8B87 CC260000 | MOV EAX,DWORD PTR DS:[RDI+26CC] |
000000014041F4C7 | A8 10 | TEST AL,10 |
000000014041F4C9 | 74 4F | JE sam4.14041F51A |
000000014041F4CB | 48:8D0D 2E443F01 | LEA RCX,QWORD PTR DS:[141813900] | 0000000141813900:"ETRSHud.Cheats.UnlimitedTimeInPast=Unlimited Time In Past"
..
000000014041F514 | 8B87 CC260000 | MOV EAX,DWORD PTR DS:[RDI+26CC] |
000000014041F51A | 0FBAE0 09 | BT EAX,9 |
000000014041F51E | 73 41 | JAE sam4.14041F561 |
000000014041F520 | 4C:8D05 C1271E01 | LEA R8,QWORD PTR DS:[141601CE8] | 0000000141601CE8:"Pass through plasma walls"
..
* * *
I want to pause a bit to explain WHO rdi above is. If you've read my other posts (Horizon Zero Dawn, Mafia: Definitive Edition, Marvel's Avengers, etc.) you will know by now what I will do. I'm going to check out the member-functions table to find a function that leads to a CLASS name. :
The function above is IsInfiniteAmmo. Why? Because, as you can see, there's this: "TEST BYTE PTR DS:[RBX+0x26CC],0x20". And if you look at the screenshot above, from my table, you see that b_chtInfiniteAmmo is a 0x20 toggle. Set a break there and inspect RCX as you fire your gun (I used map 1, pistol). On break, my rcx has this value: 00000000CE047440.
Then follow that in dump:
You can already see a "PlayerPuppet" reference. Then follow in dump the pointer to member-functions located at 0x00: 0000000141811F68. It's highlighted in the screenshot above. And we see this:
Then check the first function in disassembler:
The reason you see that naming in there is I manually named that global pointer, leading to a class. But in reality, right-click the address and choose Follow in dump > Value and you will see this:
So the first member-function leads to a structure that at offset 0x18 contains a pointer to the Class name. We will call this function GetClass. Remember it's at 0x00 in any base structures.
With that in mind.. we're now looking at CPlayerPuppet structure. Which now explains the names you've seen in my table, in the [ Debug ] section. Which means [ CPlayerPuppet + 0x26CC ] is the DWORD value used to check which cheat is enabled.
* * *
Detour over, I found the real cheat effects here:
And so on.
Based on the value stored @ CPlayerPuppet+0x26CC, the above are toggled to 0 or 1. In short, in the current build I'm looking at (557897), these are:
Code: Select all
0000000140151EBF | 8B0D A36E0F02 | MOV ECX,DWORD PTR DS:[<cht_bGiveAll>] |
0000000140151EC5 | 85C0 | TEST EAX,EAX |
0000000140151EC7 | 41:0F45CC | CMOVNE ECX,R12D |
0000000140151ECB | 890D 976E0F02 | MOV DWORD PTR DS:[<cht_bGiveAll>],ECX |
..
0000000140151EEB | 3905 676E0F02 | CMP DWORD PTR DS:[<cht_bGodMode>],EAX |
0000000140151EF1 | 0F94C0 | SETE AL |
0000000140151EF4 | 8905 5E6E0F02 | MOV DWORD PTR DS:[<cht_bGodMode>],EAX |
..
0000000140152012 | 3905 206D0F02 | CMP DWORD PTR DS:[<cht_bTurbo>],EAX |
0000000140152018 | 0F94C0 | SETE AL |
000000014015201B | 8905 176D0F02 | MOV DWORD PTR DS:[<cht_bTurbo>],EAX |
..
000000014015203B | 3905 E76C0F02 | CMP DWORD PTR DS:[<cht_bGhost>],EAX |
0000000140152041 | 0F94C0 | SETE AL |
0000000140152044 | 8905 DE6C0F02 | MOV DWORD PTR DS:[<cht_bGhost>],EAX |
..
000000014015205F | 3905 C76C0F02 | CMP DWORD PTR DS:[<cht_bFly>],EAX |
0000000140152065 | 0F94C0 | SETE AL |
0000000140152068 | 8905 BE6C0F02 | MOV DWORD PTR DS:[<cht_bFly>],EAX |
..
0000000140152083 | 3905 B76C0F02 | CMP DWORD PTR DS:[<cht_bAutoAiming>],EAX |
0000000140152089 | 0F94C0 | SETE AL |
000000014015208C | 8905 AE6C0F02 | MOV DWORD PTR DS:[<cht_bAutoAiming>],EAX |
..
00000001401520A7 | 3905 836C0F02 | CMP DWORD PTR DS:[<cht_bInvisible>],EAX |
00000001401520AD | 0F94C0 | SETE AL |
00000001401520B0 | 8905 7A6C0F02 | MOV DWORD PTR DS:[<cht_bInvisible>],EAX |
..
Ignoring Damage without Cheats
Further debugging cht_bGod and cht_bIgnoreDamageInGod cheats' effects led to discovering that what really happens behind the hood is a flag in the Entity structure receiving damage is being checked for a certain value. Namely 0x7A4 in Entity. And that led me to basically hooking the function that checks this flag BEFORE computing and executing the code performing the damage effects and writing Health. Which happens here:
The above constitutes the hook spot for the Ignore Damage (Perfect God Mode) script in my table, idea being as long as the Entity being processed is NOT CPlayerPuppet, flip the bool at 0x7A4 to 0x1 (which is the value from the TEST check), making us invulnerable. The effect acts like cht_bGod + cht_bIgnoreDamageInGod cheats combined. But since we're not using the actual cheats or their effects, the game will not know we're cheating. Thus no achievements get disabled.
Extra Functions
I then wanted to know how do I get CPlayerPuppet, our Player structure. And found that the Engine is using a certain logic: gets Entity structures by reading an id from some place and feeding this as argument to a certain function. It's always the same function. So.. player, enemies, allies, mech, weapons, etc. - any entity - is generated independently from certain templates. They are then attributed ids which can be used as arguments with the function I mentioned to retrieve the Entity pointer Similarly, you can feed an Entity pointer to another function, obtaining the assigned id.
I called these GetEntityById and GetEntityId. And this happens here:
GetEntityById:
GetEntityId:
What you can do with the above functions? Well, this:
GetCurrentWeapon:
Code: Select all
mov [CPlayerPuppetEntity],rcx
mov ecx,[rcx+F88] // dwCurrentWeaponHash
call GetEntityById
mov [CWeaponEntity],rax
Code: Select all
mov rcx,[CPlayerPuppetEntity]
mov ecx,[rcx+75C] // dwCMechPuppetEntity
call GetEntityById
mov [CMechPuppetEntity],rax
Weapon Logic
Now that you have CPlayerPuppet pointer you can get to the currently held weapon. To do so, check out the value stored @ offset 0xF88 in CPlayerPuppet and run it through the GetEntityById function:
Code: Select all
mov [CPlayerPuppetEntity],rcx
mov ecx,[rcx+F88] // dwCurrentWeaponHash
call GetEntityById
mov [CWeaponEntity],rax
Code: Select all
mov rax,[CWeaponEntity]
cmp rax,rcx
jne short @f // if not our CWeaponEntity
movsxd rax,[rcx+330]
cmp eax,-1
je short GetPrimaryWeaponModeId_o // if not a fireable weapon
// get Primary Mode
mov rdx,rax
mov rax,[rcx+310]
mov rcx,[rax+rdx*8]
mov [CPrimaryWeaponMode],rcx
// get Secondary Mode
mov rcx,[CWeaponEntity]
mov rcx,[rcx+320]
test rcx,rcx
mov [CSecondaryWeaponMode],rcx
@@:
Disable "Full" Check
When you reach the max of a property, you cannot pick stuff from the ground. Making the map look like a Christmas tree and hard to spot secrets (which usually glow in purple color). Not to mention some events are triggered by picking a certain item from the ground (like a health pack), such as spawning secret mobs or waves of them.
To get this done I used my Armor value. Found its address and debugged it on write. Then set a breakpoint at the instruction writing to it and back-traced as much as I could. I found a spot where, if I tried to pick an Armor pack from the ground, the game wouldn't write to my Armor value. And this happens here:
Notice the "FailedPick" string reference To kill this so you can pick anything from the ground (note: there are chests containing ammo; those are not included in this, game will still say "full" there) simply 90E9 the JNE (quick patch for a JMP):
Code: Select all
000000014024E986 | 0F85 AD000000 | JNE sam4.14024EA39 |
Code: Select all
000000014024E986 | 90 | NOP |
000000014024E987 | E9 AD000000 | JMP sam4.14024EA39 |
BR,
Sun