GoldSrc engine frame

Hi. In this article I'd like to discuss some of the internals of the GoldSrc engine frame. What do I mean by that? Well, as you may already know, games in general tend to define a set of subroutines or instructions or steps or whatever you want to call it, and run these instructions again and again, as fast as possible, in order for the game to function properly. Set of these instructions or "steps" is called a frame. A game frame.

Why a frame tho? Well, probably because inside of this "frame", there's some kind of graphic or rendering logic that renders the world, effects, entities, etc. Hence, every "frame" you get a new visual frame that is updated from the last one.

But a game frame usually doesn't only contain the rendering. It may also contain sound update, key/mouse event dispatcher, entity update, networking, etc. Let's now look into GoldSrc engine frame, what happens here, in detail.

Frame time, FPS, and frame count

Before we dive into details, let's first discuss some topics that are connected to a game frame. Such as frametime or fps - frames per second. The second one is pretty much straight forward, FPS defines how many frames we are able to execute on our CPU/GPU per single second. However, what about frametime? Well, frame time, unsurprisingly is an inverse of FPS, and FPS is an inverse of frametime.

But why? Frametime could be a synonym for a period, and FPS for a frequency. Hopefully you know these two. 😛 Also, very important thing. Frame time, as the name suggests, can tell us about how long the frame took to execute on our CPU/GPU. This is good for performance measurements as well as calculating the FPS, as said before.

double fps = 1.0 / frametime; double frametime = 1.0 / fps; // // let's say this is our frame // double start_time = get_time_seconds(); // mark the start of the frame // ... // run the frame (graphics, sound, networking, etc..) // now we have executed everything inside our frame, measure how long did it take. double end_time = get_time_seconds(); double frametime = end_time - start_time; // and since frametime = 1 / fps, for 100 fps, we'd have 0.01 seconds long frame (1 / 100), or a 10ms long frame. 1ms'd be for 1k fps.

For frame count, it's pretty simple. It's just a variable that increments each frame.

Architecture of engine frame

After initialization, the first frame can take place. The whole magic lies inside the function called RunListenServer, which first does the engine initialization and then finally goes to a while(1) loop to repeatedly execute "frame" code. Let's take a look at the function.

RunListenServer { // 1. initialization (if fail, quit game) // 2. the "endless" while loop, aka the engine frame. Goes like this: // // while (1) // { // engine frame code // } // // Note that this while loop isn't actually endless. It ends when the game is closed, or when a fatal error is thrown, or et cetera. // 3. shutdown code - after the while loop breaks, shutdown code is called. // 4. game quits }

There are three main "frame" functions that are called inside the while loop. The main one is CEngine::Frame() function that then calls Host_Frame() function that then finally calls _Host_Frame() function that calls all of the engine subroutines that handles sound, graphics, etc.

CEngine::Frame() -> Host_Frame() -> _Host_Frame() -> (SV_Frame, Host_UpdateScreen, CL_ReadPackets, ...)

So now when we know roughly what's the architecture or the engine frame, let's take a closer look at individual functions. Starting from the topmost function - RunListenServer.

RunListenServer

As stated before, this function is kind of the "entry point" for the engine that first does the initialization, then runs the main while loop, and then the shutdown code. Let's take a closer look at the while loop.

while (1) { // pump messages - this function is responsible for // dispatching all of the key/mouse inputs, window events, etc. game->SleepUntilInput(NULL); // look if in the previous engine frame someone requested to end the while loop. if (eng->GetQuitting() != IEngine::QUIT_NOTQUITTING) { if (eng->GetQuitting() != IEngine::QUIT_TODESKTOP) { result = ENGINE_RUN_CHANGED_VIDEOMODE; // restart } else { result = ENGINE_RUN_QUITTING; // full quit } break; // exit the "endless" while loop } // run engine frame eng->Frame(); }

So this is the topmost level of the engine frame. This piece of code is called as fast as possible. Also, the result code is for the launcher, so that it can change the videomode or quit the game, etc. Let's take a look into the eng->Frame() function, which is essentially just CEngine::Frame() that we talked about earlier.

CEngine::Frame

This function finally calls Host_Frame routine, but it also does other things, such as computing the "real" frametime. We'll talk about why it's "real" later.

For example, one thing that this function does is that it enforces lower framerate when the game window is not focused or when it is minimized. This is in order to not lose performance on your PC even when you're not really in the game. For example, when the game window is unfocused, the framerate is clamped to 50 (frametime 20 ms -> 1 / 50).

Most importantly, this function calls Host_Frame function, which is located deeply inside the engine core. To be more precise, inside host.c source file. And this is where all of the fun stuff happens.

Host_Frame

This function is pretty much straight forward. It does some measurements for code profiling (host_profile cvar) and then it finally calls the main engine frame function, _Host_Frame() that does all of the engine-related stuff.

_Host_Frame

Inside this function, all of the engine-related-stuff happens. It is also kinda big, and was changed over the years and over many engine builds. There's around 34 major functions being called. So let's look at some of them.

But first, let's divide this function into sections, and then analyze each section.

1. DInput event handling

DInput is no longer used, after 2013 when SDL was introduced to the engine along with cross-platform support, DInput became obsolete. However, the code is still present in the 8684 binary, but is unused.

The function that is called inside _Host_Frame() is called DInput_HandleEvents() and it basically serves as a dispatcher for DInput events such as mouse movement, and/or key/mouse input handling.

Also an important thing to note is that this function is only present in Windows builds, it is not present inside Linux or OSX binaries.

2. Time filtering

The function responsible is called Host_FilterTime and it basically tells whenever we should run the frame or not, based on settings. These settings are for example fps_max. As a parameter it takes the "real" frame time, and determines whenever it's time to run the frame or not, based on capped fps_max value. This is to prevent running the engine, when you set fps_max to 20, as fast as possible, every time, because you wouldn't get your 20 fps, but rather e.g. 500 or so, depending on your hardware.

This is where we can distinguish the "real" and "engine" frametime. The "real" frametime isn't compensated by the fps_max value, and rather is the "fastest" possible frametime that your hardware can handle. On the other hand, the "engine" frametime is capped by fps_max. And this frametime is actually used to calculated the engine fps value, which is then shown by the cl_showfps utility.

// cap the frame time at set value if (!Host_FilterTime(time)) { return; // don't continue the engine frame and rather wait, until it's time. }

Note that the function also works with other cvars such as fps_override or gl_vsync and it also computes host_frametime variable which represents the frame time of the engine and is later used throughout the code.

3. Beginning of the frame

Let's now go through what is happening at the beginning of the frame. Starting with function called SystemWrapper_RunFrame. This is part of the SystemWrapper interface, and I'm not entirely sure what is it's purpose, but I think that it's unused in 8684 builds..

Then we have a call to one of the functions from modfuncs_t structure called m_pfnFrameBegin. This is essentially part of the VAC1 security module interface that is not used now and was used around ~2003 before it became obsolete and unsed.

Following we have several functions that goes like this:

// compute rolling fps for the cl_showfps utility. // Uses EMA (Exponential moving average) algorithm to do so: // https://en.wikipedia.org/wiki/Moving_average Host_ComputeFPS(host_frametime); R_SetStackBase(); // sw renderer relared, does nothing in hw engine CL_CheckClientState(); // check if the client is fully connected to a server Cbuf_Execute(); // execute commands that were queued up // calls HUD_UpdateClientData from client dll and writes an update to the demo buffer, if recording. ClientDLL_UpdateClientData();

4. Serverside operations

This is where it gets more interesting. So, the server-side code of the engine frame is pretty simple compared to the client-side. There are only three functions called within this section.

First of them being CL_Move(). As you may know this function is responsible for sending client-side movement packets to the server. But why run it as a server and send data to the server? That makes little sense! Well, let me explain.

When you're connected to a public server and you are not hosting the server, the CL_Move function won't get called here. It is only called for the hosting player, because he has to move, too! So even when theoretically he is the server itself, he has to run this function, to send movement data to himself, to acknowledge his movement - it's the same as if you would run a singleplayer game.

// run the client-side movement for the host (server), if we're the host. if (sv.active) { CL_Move(); }

So there goes the "client-side" movement code for the server. Kinda hilarious to think about, but in the end, it makes sense.

Following we have the server-side frame function, SV_Frame(). Now, this function itself is very big, and I will not discuss it in detail, because it would take me a loot of time to do so. 😉 But essentially, it runs the server stuff, and other things. That is a really good definition, isn't it? I also haven't studied the server-side code as much as the client-side, so I would not do as good of a job as explaining client-side code, so let's just move on.

The final function inside this section is called SV_CheckForRcon(), and what it does is that it handles rcon packets. These may contain things such as challenges and rcons. a RCon, as you may know, stands for Remote Control, and basically what it allows us to do is to control the server remotely - that is, if you know the password.

RCons are used by dedicated servers, and they basically allows commands to be executed on the server-side from a remote position, as said before.

5. Clientside operations

This is where the fun part begins. I say fun part, because the majority of the functions or "stuff" is called/happens here, on the client-side.

So the first operation is informing the client dll about new frame. We do this using the ClientDLL_Frame() function. This just essentially calls the client dll function that then does its own thing.

CL_ReadPackets

Following we have CL_ReadPackets(). Now, this is a big one. Basically this function is responsible for all of the client-side networking. That is, parsing out all of the messages that came from the server earlier this frame (if host) or basically sometime earlier. I have described SVC (server->client) architecture here. So you can check it out if you want.

The function also takes care of resource downloading, updating the progress bar, and processing the downloaded file. This function on its own is far more complex, but I don't want to go into too much details right now, because this article isn't about client-side networking or SVC parsing.

CL_RedoPrediction

Moving up we have CL_RedoPrediction(). The initial client-side prediction is done inside CL_Move however, while parsing server messages inside CL_ReadPackets, if we receive another packet after parsing data from the previous one, we have to update the prediction data. Let me explain.

Before calling CL_ReadPackets we save the current incoming sequence number (basically the most up-to-date received packet) and after that we run the CL_ReadPackets function. Inside of this function, Netchan_Process is called, which essentially increases the sequence number (that is if we receive any new packets from the server).

So, if we receive any new packet, we re-run the prediction code, hence the function name - CL_RedoPrediction. If we don't receive any new packets, no more prediction will be done this frame, and everything will be done the next frame.

CL_SetLastUpdate(); // Sets the last sequence index before we read the data CL_ReadPackets(); // Read all data that we've received from the server // If we haven't predicted/simulated the player (multiplayer with prediction enabled and not a listen server with zero frame lag, then go ahead and predict now. CL_RedoPrediction();

As said before, the sequence counter value is saved inside CL_SetLastUpdate:

void CL_SetLastUpdate() { g_lastupdate_sequence = cls.netchan.incoming_sequence; }

And then after executing CL_ReadPackets, inside CL_RedoPrediction we have:

void CL_RedoPrediction() { // We don't have to predict since we didn't get any new packets if (g_lastupdate_sequence == cls.netchan.incoming_sequence) { return; } // run the prediction again, we have received an update CL_PredictMove(TRUE); CL_CheckPredictionError(); }

CL_EmitEntities

The next is entity creation, event firing, and stuff such as linking entity updates or new entity "states" we got from the server with already existing entities. Basically updating the current entity list with newest entity updates. This applies both for regular entities as well as players.

This involves functions such as:

CL_LinkPlayers(); // link player entities with up to date entity update CL_LinkPacketEntities(); // link other entities CL_TempEntUpdate(); // update temporary entities such as beams CL_FireEvents(); // execute all events

CL_CheckForResend

This function doesn't do anything important, it is called only during the initial connection and it basically setups the the initial connection between client and the server and if it fails to do so, it tries several times, as defined by MAX_CONNECT_RETRIES macro, i.e. 4x in vanilla GoldSrc before giving up on the connection.

You can see when the function fails to establish the connection for the first, second, or third time, because it displays a string inside the console, as well as changes the progress bar status text.

Con_Printf("Retrying %s...\n", servername); // and on the initial attempt: Con_Printf("Connecting to %s...\n", servername);

CL_HTTPUpdate

Oh boy, and here comes the imfamous DLM aka TheDownloadManager. This function runs the update code for the DLM. And long story short, the code just updates the progress bar and eventually updates all active requests (for file download) that are active at the moment.

The download manager went through a lot of changes itself. Before the 2013 build, it was different that it is now. It wasn't using the CMultipleCallResults and steamAPI in order to process HTTP requests via steamclient.dll. The entire code of the old DLM can be found inside the 2007 leak, and the newest DLM was partly inspired by the csgo leak.

So in general, the function just updates resources that are queued for download, updates the progress bar and other things like that. Note that there can (in the vanilla GoldSrc) be 5 resources being "downloaded" at the same time.

Steam_ClientRunFrame

Next up we have steam. This eventually calls into steam_api.dll, and the only thing that it does is that it runs all the queued callbacks by the DLM. Callbacks are registered by the CMultipleCallResults inside the DLM, and when they're processed by the steamclient.dll, they're ran by this function, or to be more precise, by the CCallbackMgr::RunCallbacks function from steam_api.dll.

Note that steam_api.dll is just a wrapper over steamclient.dll, and its main purpose is to handle callbacks such as these from the DLM and to deal with communication with steam.

CL_MoveSpectatorCamera

This function is pretty much straight forward, it just runs the movement code for client-side spectator mode, when on.

Bonus: IGA_Frame (??)

This function is disabled in 8684 however, the call into it was still there in the 4554 version of the game. It is the frame function for the InGameAdvertisements system, that is now obsolete. It's basically code responsible for rendering Valve's 2007 OrangeBox advertisements all over the place. For example, you may remember seeing one of the advertisements for yourself on famous maps such as de_dust2, and others. Or inside the scoreboard or perhaps when spectating.

Yes, this code is obsolete and was removed later on from the game completely. However, the code can be still seen inside the 4554 binary, and you can inspect it using IDA if you want 😉😀. However, the whole advertisements code it's kinda big.. so, you have been warned..

6. Renderer operations

And after a lot of client-side operations we finally have rendering. Now, there's only one function responsible for the entire game rendering, and that is Host_UpdateScreen(). Yes, everything is packed into this function. That includes world, decals, players, beams, entities, as well as VGUI, HUD, and more.

The function then calls SCR_UpdateScreen, which is basically the function that does all of the stuff I just said. Also, this function is very familiar to the one in original Quake 1, even the name remained the same if I remember correctly..

Also, a little bonus. Some cheats in CS 1.6 use an exploit which basically allows you to have your fps tripled or even quadrupled, if you run this function like each second frame or so. This only confirms that this function is really a big bottleneck inside the engine. Well, and it's not surprising after all, because it's literally responsible for rendering every character of text or every texture on the map, etc. So it's kinda expectable.

7. Audio operations

The function responsible is called Host_UpdateSounds. The function itself then calls S_Update, which takes care over the whole sound update. Note that the sound code contains apart from DSound on Windows or some portable SDL sound code on Linux also A3D sound code as well as EAX sound code. This (now disabled) code is under two macros: __USEA3D and _ADD_EAX_, both of which are now disabled in 8684, and are only available on windows. However, the code is still inside there, only disabled.

This can be fore example seen here, inside S_MixChannelsToPaintbuffer function:

#if defined(_ADD_EAX_) if (EAX_format_set) { S_EAXPaintChannelFrom8(ch, sc, sampleCount); } else #endif // _ADD_EAX_ { if (ppaintbuf == paintbuffer) { SND_PaintChannelFrom8(ch, sc, sampleCount); } #if defined(__USEA3D) else { SND_PaintChannelFrom8toDry(ch, sc, sampleCount); } #endif // __USEA3D }

But anyway, the sound update code just paints the dma buffer and transfers it over to the hardware device.

8. End of the frame & Profiling

And here we are! At the end of the frame.. What a journey. At the end, there're only profiling operations, measurements on how long does each part of the frame took, etc. For example, the cvar host_speeds takes place in this code region.

Apart from profiling, the host_framecount is increased, and the client-side clock is adjusted. The client clock is used to keep track of time synchronization between client and server.

Conclusion

And so here we are, that's it. Quite a lot of operations, ain't it? Well, that's what it takes in order to run a fully functional multiplayer game (kind of). And take into a count that this is a game based on Quake 1 - a game that was released in 1996. While heavily modified, GoldSrc engine is very similar to Q1, and yet, it's still decently complicated I'd say.

Now, what about modern games? From these days? Well, that's whole another level. Just so you can think about it. GoldSrc engine itself (both hw.dll and sw.dll - not including dedicated server code) is about 180,000 lines of code in total. That is, of course, not including other modules. And this engine is almost 30 years old now! The new S2 engine must have millions of lines of code then! And yet, it really does... Insane, yes.

Anyway, thanks for reading!