the settings incident
teaching khel* to read a settings file, in three acts
published by Exo on 00Q02
"Figuring things out for yourself is the only freedom anyone really has. Use that freedom."
--
during the deeply enjoyable process[1] of writing critical systems for khel*, i had to spend a significant amount of time and energy on figuring out how to get the game to read a settings file from the disk.[2]
this article describes the three approaches i tried taking to solve this problem.
the problem
Khel needs to store settings.
while the game won't have many settings, there are at least a few that will be useful to it; window scaling is one setting that has already come up while organizing the codebase.
i decided to store settings using the INI format, both because it is very simple and because it should also work well for storing Khel's charts in the future.
the INI format simply consists of key-value pairs (two values separated by an equals sign =), which may be grouped into sections (whose names are in square brackets []).
in Khel's case, the settings.ini file might look something like this:
[settings] window_scale = 1 volume = 50 downscale_assets = false
for the purposes of this article, we will skip over the code for the Khel_Setting struct, a group of which determines which keys are accepted by the game, and what their default values are.
let's say that these were the only things we had to do:
- read the
settings.inifile line by line; - make sure that the section name is
[settings]; - split key-value pairs into their two parts for further processing;
- ignore any lines which are invalid.
take 1: strtok_r
i first tried using the strtok_r function, which SDL3 provides its own implementation of.
this is its function signature:
char * SDL_strtok_r(char *str, const char *delim, char **saveptr);
it takes a string to split into tokens; a string containing all of the characters to split on; and a double pointer to store state for successive calls.[3]
two functions, Khel_ReadHeader[4] and Khel_ReadKeyValue, which read one line and expect it to contain that thing, are enough to get things working; we also use two save pointers, one to split the file data on \n and another to split each key-value pair on =.
this being the first attempt, it was bound to fall short somewhere. the logic was mostly fine - read the section name, then read key-value pairs in a loop, matching each key against a lookup table to determine valid, invalid, and missing ones - but the code[5] was a mess:
- it used compile-time constants to set limits on the size of section names, keys, and values;
- it used
SDL_strlcpy(on valid reads) andSDL_zerop(on invalid reads) to deal in everything on its own; - it had two different return types for its two primary functions (
char*for headers andintfor key-value pairs); - and, of course, it used
strtok_rwith two save pointers instead of just one.
it was so overwrought that the game even started producing a warning saying that a key-value pair was malformed, because it wasn't correctly handling the trailing \n at the end of the file. surely we can do better than that?
let's make an interesting realization and try again.
take 2: Khel_LineReader
while reviewing some of the other string functions provided by SDL3, i saw strchr:
char * SDL_strchr(const char *str, int c);
it takes a string and a character to search for, and it returns a pointer to the first instance of that character in the string.
if the pointer is not NULL, we know that the character is there.
let's review some important facts about pointers and strings:
- pointers are addresses to other things in memory, which means they decay to integers.
- all of the characters in a string are contiguous in memory. characters which appear later always have a higher memory address.
given these facts, what would happen if we tried using addition and subtraction on a string pointer?
well, it then becomes very useful in writing the function Khel_strll[6]:
int Khel_strll(char *src) {
if (src[0] == '\0') {
return 0;
}
char *rest = SDL_strchr(src, '\n') + 1;
return rest - src;
}
if we add 1 to the location of the first \n, we get the address of the entire rest of the string; since that address is greater than that of src, if we subtract src from it, we get the length of the first line.
when i started to work with this function, i had structs on my mind - i had been thinking about the possibility of wrapping strtok_r - so i ended up making up something called Khel_LineReader:
typedef struct Khel_LineReader {
char *dst;
char *src;
int maxlen;
} Khel_LineReader;
src is a string; dst stores the result of each read operation on that string; and maxlen is the maximum line length that the struct should accept.
using a new Khel_ReadLine function which takes a Khel_LineReader, we can read a line and add its length to src, effectively seeking the pointer forward so the next line can be read; the existing functions were rewritten to use the new struct, storing the read data in buffers for us using SDL_sscanf[7], which also means their return types can match (both can become int).
much better!
i ended up working on the code[8] for about eight hours (well past the point of fatigue!), ultimately staying awake until 2:30AM to finish and commit it. this is the point at which i started to humorously call this journey the settings incident.
when i woke up, i looked over the code again, and noticed that two code paths both returned the same value: reads which reached EOF (like if the src pointer had no lines left), and reads which were invalid, but did not reach EOF (like if we wrote I like gummy worms in the middle of the file.)
to make those paths unique, i thought it might be reasonable to add an enum to Khel_LineReader to denote its status.
i quickly realized, however, that this was all starting to sound a lot like a struct that SDL3 already provides.
maybe we could use that instead?
let's look at some documentation and try one last time.
take 3: SDL_IOStream
SDL_IOStream is SDL3's own structure for read/write operations.
in fact, it's the structure i had been using this whole time to get the data out of the settings file in the first place!
it allows us to do all of the following:
- read a number of bytes from a data source (
SDL_ReadIO); - determine our offset in the data source (
SDL_TellIO); - seek to a specific offset in the data source (
SDL_SeekIO); - access the data source pointer directly (via
SDL_GetIOProperties).
in addition to the I/O stream which reads the data from the settings.ini file into a char[], we can use SDL_IOFromConstMem to open a second I/O stream which uses that char[] as its data source; we can still use Khel_strll to find out the length of a line, but since SDL_IOStream can tell us our offset at any time, we no longer need to overwrite the source pointer to read the next line.
we can even determine if a read reaches EOF, thanks to the SDL_IOStatus enum and the SDL_GetIOStatus function!
this code[9] took an additional four or five hours, a good portion of which was spent figuring out how to definitively seek the stream to EOF (which did not help with the fatigue from the day before); however, i am much happier with this version than i am with the two versions that came before it. third time's the charm!
in conclusion
this is the first "article" i've published to sporeball.dev, and it's intentionally light on the specific details of function definitions - rather, i wanted to remember the story behind the work, and preserve my reasoning for reference in the future, like some kind of long-form {spellbook}.
the fact is, i've really enjoyed learning more about C99 and closely reading the SDL3 documentation while developing Khel so far, and this process was no exception.
it was a good way to remind myself that it's better to write a solution to a problem before trying to make it the best solution.
at the time of writing, Khel will be receiving an object system next; i look forward to finding out if its implementation will lead to any other interesting stories.
see you later!
--
footnotes
[1] this is completely genuine.
[2] chances are, if you give a C program a file, it's going to need a lot of string handling to go with it. in retrospect, this shouldn't have come as a surprise.
[3] the ability to keep track of state this way makes this function "reentrant" (which is where the r comes from.)
[4] this function was originally named the way it is because i hadn't yet learned that the INI format calls them "sections" and not "headers". oops!
[5] commit 395bab6c, "settings.c complete"
[6] the version of this function reproduced here is missing a null check against rest, meaning it misbehaves if there is no \n in the string at all. this was fixed during the production of this article.
[7] these are actually values of type char[maxlen], declared in advance; this also means that we can remove the fine-grained compile-time constants from "take 1", leaving only one: KHEL_SETTINGS_LINE_LENGTH, which is equal to 80.
[8] commit 8adefdce, "overhaul settings io"
[9] commit 3102f131, "use iostreams for settings io instead"