Day 15. Platform-Independent Debug File I/O

Day 15. Platform-Independent Debug File I/O
Video Length (including Q&A): 1h33

Welcome to “Handmade Hero Notes”, the book where we follow the footsteps of Handmade Hero in making the complete game from scratch, with no external libraries. If you'd like to follow along, preorder the game on handmadehero.org, and you will receive access to the GitHub repository, containing complete source code (tagged day-by-day) as well as a variety of other useful resources.

Throughout the past four days, we were isolating what will become the platform-independent layer from our Win32 layer. Up until the present, we've been converting already existing “systems” but now the time has come to introduce a somewhat new one. We'll do some basic file I/O today.

Day 14 Day 16

(Top)
About File I/O in Games
  1.1  Define Approach
  1.2  Write Usage Code First
Platform implementation
  2.1  Free File Memory
  2.2  Read file
    2.2.1  Get a Handle
    2.2.2  Get File Size
    2.2.3  Allocate File Memory
    2.2.4  Read the File Contents
    2.2.5  Close Handle
  2.3  Write file
Compile and Test
Recap
Programming Notions
  5.1  Intro to Struct Packing
Side Considerations
  6.1  Safe Truncation
Navigation

   

About File I/O in Games

File I/O means literally interacting with the file system (folders, files, etc.) for reading and writing files. In games, this is usually done for two distinct purposes:

  1. Loading game assets from read-only files.
    • These include music, sounds, 2D/3D art, level layouts, and much more.
    • During the game execution, you never write to these files. Writing happens at some point before the game ships, and not necessarily by the game itself. It might even be done by some third-party tool like photoshop.
    • You read from the asset files and into the memory during the game execution. That data can be pulled from the network, an optical drive, hard drive, and so on.

  2. Reading and writing configuration and save files.
    • The data in config files might be graphics or sound settings, and potentially more.
    • The save files store the state of the progress of the player (various unlocks, player position on map, etc.)

When we talk about game I/O we talk about these two different things. And don't take this lightly: these are completely different things and they have different criteria for success. Config files are small and agile, while the asset files take potentially gigabytes of data.

About Asset Files Loading Evolution

The way asset files are loaded has evolved over the years. The process used to be quite linear: you'd open a file directly as the part of execution, read N bytes to the provided buffer, do something with that buffer, read more, etc. (and when the file was no longer needed, close the file).

Nowadays, thanks to the widespread of multithreading techniques, many adopt streaming of assets in the background so that the user doesn't have to sit on the loading screens.

   

Define Approach

As usual, there're many ways to put the proverbial pants on a dog. One way would be keeping the file handle around and only reading the bytes we want at a time. This is an approach that many games adopt... but we won't, because of the issues it presents later down the line:

 Figure 1: Latency (delay) comparison using various media. To give you some context, if you scale the numbers up, reading from memory would take a couple minutes, from SSD a couple of days, and from hard drive a few months.

So what will be our approach?

For the smaller files we'll use right now, we'll simply read them whole, and store them in memory. It's a similar structure to the above method except we don't read in small pieces. Once we're ready to stream big asset files, we'll be doing it asynchronously and from only one asset file.

That said, as with anything we've done up until now, it won't be the final system. We don't want to put the cart before the horse in designing a fully-fledged asset streaming system, but we need to be able to load the files for our debugging purposes from somewhere. So today we're going to just that: put the minimal set of functions to get the game running.

   

Write Usage Code First

Let's start coding. Open your handmade.cpp and try to imagine how would you approach reading files. Such a simple function would only need to take the path to the file (absolute or relative to our working directory) and return a void *, pure memory.

If you remember, during the day 1 we set our “Working Directory” to be data, so any relative paths in the file system will take it from there. If you skipped that step then, make sure to return and set the correct working directory!

internal void
GameUpdateAndRender(game_memory* Memory, game_input *Input, game_offscreen_buffer* Buffer, game_sound_output_buffer *SoundBuffer)
{
void *FileData = ReadEntireFile("test.bmp"); if (FileData) { // yay! }
// ... }
 Listing 1: [handmade.cpp] Adding a read file function.

We don't have any files to read yet, so let's say we want to read this file. Compiler has a handy macro called __FILE__ which stores the path to this specific file you put this macro in (in our case, handmade.cpp). We'll also want to have a call to free whatever memory we get.

void *FileData = ReadEntireFile(__FILE__);
if (FileData) {
FreeFileMemory(FileData);
}
 Listing 2: [handmade.cpp] Adding a free file memory call.

Last, we also want to be able to write to our files. Let's imagine that, in order to write to the file, you'd want to provide a path, but also link to the data and the size.

This reminds us that from a Read function we'd also want to get the size of the file we read. Let's store both the void * and the size in a struct, say read_file_result. FreeFileMemory would only need the former, so our usage would be the following.

read_file_result FileData = ReadEntireFile(__FILE__);
if (FileData.Contents) {
WriteEntireFile("test.out", FileData.ContentsSize, FileData.Contents);
FreeFileMemory(FileData.Contents);
}
 Listing 3: [handmade.cpp] Adding a write file call and modifying the others.

Looks good! Now this code differs from what we've written thus far: this is the call which happens from the game back to the platform layer, thus making the “round trip”. to the platform.

And this is what you have if you add callbacks (for memory or anything else): PLATFORMGAMEGameUpdateAndRender(startoftheframe)ReadEntireFilereturnWriteEntireFilereturnFreeFileMemoryreturnreturn(endofframe)

 Figure 2: Our API structure. Of course, we can call the file functions at any moment during the frame.

We want to make sure that anyone using this call in the game knows it's not final, and that's it a call to the platform. We can even write it in big letters that it's all for DEBUG purposes. Also it's a Platform call, se we will add this prefix as well.

debug_read_file_result FileData = DEBUGPlatformReadEntireFile(__FILE__);
if (FileData.Contents) {
DEBUGPlatformWriteEntireFile("test.out", FileData.ContentsSize, FileData.Contents); DEBUGPlatformFreeFileMemory(FileData.Contents);
}
 Listing 4: [handmade.cpp] Adding prefixes.

Now, moving to handmade.h, we can even further accentuate it by writing the API inside #if HANDMADE_INTERNAL (that we defined last time). This will make so these calls only work when we're compiling with this flag set. We will define our file API inside the section we reserved for the “services that the platform layer provides to the game”:

// NOTE(casey): Services that the platform layer provides to the game.
#if HANDMADE_INTERNAL struct debug_read_file_result { u32 ContentsSize; void *Contents; }; internal debug_read_file_result DEBUGPlatformReadEntireFile(char *Filename); internal void DEBUGPlatformFreeFileMemory(void *Memory); internal b32 DEBUGPlatformWriteEntireFile(char *Filename, u32 MemorySize, void *Memory); #endif
// NOTE(casey): Services that the game provides to the platform layer.
 Listing 5: [handmade.h] Defining the API.

Hopefully, it's clear what's going on here. We largely mirrored the usage we've thought about inside GameUpdateAndRender. The only difference is that our Write function now returns a b32 so we can potentially check if the write was successful.

   

Platform implementation

For each of these functions, we'll ask Windows to do exactly we mentioned in the API. It won't be anything fancy: you should strive to only write the absolute minimum necessary at this stage.

   

Free File Memory

Our free file memory function will be trivial. We'll be using VirtualAlloc for file memory allocation, so we can simply call VirtualFree with MEM_RELEASE flag.

#define DIRECT_SOUND_CREATE(name) HRESULT WINAPI name(...)
typedef DIRECT_SOUND_CREATE(direct_sound_create);
internal void DEBUGPlatformFreeFileMemory(void *Memory) { if (Memory) { VirtualFree(Memory, 0, MEM_RELEASE); } }
internal void Win32LoadXInput()...
 Listing 6: [win32_handmade.cpp] Defining free file memory function.
   

Read file

Read file is a bit more involved but, again, is rather straightforward. It just involves more steps and calling Windows. This is what we need to do:

  1. Get the file handle by calling CreateFileA to open a file.
  2. Get the file size using GetFileSizeEx. We could use GetFileSize but it's a bit antiquated and doesn't really work well in the 64-bit systems.
  3. Allocate memory for that size using VirtualAlloc.
  4. ReadFile contents into that memory.
  5. Finally, CloseHandle to return the file back to Windows.

We will also need to have a debug_read_file_result which we'll return at the end of the function.

internal void
DEBUGPlatformFreeFileMemory(void *Memory)
{
    if (Memory)
    {
        VirtualFree(Memory, 0, MEM_RELEASE);
    }
}
internal debug_read_file_result DEBUGPlatformReadEntireFile(char *Filename) { debug_read_file_result Result = {}; CreateFileA(); GetFileSizeEx(); VirtualAlloc(); ReadFile(); CloseHandle(); return (Result); }
 Listing 7: [win32_handmade.cpp] Sketching out the read file function.

Not a lot of mystery, and actually exactly what you'd expect from a similar function. Let's expand and complete each step.

About Result Definition

You might have noticed that, in our functions, we usually define a Result value at the beginning of the function and clear it to 0 (false/{} or whatever is appropriate depending on its type). We then fill it in somewhere in the middle of the function and eventually return at the very end.

While this structure is not at all necessary, it definitely helps to structure your way of thinking, so you don't lose things along the way, plus you always have a result that you can generally check against 0 to make sure the operation succeeded. It's also error-secure, clean, and helps to verify if everything's ok.

   

Get a Handle

Operating systems love their handles. These systems have all sorts of resources (processes, files, sockets, streams, etc.) and they need to know which one exactly are you talking about when you ask some of these. They might not want to give you the exact thing because of the opaque API or some other reasons, so they give you a handle. Internally, handles might be something like a index in some lookup table or pointers in the kernel, but we really don't care.

In order to get our file handle, we need to call [CreateFileA], which is a general purpose function for this thing. CreateFileA is used to create new files, open existing files, for all sorts of purposes, and it allows you, the programmer, specify which purpose are you after using its various parameters. * lpFileName: file name, we have our Filename which we simply pass. * dwDesiredAccess: what you want to do with the file. For reading, we want a GENERIC_READ. * dwShareMode: It's essentially a lock. Windows uses this flag to know if many people can access the same file, and what can they do with it. We are fine with sharing the read permissions, but not writing or deletion, so that the file isn't removed from under our feet during the following operations. We thus specify the flag FILE_SHARE_READ. * lpSecurityAttributes: This can be used to be able to pass, for example, the handle to another process. We don't really care about it, so we pass 0 (or NULL if that's your cup of tea). * dwCreationDisposition: This complicated-sounding parameter tells Windows what it should do if the file with that name already exists (or doesn't). For reading, we want to specify OPEN_EXISTING which will attempt to open a file if it exists. * dwFlagsAndAttributes: Whether you want the file (if you create it) to be read-only/hidden/etc, as well as some other special system flags. We don't really care about it for reading, 0. * hTemplateFile: Not something we care about,0 once again.

CreateFileA will return INVALID_HANDLE_VALUE if it fails for any reason (file doesn't exist, it's locked, or what have you), so we can check the resulting handle if it's valid (and eventually handle errors if it's not).

internal debug_read_file_result 
DEBUGPlatformReadEntireFile(char *Filename)
{
    debug_read_file_result Result = {};
HANDLE FileHandle = CreateFileA(Filename, GENERIC_READ, FILE_SHARE_READ, 0, OPEN_EXISTING, 0, 0);
if (FileHandle != INVALID_HANDLE_VALUE) {
GetFileSizeEx(); VirtualAlloc(); ReadFile(); CloseHandle();
} else { // Error: handle creation failed // TODO(casey): Logging }
return (Result); }
 Listing 8: [win32_handmade.cpp > DEBUGPlatformReadEntireFile] Getting the File Handle.
   

Get File Size

Now that we have a valid handle to the file, we can ask the system how big that file is. We will call GetFileSizeEx for it. Why not GetFileSize? GetFileSize is the original version of the call which should still work with the old legacy programs. If you have a file bigger than ~4GB, you'll need to do some additional operations to get the final size. GetFileSizeEx (“Ex” standing for “Extended”), on the other hand, fills out a LARGE_INTEGER value the address of which you pass to the function.

We've already seen LARGE_INTEGERs in day 10. We're looking for the FileSize.QuadPart which is the combined 64-bit value representing the file size. The result of this function is a handy BOOL which we can use to make sure the function executed properly.

if (FileHandle != INVALID_HANDLE_VALUE)
{
LARGE_INTEGER FileSize;
if(GetFileSizeEx(FileHandle, &FileSize))
{ // FileSize.QuadPart is the 64-bit value of the size.
VirtualAlloc(); ReadFile();
} else { // Error: File size evaluation failed // TODO(casey): Logging }
CloseHandle(); } else { // Error: handle creation failed // TODO(casey): Logging }
 Listing 9: [win32_handmade.cpp > DEBUGPlatformReadEntireFile] Getting the File Size. We want to close the handle no matter what if we opened it, that's why it remains outside that block.

We don't want to store the FileSize as our ContentsSize just yet; we'll return to it in a short while.

   

Allocate File Memory

You should be intimately familiar with VirtualAlloc by now. It's exactly as simple as you can imagine: Get the file size and allocate memory for it. That said, we wouldn't want to use VirtualAlloc in our final code, since it only reserves memory in large chunks. But for our debug code it's perfectly fine.

if(GetFileSizeEx(FileHandle, &FileSize))
{
Result.Contents = VirtualAlloc(0, FileSize.QuadPart, MEM_RESERVE|MEM_COMMIT, PAGE_READWRITE);
if (Result.Contents) {
ReadFile();
} else { // Error: Memory allocation failed // TODO(casey): Logging }
}
 Listing 10: [win32_handmade.cpp > DEBUGPlatformReadEntireFile] Getting the memory.
   

Read the File Contents

Now we finally can do what we came for... read the file. We do it using the ReadFile function.

Function returns a BOOL so we can verify that the read was successful. We can then to “further nest into success” so that if necessary we can confirm it. While we're at it, we can also make sure that the size we want to read corresponds to the bytes we've read. Once we finished reading, we'll set the ContentsSize to how many bytes we've read.

Result.Contents = VirtualAlloc(...);
if (Result.Contents)
{
DWORD BytesRead;
if (ReadFile(FileHandle, Result.Contents, FileSize.QuadPart, &BytesRead, 0) &&
(FileSize.QuadPart == BytesRead)) { // NOTE(casey): File read successfully Result.ContentsSize = BytesRead; }
}
 Listing 11: [win32_handmade.cpp > DEBUGPlatformReadEntireFile] Reading file contents.

The read can fail (for all sorts of reasons). In such case we should free the memory we reserved just beforehand. We will also set the Result.Contents pointer back to 0, since DEBUGPlatformFreeMemory doesn't do it for us.

DWORD BytesRead;
if (ReadFile(...))
{
    // NOTE(casey): File read successfully
    Result.ContentsSize = BytesRead;
}
else { // Error: Read failed DEBUGPlatformFreeFileMemory(Result.Contents); Result.Contents = 0; // TODO(casey): Logging }
 Listing 12: [win32_handmade.cpp > DEBUGPlatformReadEntireFile] Handling read failure.

About nNumberOfBytesToRead

If you read the documentation carefully, you'll notice that nNumberOfBytesToRead is a DWORD, which is a Win32 type for 32 unsigned int.

This means that, even if we got the 64-bit file size, ReadFile only supports reading up to 32 bits of size (~4GB). If the file were larger than 32 bits, you'd actually need to loop over and do multiple reads. In our debug code we don't really care since we don't expect won't work with big files using debug functions. That said, we'll do the safe truncation described in subsection 6.1!

   

Close Handle

Finally, whether we succeeded in reading the file or not, we should always remember to close the handle. Failing to do so will result in all sorts of nastiness. CloseHandle is a simple call which only takes the handle.

HANDLE FileHandle = CreateFileA(...);
if (FileHandle != INVALID_HANDLE_VALUE)
{
    //...
CloseHandle(FileHandle);
}
 Listing 13: [win32_handmade.cpp > DEBUGPlatformReadEntireFile] Closing the file handle.
   

Write file

DEBUGPlatformWriteEntireFile is quite similar to ReadEntireFile. In fact, we can simply copy the call and edit the things that we don't need:

internal debug_read_file_result
DEBUGPlatformReadEntireFile(char *Filename)
{
    //... 
}
internal b32 DEBUGPlatformWriteEntireFile(char *Filename, u32 MemorySize, void *Memory) { b32 Result = false; HANDLE FileHandle = CreateFileA(Filename, GENERIC_WRITE, 0, 0, CREATE_ALWAYS, 0, 0); if(FileHandle != INVALID_HANDLE_VALUE) { DWORD BytesWritten; if(WriteFile(FileHandle, Memory, MemorySize, &BytesWritten, 0)) { // NOTE(casey): File written successfully Result = (BytesWritten == MemorySize); } else { // Error: Write failed // TODO(casey): Logging } CloseHandle(FileHandle); } else { // Error: Handle creation failed // TODO(casey): Logging } return(Result); }
 Listing 14: [win32_handmade.cpp] Defining write file function.

Important

We said it already, but this write is NOT for doing anything in the shipping game - such a write is blocking and doesn't protect against lost data. Overwrite could fail, leaving the user with a corrupted save file.

In a similar situation what we'll want to do is to write to a different file and either have a rolling buffer scheme where you have an A and a B file, and you write to them interchangeably, or you write to a secondary temp file, release the old file and rename the new file. (Rename can also fail but at least you know you won't run out of space so it's a lesser of two evils).

   

Compile and Test

We should now be compilable! Let's quickly test the changes.

  1. Set the breakpoint (F9) at the beginning of your GameUpdateAndRender function.
  2. Step into (F11) DEBUGPlatformReadEntireFile.
  3. Step over (F10) all the lines in DEBUGPlatformReadEntireFile until you (hopefully) arrive the its core. You should successfully read the contents of the file.
  4. Step over until the return of the function.
  5. Once back in GameUpdateAndRender, add FileData to the Watch window.
  6. Open the structure in the Watch window, copy the value of Contents and paste it in the Memory window (some debuggers also allow typing directly the variable name, i.e. FileData.Contents).

 Figure 3: Inspecting the file memory in the debugger.

  1. In the Memory window, you should see the contents of your memory. Next to them you'll also see their conversion to ASCII (if you don't see the ascii conversion, right click on the memory window and enable it, or resize the window). You'll notice that that's your file contents!
  2. Step into DEBUGPlatformWriteEntireFile.
  3. If you open your Command Prompt, and type dir inside your data folder after each step, you'll notice that the file test.out will appear, but it will have zero size up until you close the handle.

 Figure 4: Inspecting the data folder in the command prompt.

  1. Step over DEBUGPlatformFreeFileMemory. You'll notice that the contents of FileData.Contents will disappear.

   

Recap

We made one simple but powerful addition to our code: we can now read and write from any file we want. It's nowhere near as robust as we want it to, yet we can achieve a lot more now for our debug purposes.

Our platform code is finally complete for the first pass. We now can finish “tightening the screws” on the API, do the general cleanup and finally transition away into our game!

   

Programming Notions

   

Intro to Struct Packing

You've seen us create a number of structs already, big and small. We'll be working on many, many more in the future. But for now, let's compare these two structs:

struct A
{
    u8 X;
    u32 Y;
    u8 Z;
    u8 U;
    u8 V;
};

struct B {
    u32 Y;
    u8 X;
    u8 Z;
    u8 U;
    u8 V;
};

You'll notice that these two structs are basically identical, except Y variable position changed. Does it matter for the struct? As it turns out, yes, order of struct components matters! struct A is in fact much bigger than struct B, 12 bytes vs. 8!

It has to do with how C packs and pads the structs. Memory is cheap but performance? Not necessarily. If you have tens of thousands of these structs, any byte extra might be critical for the performance.

   

Side Considerations

   

Safe Truncation

If we pass a 64-bit value as a 32-bit parameter, the system will do what is known as “implicit conversion”, truncating away the upper 32 bits. This of course can be a problem if you have data in those upper 32 bits.

In our DEBUGPlatformReadEntireFile, we do such an implicit conversion from the 64-bit FileSize.QuadPart to the 32-bit DWORD.

To make sure we didn't have anything in our upper bytes, we can Assert that our QuadPart isn't bigger than the maximum 32-bit value, 0xFFFFFFFF. We can be even more explicit and cast our value to (u32) ourselves.

Let's write a separate helper function, stored inside handmade.h, doing just that:

#define ArrayCount(Array) (sizeof(Array) / sizeof((Array)[0]))
inline u32 SafeTruncateUInt64(u64 Value) { Assert(Value <= 0xFFFFFFFF); u32 Result = (u32)Value; return (Result); }
 Listing 15: [handmade.h] Defining safe truncate of u64.

We now can use this value in our code:

LARGE_INTEGER FileSize;
if(GetFileSizeEx(FileHandle, &FileSize))
{
u32 FileSize32 = SafeTruncateUInt64(FileSize.QuadPart);
Result.Contents = VirtualAlloc(0, FileSize32, MEM_RESERVE|MEM_COMMIT, PAGE_READWRITE);
if (Result.Contents) { DWORD BytesRead;
if (ReadFile(FileHandle, Result.Contents, FileSize32, &BytesRead, 0) && (FileSize32 == BytesRead))
{ //... } } // ... }
 Listing 16: [win32_handmade.cpp > DEBUGPlatformReadEntireFile] Using our 32-bit file size.

Why the assertion anyway? If this code is used in a way we didn't anticipate, we'll catch it in time before bigger issues arise. It's always good to catch issues at their source. Mysterious bugs happening in the middle of your multithreaded code are not fun to hunt for.

(Continue to subsection 2.1)

   

Navigation

Previous: Day 14. Platform-Independent Game Memory

Previous: Day 16. Visual Studio Compiler Switches

Back to Index

Glossary

MSDN

CloseHandle

CreateFileA

GetFileSizeEx

ReadFile

VirtualAlloc

VirtualFree

WriteFile

Articles

Media latency

formatted by Markdeep 1.10