A simple GSC loader for CoD Black Ops 1
Learn how
GSC
scripts are loaded into Black Ops 1 and how to write your own simpleGSC
loader inC
for the Microsoft Windows version of the game.
- Fond Memories
- Game Script Code
- GSC Script Call Chain
- Format of raw GSC scripts
- Loading and executing GSC script assets
- Black Ops Offsets
- Source Code
- Demo
Fond Memories
Call of Duty: Black Ops 1
Call of Duty: Black Ops 1
for Microsoft Windows was released all the back back
in 2010! At the time I was still a teenager in high-school and a dedicated
gamer.
It seemed like another purchase on steam back then but I would have never expected a game to produce so many fond memories playing kino der toten zombies with friends from all over the world.
I mean who doesn’t remember all the iconic glitches, mystery boxes, etc of the
game? Some say the Black Ops 1
zombies
was the best in the Black Ops
series.
There is something about the game aesthetics that creates a rush of nostalgia
.
Ok enough rambling, let’s get technical…
Game Script Code
GSC
is an internal scripting language that is used to script missions and game
modes for Black Ops 1
. It has a syntax similar to C++
but is a very limited
language. Here is an example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include common_scripts\utility;
#include maps\_utility;
init()
{
level thread on_player_connect();
// TODO: put weapon init's here
maps\_flashgrenades::main();
maps\_weaponobjects::init();
maps\_explosive_bolt::init();
maps\_flamethrower_plight::init();
maps\_ballistic_knife::init();
}
on_player_connect()
{
while( true )
{
level waittill("connecting", player);
player.usedWeapons = false;
player.hits = 0;
player thread on_player_spawned();
}
}
GSC
scripts are only loaded and executed when a map or game has started and in
Black Ops 1
they are reloaded every time a map or game mode is restarted. Writing
custom GSC
scripts is thus the basis for the vast majority of Black Ops 1
modding efforts.
GSC
scripts are first loaded into memory and then compiled with an internal GSC
compiler. There is a public GSC
compiler and decompiler on GitHub
:
gsc-tool but support for Black Ops 1 (T5)
seems to be missing.
Luckily it turns out we don’t actually need an external GSC
compiler to compile
our own GSC
scripts. We can (in theory) simply call the compile()
function of
the internal GSC
compiler and pass our mod script.
Whenever we talk about hooking a function or calling an in-game function we
usually assume that static analysis has been performed on the game binary to
find functions and offsets of interest. IDA Pro
is my disassembler of choice
but Ghidra
is probably also worth mentioning.
For debugging, Cheat Engine
usually does the job quite well. However debugging
Black Ops 1
is not as simple as attaching to the process and setting
breakpoints.
As far as I know this was possible in the official release version of the game,
but the game received multiple updates/patches which introduced various
primitive anti-debugging
and anti cheat detections which must be bypassed.
Usually if I am starting reverse engineering on a game, my first quick option is
scyllahide. This seemed to work
for the latest Black Ops 1
patch which allowed me to set breakpoints in game
which aided in the search for various Black Ops 1
function addresses.
As we know research is an important part of any reverse engineering task, without adequate research you could be wasting hours, days or even months of your time by doing the same work that others have done before.
So I started research and found out that the source code for the CoD 4
server
was leaked at some point and can be found in the following repo:
cod4x_server
This is very valuable information and was the basis for understanding how GSC
scripts are loaded and compiled in game.
GSC Script Call Chain
The next task was figuring out the call chain (sequence of function calls) which
is required for loading custom GSC
scripts. So I decided to search for the
keyword script
and stumbled upon a function called Scr_LoadScript
here
which seemed to be responsible for loading a GSC
or CSC
script (CSC
scripts are
almost identical to GSC
scripts but are executed by the client instead of a
server).
The only argument to Scr_LoadScript
was a const character string however:
1
unsigned int Scr_LoadScript(const char* scriptname)
which I assumed was some type of resource/asset string. I therefore proceeded to
look for any functions that would load assets and stumbled upon DB_AddXAsset
here.
The function signature for DB_addXAasset
was as follows:
1
XAssetHeader __cdecl DB_AddXAsset(XAssetType type, XAssetHeader header)
I decided to search for the definition of XAssetHeader
. XAssetHeader
was
defined as a union of various
structs:
1
2
3
4
5
6
7
8
9
10
11
union XAssetHeader
{
struct XModelPieces *xmodelPieces;
struct PhysPreset *physPreset;
struct XAnimParts *parts;
...
struct FxImpactTable *impactFx;
struct RawFile *rawfile;
struct StringTable *stringTable;
void *data;
};
Out of all the structs listed RawFile
seemed like the most interesting for our
purposes (loading GSC
script assets). Let’s have a look at the Rawfile
structure
itself:
1
2
3
4
5
6
struct RawFile
{
const char *name;
int len;
const char *buffer;
};
Seems simple enough:
-
name
is the resource/asset identifier. -
len
is the total buffer length. -
buffer
is the buffer holding our script to compile.
One thing worth noting is that we don’t actually know the format that scripts
are stored in before they are compiled (i.e format of buffer
). For example,
are they plaintext, compressed or encrypted at all? We will discuss this further
on.
Let us now for a brief moment turn our attention to the following code block:
1
2
3
4
5
6
XAssetEntry newEntry;
newEntry.asset.type = type;
newEntry.asset.header = header;
existingEntry = DB_LinkXAssetEntry(&newEntry, 0);
This seems to be doing the actual asset allocation. The first argument is of type
XAssetEntry
which is defined as:
1
2
3
4
5
6
7
8
9
struct XAssetEntry
{
struct XAsset asset;
byte zoneIndex;
bool inuse;
uint16_t nextHash;
uint16_t nextOverride;
uint16_t usageFrame;
};
The first element of this struct asset
looks interesting:
1
2
3
4
5
struct XAsset
{
enum XAssetType type;
union XAssetHeader header;
};
We know what XAssetHeader
is and XAssetType
is a simple enum which is used
to indicate the asset type:
1
2
3
4
5
6
7
8
enum XAssetType
{
ASSET_TYPE_XMODELPIECES,
ASSET_TYPE_PHYSPRESET,
ASSET_TYPE_XANIMPARTS,
...
ASSET_TYPE_RAWFILE
}
ASSET_TYPE_RAWFILE
seems like the right enum value in our case. With all this
information we now know what is required to load a script asset:
- Allocate
RawFile
struct with script name, size and data buffer. - Initialise
XAssetHeader
union with pointer to ourRawfile
struct. - Allocate
XAssetEntry
struct. - Set
asset.type
toASSET_TYPE_RAWFILE
(XAssetEntry
). - Set
asset.header
toXAssetHeader
allocated earlier (XAssetEntry
). - Call
DB_LinkXAssetEntry
to ‘link’ the asset.
After this series of steps it should be possible to call Scr_LoadScript
with
the path of our asset. This would be the same as the name
member of the
RawFile
struct.
Format of raw GSC scripts
We now have most of the required information to load GSC
script assets into
memory but one question remains, what format are they in?
To answer this question I thought a good starting point would be to hook/detour
DB_LinkXAssetEntry
and dump the RawFile
data buffer for a GSC
script to a
file for analysis.
To detour functions in the game who will have to bypass an annoying anti-debug
feature introduced into the game that uses GetTickCount
to analyse a thread’s
timing behaviour.
Using Cheat Engine I managed to trace the evil function to 0x004C06E0
:
1
#define T5_Thread_Timer 0x004C06E0
Anti-debug timer view in IDA
.
So make sure you patch this out otherwise the game will simply close after some time after detouring.
We would have to hook the function before loading a solo zombie match as script
assets are loaded into memory shortly after starting a game mode. I wrote a
simple detouring library called cdl86
that can hook/detour
functions either by inserting a JMP
opcode at the target
functions address to a detour function or through a software breakpoint.
I will leave this task up to the reader to perform. What you need is an address
and I took the liberty of finding the address of DB_LinkXAssetEntry
for you in
IDA Pro
. If you are wondering how I found the address, it would be a combination
of research, debugging and static analysis.
A lot comes down to recognising patterns in the disassembly. In general as you find the address of functions it gradually becomes easier to find the address of other related functions.
Bare in mind this is the address of the last x86
(32-bit
) PC
version of the
game:
1
#define T5_DB_LinkXAssetEntry 0x007A2F10
Once you have a GSC
script dump, open it in your favorite text editor (I used
HxD
). This is a dump of a small GSC
script from memory:
We can immediately see that the script is not stored in simple plaintext. In
this case I like to look for magic
bytes which may indicate if the file was
compressed. zlib
compression is one of the most common compression types for
binary files.
Based off research I did, zlib
uses the following magic bytes:
1
2
3
78 01 - No Compression/low
78 9C - Default Compression
78 DA - Best Compression
These bytes should appear not too far from the start of the file. Notice that in
the dump these bytes appear at offset 0x8
and 0x9
so there is a good chance
this file is zlib
compressed. Let’s test this theory, here is a simple zlib
inflate
python script:
1
2
3
4
5
6
7
8
import zlib
with open(sys.argv[1], 'rb') as compressed_data:
compressed_data = compressed_data.read()
unzipped = zlib.decompress(compressed_data[8:])
with open(sys.argv[2], 'wb') as result:
result.write(unzipped);
result.close()
I get the following text:
1
2
3
4
5
6
7
8
9
10
11
12
13
// THIS FILE IS AUTOGENERATED, DO NOT MODIFY
main()
{
self setModel("c_jap_takeo_body");
self.voice = "vietnamese";
self.skeleton = "base";
}
precache()
{
precacheModel("c_jap_takeo_body");
}
This text has a size of 0xC6
.
Great and +1
for the data being stored in plaintext!
So what are the first 8
bytes used for?
If you have a sharp eye you might have noticed that this 8 bytes actually
contains 2
values.
The first 4
bytes seems to represent our inflated
(decompressed) data size of
0xC6
and the second value seems to represent the size of the file - 8 bytes
or the size of our compressed data which is 0x9A
.
These values seem to be stored in little-endian
format with the 4
byte integers
ordered as LSB
.
Great we now have enough information to load a plaintext GSC
script, compress it
and load it as an asset using DB_LinkXAssetEntry
. It’s pretty cool that it is
this straightforward to decompress GSC
scripts as it gives insight into how the
game modes/logic were implemented and plenty of ideas for mods of course!
So in theory we could inject a DLL
, read our GSC
script, compress it, link it
with DB_LinkXAssetEntry
and then call Scr_LoadScript
right? We will discuss
this in the next section.
Loading and executing GSC script assets
It turns out that we cannot simply call Scr_LoadScript
when we like. As
mentioned above CoD
Black Ops
will load all script assets using
DB_LinkXAssetEntry
and then compile them using Scr_LoadScript
while a map is
loading.
Therefore we have to load our GSC
script at the correct time otherwise it won’t
work. The strategy is therefore to hook Scr_LoadScript
and then load our
custom GSC
script asset when Scr_LoadScript
is loading one of the last GSC
scripts for the current map.
It turns out that one of the last GSC
scripts contains the current map name.
To get the value of the current map we can utilize the function Dvar_FindVar
which i found to be at address: 0x0057FF80
:
1
#define T5_Dvar_FindVar 0x0057FF80
It’s function prototype is defined as follows:
1
2
3
typedef uint8_t* (__cdecl* Dvar_FindVar_t)(
uint8_t* variable
);
The variable in question is mapname
which will return the current map. It
should be noted that Scr_LoadScript
does not execute our GSC
script however,
instead we defer execution of our main function in our GSC
script until the map
has finished loading.
To this end i found a convenient function that is called when the game is just
about to start at address 0x004B7F80
:
1
#define T5_Scr_LoadGameType 0x004B7F80
with the following signature:
1
2
3
typedef void (__cdecl* Scr_LoadGameType_t)(
void
);
To defer execution however we need to obtain the function handle after the GSC
script has been loaded in our hooked Scr_LoadScript
. In this case we utilize a
function called Scr_GetFunctionHandle
which returns the address of a function
which has been loaded into the GSC
execution environment or VM
.
It has the following function signature:
1
2
3
4
5
typedef int32_t (__cdecl* Scr_GetFunctionHandle_t)(
int32_t scriptInstance,
const uint8_t* scriptName,
const uint8_t* functioName
);
scriptInstance
is a variable which determines whether the script is a GSC
or
CSC
type script. A value of 0
indicates a GSC
script while 1
indicated a CSC
script.
The function handle to our loaded script is then stored in a global variable and
the function can be executed in it’s own thread using Scr_ExecThread
which has
the following prototype:
1
2
3
4
5
typedef uint16_t (__cdecl* Scr_ExecThread_t)(
int32_t scriptInstance,
int32_t handle,
int32_t paramCount
);
I found this function at address: 0x005598E0
:
1
#define T5_Scr_ExecThread 0x005598E0
I also noticed during static analysis that a call to T5_Scr_FreeThread
was
always made following a call to Scr_ExecThread
so that the pseudo code would
look something like:
1
2
int16_t handle = Scr_ExecThread(0, func_handle, 0);
Scr_FreeThread(handle, 0);
We also obviously also need a compression library and for this I am using miniz.
So to summarize the steps to inject our GSC
script via our Scr_LoadScript
hook:
- Query map being loaded with
Dvar_FindVar
. - Compare current
scriptName
with map name. - On match open
GSC
script, compress and load asset as described above. - Call
Scr_LoadScript
on our loaded script asset. - Get function handle with
Scr_GetFunctionHandle
.
To execute our GSC
script entry function via our Scr_LoadGameType_hk
hook:
- Call
Scr_ExecThread
on function handle. - Call
Scr_FreeThread
on handle returned byScr_ExecThread
.
Black Ops Offsets
I have included all the offsets I found for the game using IDA
:
1
2
3
4
5
6
7
8
9
10
#define T5_Scr_LoadScript 0x00661AF0
#define T5_Scr_GetFunctionHandle 0x004E3470
#define T5_DB_LinkXAssetEntry 0x007A2F10
#define T5_Scr_ExecThread 0x005598E0
#define T5_Scr_FreeThread 0x005DE2C0
#define T5_Scr_LoadGameType 0x004B7F80
#define T5_Dvar_FindVar 0x0057FF80
#define T5_Assign_Hotfix 0x007A4800
#define T5_init_trigger 0x00C793B0
#define T5_Thread_Timer 0x004C06E0
A simple challenge would be to find these offsets yourself, it is not difficult.
Black Ops 1
uses the __cdecl
calling convention for functions.
Source Code
I have included the source code of my simple GSC
loader in the
following repo.
I have also included a sample GSC
script that spawns a raygun at spawn in
zombies solo mode and dumps GSC
scripts as they are loaded via a
DB_LinkXAssetEntry
hook.