Reading Data from Files

Learn how to read and parse game data stored in external files using SDL_RWops
This lesson is part of the course:

Game Dev with SDL2

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

Free, Unlimited Access
Abstract art representing computer programming
Ryan McCombe
Ryan McCombe
Posted

In this chapter, we’ll introduce read/write operations, specifically using SDL_RWops. This is the API that SDL provides for reading from and writing to files on our players’ hard drives.

Many of the topics covered here additionally lay the foundation for more advanced capabilities. Streaming data to and from files has much in common with streaming data over a network, for example, which is fundamental for creating multiplayer games.

In this lesson, we’ll create a basic SDL application that calls Write() and then Read() within a File namespace we’ll create. The file I/O system is initialized by default, so we can simply pass 0 as the initialization flag to SDL_Init():

#include <SDL.h>
#include "File.h"

int main(int argc, char** argv) {
  SDL_Init(0);
  File::Write("data.txt");
  File::Read("data.txt");
  return 0;
}

The following code example shows the content of our File namespace.

We’ll focus on the Read() function in this lesson and will cover writing in the next lesson. For now, we can just note that Write() creates a file on our hard drive containing the text "Hello World", so we have something to test our Read() function with:

// File.h
#pragma once
#include <iostream>
#include <SDL.h>

namespace File{
  void Read(const std::string& Path) {}
  
  void Write(const std::string& Path) {
    SDL_RWops* Context{
      SDL_RWFromFile(Path.c_str(), "wb")};
    const char* Content{"Hello World"};
    SDL_RWwrite(Context, Content, sizeof(char),
                strlen(Content));
    SDL_RWclose(Context);
  }
}

Reading an Entire File

The simplest way to get data from a file is to read the entire file using the SDL_LoadFile() function. This function receives a C-style string specifying the file we want to load, and a second pointer argument. We’ll pass a nullptr there for now:

SDL_LoadFile(Path.c_str(), nullptr);

This function returns a void pointer to a memory location managed by SDL. This points to the location where the data was read to.

When we no longer need the data, we should prompt SDL to free it, preventing any memory leaks:

void Read(const std::string& Path) {
  void* Content{
    SDL_LoadFile(Path.c_str(), nullptr)
  };
  
  // Use the data...
  
  SDL_free(Content);
}

File Size

The second argument to SDL_LoadFile() allows us to discover how much data was read. If we pass a pointer to that parameter, the value will be updated with the number of bytes that were read.

In this example, our file contains "Hello World". That is 11 characters (of type char) including the space, each occupying one byte:

void Read(const std::string& Path) {
  size_t Size;
  void* Content{
    SDL_LoadFile(Path.c_str(), &Size)
  };
  std::cout << "Loaded " << Size << " bytes";
  SDL_free(Content);
}
Loaded 11 bytes

Error Handling

SDL_LoadFile() will return a null pointer if there was an error. As usual, we can get details about the error by calling SDL_GetError():

void Read(const std::string& Path) {
  void* Content{SDL_LoadFile(
    "does-not-exist.txt",
    nullptr
  )};
  if (!Content) {
    std::cout << "Error loading file: " << SDL_GetError();
  }
  SDL_free(Content);
}
Error loading file: Parameter 'src' is invalid

Using the Data

A void pointer is not directly useful, so we’ll need to cast it to an appropriate type before we can meaningfully work with the content.

For convenience, the SDL_LoadFile() function inserts a null terminator at the memory location immediately after the data it read, so we can use the pointer as a null-terminated string, such as a char*:

void Read(const std::string& Path) {
  char* Content{
    static_cast<char*>(
      SDL_LoadFile(Path.c_str(), nullptr)
    )};
  std::cout << Content;
  SDL_free(Content);
}
Hello World

As with any C-style string, we can use it to construct a std::string if we wish. This constructor copies the null-terminated string into a new memory location managed by the std::string, so we can safely free the original memory address as soon as our new string is constructed:

void Read(const std::string& Path) {
  void* RawContent{SDL_LoadFile(
    Path.c_str(), nullptr)};

  std::string Content{
    static_cast<char*>(RawContent)};
  SDL_free(RawContent);

  std::cout << Content;
}
Hello World

Note if we’re passing a second argument to retrieve the size of the data that was read, the null terminator is not included in the returned result.

In this example, SDL has allocated 12 bytes - one for each character of Hello World and an additional byte for the null terminator \0. However, the size argument will be set to 11, as our file contains only 11 bytes.

SDL_RWops

When working with files in real use cases, we typically don’t read the entire file at once. We usually need much more granular control. To help with this, SDL provides the SDL_RWops type, designed to generalize interactions with files across the variety of platforms SDL supports.

We can create an SDL_RWops object associated with a file using the SDL_RWFromFile() function. This accepts two arguments - the file we want to open, and the file open mode.

We’ll discuss file open modes in more detail in the next lesson. For now, we’ll just pass "rb" (meaning read in binary) as that argument:

SDL_RWFromFile("some-file.txt", "rb")

The SDL_RWops created from this function is stored internally within SDL, but the function returns a pointer to it that we can use. When we no longer need it, we pass it to SDL_RWclose(), allowing SDL to release it and prevent memory leaks.

In the following example, we’ll call our pointer Handle:

void Read(const std::string& Path) {
  SDL_RWops* Handle{
    SDL_RWFromFile(Path.c_str(), "rb")
  };
    
  // Use Handle, then close it
  // ...
  
  SDL_RWclose(Handle);
}

File Handles

Behind the scenes, SDL_RWFromFile() creates what is often referred to as a file handle. This handle tells the operating system that our program is currently using the file. SDL_RWclose() closes the file handle, telling the operating system we’re no longer using the file.

Having an open file handle can prevent other functions within our program from using that same file, and can even disrupt the functionality of other applications running on the system.

You may have experienced frustrating "another program is using this file" errors when using your computer. These are caused by programs keeping file handles open for longer than they need to. As a general best practice, we should only create the file handle at the moment we need to access the file and should close it as soon as we no longer need access.

Error Checking

SDL_RWFromFile() returns a null pointer if it is unable to open the file. We can call SDL_GetError() to retrieve the error information:

void Read(const std::string& Path) {
  SDL_RWops* Handle{
    SDL_RWFromFile(Path.c_str(), "rb")
  };
    
  if (!Handle) {
    std::cout << "Error loading file: "
      << SDL_GetError();
  }
  
  SDL_RWclose(Handle);
}

SDL_RWsize()

We can retrieve the size of a stream managed by SDL_RWops object by passing its pointer to SDL_RWsize(). This function returns a 64-bit signed integer. When the SDL_RWops is associated with a file, this return value will be the size of the file:

void Read(const std::string& Path) {
  SDL_RWops* Handle{
    SDL_RWFromFile(Path.c_str(), "rb")};

  std::cout << "Size: " << SDL_RWsize(Handle);

  SDL_RWclose(Handle);
}
Size: 11

If SDL_RWsize() cannot determine the size, it returns a negative value. We can call SDL_GetError() to understand the problem:

void Read(const std::string& Path) {
  SDL_RWops* Handle{
    SDL_RWFromFile(Path.c_str(), "rb")};

  Sint64 Size{SDL_RWsize(Handle)};
  if (Size < 0) {
    std::cout << "Cannot determine size: "
      << SDL_GetError();
  }

  SDL_RWclose(Handle);
}

Reading an Entire File using SDL_RWops

An alternative to the SDL_LoadFile() function we introduced earlier is available for when we want to read all the content associated with an SDL_RWops object.

The function is SDL_LoadFile_RW(), and it requires three arguments:

  • A pointer to the SDL_RWops we want to read.
  • A pointer to an integer, which will be updated with the number of bytes that were read. Can be a nullptr if we don’t care.
  • A boolean, representing whether we want the file handle to be closed when the read is complete. Setting this to true is equivalent to calling SDL_RWclose() after SDL_LoadFile_RW() completes:
size_t Size;
SDL_LoadFile_RW(Handle, &Size, true);

As with SDL_LoadFile(), this function returns a void pointer to the ingested content or a null pointer on error. We should cast this to an appropriate type to use it and to prevent memory leaks, call SDL_Free() once we’re done with it.

As before, a null terminator is inserted at the end of the content, so we can directly use it as a C-style string:

void Read(const std::string& Path) {
  SDL_RWops* Handle{
    SDL_RWFromFile(Path.c_str(), "rb")};

  void* Content{
    SDL_LoadFile_RW(Handle, nullptr, false)};

  if (!Content) {
    std::cout << "Error loading file: "
      << SDL_GetError();
  }

  std::cout << static_cast<char*>(Content);
  SDL_RWclose(Handle);
  SDL_free(Content);
}
Hello World

If we pass true to the third argument of SDL_LoadFile_RW(), the handle will be closed when the function completes. This means we can no longer use it, but it removes the need to call SDL_RWclose():

void Read(const std::string& Path) {
  SDL_RWops* Handle{
    SDL_RWFromFile(Path.c_str(), "rb")};

  void* Content{
    SDL_LoadFile_RW(Handle, nullptr, true)};

  // We no longer need to close the handle:
  SDL_RWclose(Handle); 

  // We still need to free the content:
  SDL_free(Content);
}

Reading Part of a File

For reasons we’ll explore later in the chapter, it’s fairly uncommon that we read the entire contents of a file. In a real use case, such as creating a save file for a video game, our files will contain a lot of data, from a variety of types.

Reading all of it at once is not particularly useful. Instead, the objects of our program will want to read just the parts of the file that are necessary to perform their task.

The benefit of SDL_RWops over the simpler approach of SDL_LoadFile() is that it gives us the control to do this.

SDL_RWread()

The SDL_RWread() function is our primary tool for reading partial file content. It offers precise control over how much data we read and where we store it. Let's break down its four arguments:

  1. The SDL_RWops handle for the file we're reading from
  2. A pointer to the memory location where we want to store the read data
  3. The size (in bytes) of each object we're reading
  4. The number of objects to read

Let’s start by recreating the previous example where we read the entire file into a C-style string, but this time using SDL_RWread().

In this example, we allocate 12 bytes to store the contents of our file (11 bytes) with an additional space for a null terminator. Unlike before, SDL_RWread() does not provide this null terminator, so we need to add it ourselves.

Remember, array indices start at 0, so if we read 11 bytes into our array, those bytes will run from index 0 to index 10. The last element of an array with a size of 12 is at index 11, which is where we place the null terminator in this example:

void Read(const std::string& Path) {
  SDL_RWops* Handle{
    SDL_RWFromFile(Path.c_str(), "rb")};

  char Content[12];
  SDL_RWread(Handle, Content, 1, 11);
  Content[11] = '\0';

  std::cout << Content;

  SDL_RWclose(Handle);
}
Hello World

Note in this example we know the size of our file is 11 bytes, so can size our char array at compile time. Later in this lesson, we have an example where we read a file with an unknown number of bytes into a std::string.

Return Value

The SDL_RWread() function returns an integer representing the number of bytes that were read, which may be fewer than requested. This can be a result of an error within the SDL subsystem, in which case SDL_GetError() can provide details.

More commonly, it will be because we simply requested more bytes than the file contains. This could represent an error or an intentional design choice in how we wrote our code.

In the following example, we request 20 bytes to be read, but our code still works correctly if the file has fewer because we’re dynamically positioning the null terminator based on the value returned from SDL_RWread():

void Read(const std::string& Path) {
  SDL_RWops* Handle{
    SDL_RWFromFile(Path.c_str(), "rb")};

  char Content[21];
  size_t BytesRead{SDL_RWread(Handle, Content, 1, 20)};
  
  if (BytesRead < 20) {
    std::cout << "Only read " << BytesRead
      << " bytes\n"
      << "Error:" << SDL_GetError() << '\n';
  }
  
  Content[BytesRead] = '\0';
  std::cout << "Content: " << Content;;
  SDL_RWclose(Handle);
}
Only read 11 bytes
Error: 
Content: Hello World

Read/Write Offsets

Behind the scenes, SDL_RWops objects keep track of which position we last read from. This is typically referred to as the byte offset within SDL, but terms like read/write position are also commonly used. This is the key mechanism that allows us to control where we read from, or where we write to within our files.

SDL updates this byte offset automatically as we read from our file. This becomes relevant any time we read content from the same SDL_RWops object multiple times.

For example, when we perform a read operation using SDL_RWread(), SDL will position the byte offset at the end of the content we read. When we perform another read operation, we pick up where we left off. That is, we’ll start reading from where the byte offset is, rather than from the start of the file.

We can see an example of this below, where we read 5 bytes from our file, then 5 bytes again:

void Read(const std::string& Path) {
  SDL_RWops* Handle{
    SDL_RWFromFile(Path.c_str(), "rb")};

  char Content[6];
  Content[5] = '\0';

  SDL_RWread(Handle, Content, 1, 5);
  std::cout << "Read 1: " <<  Content;
  SDL_RWread(Handle, Content, 1, 5);
  std::cout << "\nRead 2: " << Content;

  SDL_RWclose(Handle);
}
Read 1: Hello
Read 2:  Worl

We’ll cover this concept in much more detail later in the chapter, including why it’s useful and how to control it.

Practical Examples

Let’s end this section by using these techniques in more complex contexts.

Reading Text to a std::string

In this example, we read content from a file directly into a std::string. In addition to the normal advantages a std::string has over a C-style string, this example does not require us to know the size of the content at compile time.

It uses SDL_RWsize() to dynamically scale the std::string to ensure we allocate enough memory to store our content.

This example uses slightly more advanced knowledge of the std::string class than we’ve introduced so far. We cover this class in much more detail in a dedicated chapter in our advanced course, but the key points to note for this example are the following:

  • The std::string class has a constructor that accepts two arguments - the size of the string, and the character we want the string to be initially filled with. We’re using that constructor in this example to create a string with enough space to store what’s in our file. The string is initially filled with null terminators - \0 - but we later overwrite those with the content from our file using SDL_RWread().
  • The internal representation of std::string doesn't rely on null-termination for determining its length, so we don't need to allocate an additional byte for a null terminator. std::string objects keep track of their length using a member variable, which is made available to us through a method called length().
  • As with any array, std::string objects keep their underlying characters in a contiguous block of memory. The first character is available at MyString[0]. As such, the address of the start of this array is &MyString[0]. By writing to this location, we replace the initial content of our string (the null terminators) with the content from our file.
#include <SDL.h>
#include <iostream>
#include <string>

#include "File.h"

// Function to read file into std::string
std::string ReadString(const std::string& Path) {
  SDL_RWops* Handle = SDL_RWFromFile(
    Path.c_str(), "rb");
    
  if (!Handle) {
    std::cout << "Could not open file "
      << Path << " - " << SDL_GetError();
    return "";
  }

  // Determine the file size
  Sint64 Size{SDL_RWsize(Handle)};
  if (Size < 0) {
    std::cout << "Could not read file size "
      << Path << " - " << SDL_GetError();
    SDL_RWclose(Handle);
    return "";
  }

  // Allocate a string with the required size
  std::string Content(Size, '\0');

  // Read the file content into the string
  size_t BytesRead{SDL_RWread(
    Handle, &Content[0], 1, Size
  )};
  if (BytesRead != Size) {
    std::cerr << "Could not read entire file "
      << Path << " - " << SDL_GetError();
    SDL_RWclose(Handle);
    return "";
  }

  // Close the file
  SDL_RWclose(Handle);

  return Content;
}

int main(int argc, char** argv) {
  SDL_Init(0);
  File::Write("data.txt");
  std::cout << ReadString("data.txt");
  return 0;
}
Hello World

Reading Comma-Separated Values (CSVs) to an Array

In this example, we’ll see how we can read a collection of values from a file. A common way to represent multiple values within a single string is simply to separate them with some special character that denotes where one value ends and the next begins.

A comma - , - is commonly used as this special character. For example, using this approach to store three values - One, Two, and Three - would look like this:

"One,Two,Three"

If our values could themselves contain commas, we’d need to use a different character. For example:

"Toy Story|Monsters, Inc.|Finding Nemo"

First, let’s update our File::Write() helper so we have a file containing comma-separated values to work with:

#include <iostream>
#include <SDL.h>

namespace File {
  void Write(const std::string Path) {
    SDL_RWops* Context{
      SDL_RWFromFile(Path.c_str(), "wb")};
      
    const char* Content{"One,Two,Three"};
    
    SDL_RWwrite(Context, Content, sizeof(char),
                strlen(Content));
    SDL_RWclose(Context);
  }
  // ...
}

To parse this file, we can rely on the byte offset technique we recovered earlier, where SDL_RWread() automatically advanced the read position. We use this by calling SDL_RWread() in a loop, where each invocation reads the next character.

This loop continues as long as there are more characters to read - that is, as long as SDL_RWread() returns 1, indicating it successfully read a byte. We append each new character into a std::string called CurrentValue using the += operator.

However, if the character we read is a comma, that indicates the end of the current value. In that case, we push the CurrentValue onto the Values vector, and clear CurrentValue so it’s ready to start accumulating the characters for the next value.

#include <SDL.h>
#include <vector>
#include <string>
#include "File.h"

std::vector<std::string> ReadCSV(
  const char* Path
) {
  SDL_RWops* Handle{SDL_RWFromFile(Path, "rb")};
  if (!Handle) {
    std::cout << SDL_GetError();
  }

  std::vector<std::string> Values;
  std::string CurrentValue;
  char c;

  while (SDL_RWread(Handle, &c, 1, 1) == 1) {
    if (c == ',') {
      Values.push_back(CurrentValue);
      CurrentValue.clear();
    } else {
      CurrentValue += c;
    }
  }

  // Don't forget the last value
  if (!CurrentValue.empty()) {
    Values.push_back(CurrentValue);
  }

  SDL_RWclose(Handle);
  return Values;
}

int main(int argc, char** argv) {
  SDL_Init(0);
  File::Write("data.txt");

  std::vector Values{ReadCSV("data.txt")};
  for (std::string& Value : Values) {
    std::cout << "Value: " << Value << '\n';
  }

  return 0;
}
Value: One
Value: Two
Value: Three

Non-String Data Types

So far, we’ve focused on reading only text-based data. By the end of this chapter, we’ll have widened our knowledge to reading and writing any type of data in our program, including custom types that we define by creating classes or structs.

However, we can also represent any data type using text, if we wish. For example, the string "42" could be conceptually understood as the int value 42, and the string "3.14" could be understood as the float value 3.14f

Further, an object comprising three variables - a string, an int, and a float could be represented by a comma-separated string, such as "Hello,42,3.14".

Let’s create an example of such a type:

// Player.h
#pragma once
#include <iostream>

struct Player {
  std::string Name;
  int Level;
  float Armor;

  void Log() {
    std::cout << "Name: " << Name
      << "\nLevel: " << Level
      << "\nArmor: " << Armor;
  }
};

We’ll also update our File::Write helper to create a file to represent an instance of this class:

// File.h
// ...

namespace File {
  void Write(const std::string Path) {
    SDL_RWops* Context{
      SDL_RWFromFile(Path.c_str(), "wb")};
      
    const char* Content{"Anna,42,0.25"};
    
    SDL_RWwrite(Context, Content, sizeof(char),
                strlen(Content));
    SDL_RWclose(Context);
  }
  // ...
}

To convert this string to a Player object, we need the capability to read the first value (Anna) as a std::string, the second value (42) as an int, and the third value (0.25) as a float.

Let’s create a ReadString() function that reuses the technique from our previous example. This function builds a std::string by reading our file character-by-character until we encounter a comma, or there are no more characters to read:

std::string ReadString(SDL_RWops* Handle) {
  std::string CurrentValue;
  char c;

  while (SDL_RWread(Handle, &c, 1, 1) == 1) {
    if (c == ',') {
      return CurrentValue;
    }
    CurrentValue += c;
  }
  return CurrentValue;
}

We can interpret a std::string as an int using the std::stoi() (string-to-int) function, or as a float using the std::stof() (string-to-float) function.

Let’s add ReadInt() and ReadFloat() functions. These functions will call ReadString() to get the next string in our comma-separated value, and convert it to the appropriate type:

int ReadInt(SDL_RWops* Handle) {
  return std::stoi(ReadString(Handle));
}

float ReadFloat(SDL_RWops* Handle) {
  return std::stof(ReadString(Handle));
}

Let’s now use these functions to retrieve the arguments to pass to our Player constructor. Remember, SDL_RWops keeps track of the byte offset between reads. As such:

  • The first call to ReadString() will read Anna, from our file, and return the string "Anna" to our main() function
  • The second call will read 42, from our file and return the string "42" to the ReadInt() function. This function will then pass that string to std::stoi(), which will return the integer 42
  • The third call will read 0.25 from our file and return the string "0.25" to the ReadFloat(), function. This function will then pass that string to std::stof(), which will return the floating point number 0.25
#include <SDL.h>
#include <string>

#include "Player.h"
#include "File.h"

std::string ReadString(SDL_RWops* Handle) {
  std::string CurrentValue;
  char c;

  while (SDL_RWread(Handle, &c, 1, 1) == 1) {
    if (c == ',') {
      return CurrentValue;
    }
    CurrentValue += c;
  }
  return CurrentValue;
}

int ReadInt(SDL_RWops* Handle) {
  return std::stoi(ReadString(Handle));
}

float ReadFloat(SDL_RWops* Handle) {
  return std::stof(ReadString(Handle));
}

int main(int argc, char** argv) {
  SDL_Init(0);
  File::Write("data.txt");

  SDL_RWops* Handle{
    SDL_RWFromFile("data.txt", "rb")};

  Player PlayerOne{
    ReadString(Handle),
    ReadInt(Handle),
    ReadFloat(Handle)
  };
  SDL_RWclose(Handle);

  PlayerOne.Log();

  return 0;
}
Name: Anna
Level: 42
Armor: 0.25

Storing our data in text in this way has the advantage of being human-readable. However, there is a performance cost to converting between text-based representations and the binary format that types such as int and float actually use.

Typically, we don’t require our data to be human-readable. In most use cases, the data is written by our program, and read by our program, so this intermediate text-based format is unnecessary. Later in this chapter, we’ll see how we can store objects in their native format to remove these conversion costs and maximize performance.

Summary

In this lesson, we've explored SDL's file I/O capabilities using the SDL_RWops API. We've learned how to read entire files, work with file offsets, and handle different data types.

We’ll build upon these techniques through the rest of this chapter, by which point we’ll have all the tools we need to implementing any behavior our games might need when working with external files.

Was this lesson useful?

Next Lesson

Writing Data to Files

Learn to write and append data to files using SDL2's I/O functions.
Abstract art representing computer programming
Ryan McCombe
Ryan McCombe
Posted
sdl2-promo.jpg
This lesson is part of the course:

Game Dev with SDL2

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

Free, Unlimited Access
Reading and Writing (RWops)
    63.
    Reading Data from Files

    Learn how to read and parse game data stored in external files using SDL_RWops


sdl2-promo.jpg
This lesson is part of the course:

Game Dev with SDL2

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

Free, unlimited access

This course includes:

  • 67 Lessons
  • 100+ Code Samples
  • 91% Positive Reviews
  • Regularly Updated
  • Help and FAQ
Next Lesson

Writing Data to Files

Learn to write and append data to files using SDL2's I/O functions.
Abstract art representing computer programming
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved