A simple GSC loader for CoD Black Ops 1

Written on September 17, 2023
Author: Dylan Müller

Learn how GSC scripts are loaded into Black Ops 1 and how to write your own simple GSC loader in C for the Microsoft Windows version of the game.

  1. Fond Memories
  2. Game Script Code
  3. GSC Script Call Chain
  4. Format of raw GSC scripts
  5. Loading and executing GSC script assets
  6. Black Ops Offsets
  7. Source Code
  8. Demo

Fond Memories

Black Ops 1 Zombies 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 :heart:.

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:

  1. Allocate RawFile struct with script name, size and data buffer.
  2. Initialise XAssetHeader union with pointer to our Rawfile struct.
  3. Allocate XAssetEntry struct.
  4. Set asset.type to ASSET_TYPE_RAWFILE (XAssetEntry).
  5. Set asset.header to XAssetHeader allocated earlier (XAssetEntry).
  6. 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 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:

GSC Dump

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:

  1. Query map being loaded with Dvar_FindVar.
  2. Compare current scriptName with map name.
  3. On match open GSC script, compress and load asset as described above.
  4. Call Scr_LoadScript on our loaded script asset.
  5. Get function handle with Scr_GetFunctionHandle.

To execute our GSC script entry function via our Scr_LoadGameType_hk hook:

  1. Call Scr_ExecThread on function handle.
  2. Call Scr_FreeThread on handle returned by Scr_ExecThread.

meme

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.

Demo


Questions, comments? Please send a plain-text email to root@lunar.sh with a descriptive subject line and we will try get back to you!

~2023-09-17-gsctool.md