Today I have published an extension that offers a wide variety of functions for working with INI files in GameMaker.
In doing so, it also does a better job than the built-in functions in many aspects.
This is a blog post detailing the advantages and technical details.
General
GameMaker's API offers a set of ini_ functions that allow to work with one file at once, such as ini_open(path), ini_write_string(section, key, value), ini_close().
My extension offers file_ini_ functions which are structured much akin to default ones, but allow to work with multiple files at once - file_ini_open(path) returns a reference to the file, and all other functions accept it as first argument (like the standard file_ functions), so you have file_ini_write_string(file, section, key, value, file_ini_close(file), etc.
This makes it easy enough to port existing code over.
Technical
As with many things I do, the extension is largely written in Haxe.
For native targets, it's compiled to GML (via a modified JavaScript generator), and makes extensive use of buffers to reach appropriate reading/writing speeds.
On JavaScript-based targets, reading/writing via raw JS String functions is actually faster than using GameMaker's buffer functions, so a separate JavaScript module is compiled with those.
While a slightly strange layout, this allows to reach appropriate performance on all target platforms without porting the code changes back and forth between GML and JS.
Format and order
INI format is originally intended for use for storing configuration ("initialization") in a human-readable/editable format, but many implementations, that of GameMaker included, tend to degrade and discard user-defined formatting on save - sections and keys are reordered and comments vanish:
Original INI
[test] ; for a test a=hello ; <-[1] c=world ; <-[2] b=4 |
GML code
ini_open("test.ini"); ini_write_string("test", "c", "hi"); ini_write_string("test", "d", "~"); ini_close(); |
Resulting INI
[test] d="~" c="hi" b="4" a="hello" |
That's not ideal - if you were to not care for user's ability to manage the files, you could have as well used JSON. My extension handles things better:
Original INI
[test] ; for a test a=hello ; <-[1] c=world ; <-[2] b=4 |
GML code
var q = file_ini_open("test.ini"); file_ini_write_string(q, "test", "c", "hi"); file_ini_write_string(q, "test", "d", "~"); file_ini_close(q); |
Resulting INI
[test] ; for a test a=hello ; <-[1] c=hi ; <-[2] b=4 d=~ |
As can be seen, the items maintain the original order, and the comments remain intact - even the comment after a changed item.
Internally, this is accomplished by keeping track of file structure even after initial the read - each section is represented as a list of items that can be a key-value pair, whitespace (incl. linebreaks), or a comment;
For reading/writing, a separate hashtable is used for quick key -> pair lookups;
When removing items, the item itself is removed, as well as whitespace preceding it (if available) and a comment following it (if on the same line)
Overall, the extension maintains the file structure as precisely as possible, making it a perfect fit for actual (editable) configuration files.
Numbers
The following is something that slightly bothered me for a while with built-in functions - for whatever reason, numeric values are always written with 6-digit precision, and in quotes:
ini_open("test.ini"); ini_write_real("test", "one", 1); ini_write_real("test", "one5", 1.5); ini_write_real("test", "3qrt", 3/4); ini_write_real("test", "small", 7/256); ini_close(); |
[test] small="0.027344" 3qrt="0.750000" one5="1.500000" one="1.000000" |
This means that in most situations they either have too much or not enough precision for the task.
My extension, on other hand, goes that tiny length to truncate unneeded zeroes at the end of a number, and to not display the decimal part at all if the number doesn't have it to begin with:
var q = file_ini_open("test.ini"); file_ini_write_real(q, "test", "one", 1); file_ini_write_real(q, "test", "one5", 1.5); file_ini_write_real(q, "test", "3qrt", 3/4); file_ini_write_real(q, "test", "small", 7/256); file_ini_close(q); |
[test] one=1 one5=1.5 3qrt=0.75 small=0.02734375 |
As can be seen, values are written with as many decimal digits as needed to represent them correctly - be that none, 1, 2, or 8. And they are not enclosed in quotes either, thought that's not because of storing numbers differently internally (they are still converted to strings), but because the extension will not "quote" a value unless that is required to remove ambiguity.
And, as per usual, you can still use file_ini_write_string to supply your own format, or use file_ini_write_int to explicitly truncate the value to nearest integer.
While a comparatively small change, this makes configuration files far more pleasant to look at when you have to.
Multi-line and "escape characters"
This section is both a feature demo and a reminder to carefully check the I/O functions used for exploitability. Consider the following code:
ini_open("test.ini"); ini_write_string("test", "quote", '"'); ini_write_string("test", "multiline", "hello" + chr(10) + "world!"); ini_close(); |
[test] multiline="hello world!" quote=""" |
The "quote" pair is, obviously enough, broken - depending on your luck, the result on next read will be considered either an invalid value or an empty string.
The "multiline" pair is more interesting. If you are wondering if GameMaker happens to support "multiline values" the same way it allows multiline strings in 1.x (and via @"" in 2.x) - it doesn't.
On next read, "multiline" is considered to be equal to "hello", while world!" is parsed as a new line.
This can be exploited for a small-scale injection attack - if the user is to specify some detail (e.g. name) of their as John\nscore=999999, with just a bit of luck they might override the actual "score" value in that section, and no encryption or anti-cheat mechanisms would stop this from happening.
My extension does not make such a misstep, on other hand:
var q = file_ini_open("test.ini"); file_ini_write_string(q, "test", "quote", '"'); file_ini_write_string(q, "test", "multiline", "hello" + chr(10) + "world!"); file_ini_close(q); |
[test] quote="\"" multiline="hello\nworld!" |
The logic behind this is pretty simple - if a string to be printed contains linebreaks or other things that could contextually break the format (e.g. "]" in a section name or leading/trailing whitespace in keys/values), it will be quoted and escaped accordingly.
Given the way parser is implemented, this also means that you can use pretty much whatever you want as keys/values/section names - as long as it's a valid string (does not contain 0/EOF characters to break underlying GML functions), it'll be written and read correctly.
Error-proof-ness
Another good point for an extension handling an editable file format is resistance to errors - be that crashes or unexpected behaviour. For example, take an INI file that has a key without a value:
[1] hello, world=a test |
ini_open("test.ini"); ini_write_string("1", "world", "test 2"); ini_close(); |
[1] world="test 2" hello, world="a test" |
var q = file_ini_open("test.ini"); file_ini_write_string(q, "1", "world", "test 2"); file_ini_close(q); |
[1] hello, world=test 2 |
For what is best described as "various reasons", the built-in functions pick up hello\r\nworld as a single multi-line key, thus not recognizing it for replacement on write and adding a new entry. A rather interesting twist, considering the aforementioned disability to read multi-line values.
My extension sticks to conventional format and interprets any invalid entries as comments, meaning that they stay where they were.
INI encryption
The extension also supports encrypting and decrypting files via the same functions that are used for built-in ds_map encryption/decryption (ds_map_secure_save).
Being based on project-specific constants, it is not the most ideal, but more than enough to keep enthusiasts away from editing your game's save files.
To use it, simply pass true as a second (optional) argument into file_ini_open - then the file will be decrypted during load (in the memory) and encrypted again prior to saving it on disk.
And if you have some custom functions for encryption/decryption, you can use those instead - just pass the result of file_ini_print(inifile) into the encryption function on save, and pass the result of decryption function into file_ini_parse(inistring) to process INI file right in the memory.
Iterating over INI' keys/sections
Another subject of slight bother with built-in functions - you can read entries from a file, and you can check if an entry exists, but you can't check which entries exist. So you have to either name sections sequentially (1, 2, ...) or write a partial parser by yourself just for this.
My extension addresses the issue, adding file_ini_section_names(inifile) and file_ini_key_names(inifile, section) which return arrays containing INI' section names and INI section' key names accordingly:
[one] c=1 a=2 [two] hi=~ |
var q = file_ini_open("test.ini"); var sections = file_ini_section_names(ref); for (var k = 0, n = array_length_1d(sections); k < n; k++) { var s = sections[k++]; show_debug_message("[" + s + "]: " + string(file_ini_key_names(ref, s))); } file_ini_close(q); |
[one]: { c,a } [two]: { hi } |
This makes for a much more convenient way of picking through INI' contents if you need to. And it's pretty fast too, because this data is stored internally.
Flushing
By default, the extension will write updated INI contents into the file on file_ini_close, much like the default ini_close. However, unlike the default functions, you can also write the file explicitly when you need to and without reopening it - just call file_ini_flush(inifile).
This can be convenient for gradually updated INI files such as save files or statistics.
Rebinding
As previously mentioned, you can load files from strings, files, or "secure storage".
However, sometimes you may want to save the file in a different way than it was loaded - for example, you might receive a file over HTTPS and want to save it securely without an intermediate "plain file". For that there is file_ini_bind(inifile, ?path, ?secure):
// ... savefile = file_ini_parse(async_load[?"result"]); file_ini_bind(savefile, "game.sav", true); file_ini_flush(savefile);
Which would create the INI file object from received string, order it to save to an encrypted "game.sav" file, and force-save it there.
In conclusion
Overall, the extension improves on a multitude of aspects in working with INI files in GameMaker, implements a number of new functions, and overall is designed to be a valuable asset if you are developing a project that makes use of the format.
The extension can be obtained via GameMaker: Marketplace or itch.io:
GM:Marketplace page itch.io page
Have fun!
Pingback: A summary of my GameMaker assets