// The MIT License (MIT) // // Copyright (C) 2026 Scott Duensing // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to // deal in the Software without restriction, including without limitation the // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS // IN THE SOFTWARE. // dvxPrefs.c -- INI-based preferences system (read/write) // // Handle-based: each PrefsHandleT holds its own entry array and file // path. Multiple INI files can be open simultaneously. The file is read // whole, so lines of any length load without truncation, and the content // of every line not changed through the API is preserved (every line is // written with CR LF). #include "dvxPrefs.h" #include "dvxTypes.h" #include "platform/dvxPlat.h" #include #include #include #include #include #include "dvxMem.h" #include "thirdparty/stb_ds_wrap.h" // ============================================================ // Internal types // ============================================================ typedef enum { PrefsLineOtherE, // blank, comment, or neither a section nor a key PrefsLineSectionE, // [name] PrefsLineKeyE // key = value } PrefsLineE; // One physical line. raw is the line exactly as read (without its line // ending) and is written back verbatim; it is dropped when the key's // value changes, and entries created through the API have none, so those // are written in canonical "key = value" / "[section]" form. Other lines // always keep raw. section is the section the line sits in ("" before // the first header). typedef struct { PrefsLineE kind; char *section; char *key; char *value; char *raw; } PrefsEntryT; struct PrefsHandleT { PrefsEntryT *entries; // stb_ds dynamic array char *filePath; // path used by prefsSave bool loadFailed; // file exists but could not be read: prefsSave refuses }; // A settings file saved while DVX's folder cannot be written to // (platformIsReadOnly): kept here until DVX exits, and read back by // prefsLoad in place of the file on disk. typedef struct { char *path; char *text; int32_t len; } PrefsMemFileT; // Decimal text of an int32_t including sign and NUL #define PREFS_INT_TEXT_MAX 16 // Line ending written by prefsSaveAs #define PREFS_EOL "\r\n" // [video]: the display mode DVX starts in (prefsGetVideoMode) #define PREFS_VIDEO_SECTION "video" #define PREFS_VIDEO_WIDTH "width" #define PREFS_VIDEO_HEIGHT "height" #define PREFS_VIDEO_BPP "bpp" // Words prefsGetBool accepts (case-insensitive, whole value) static const char *const sTrueWords[] = { "true", "yes", "1" }; // Settings files held in memory (stb_ds array) static PrefsMemFileT *sMemFiles = NULL; static const char *const sFalseWords[] = { "false", "no", "0" }; // ============================================================ // Shared preference vocabularies // ============================================================ const PrefsChoiceT prefsAccelChoices[PREFS_ACCEL_CHOICE_COUNT] = { { "Off", "off", MOUSE_ACCEL_OFF }, { "Low", "low", MOUSE_ACCEL_LOW }, { "Medium", "medium", MOUSE_ACCEL_MEDIUM }, { "High", "high", MOUSE_ACCEL_HIGH } }; const PrefsChoiceT prefsWallpaperChoices[PREFS_WALLPAPER_CHOICE_COUNT] = { [WallpaperStretchE] = { "Stretch", "stretch", WallpaperStretchE }, [WallpaperTileE] = { "Tile", "tile", WallpaperTileE }, [WallpaperCenterE] = { "Center", "center", WallpaperCenterE } }; const PrefsChoiceT prefsWheelDirChoices[PREFS_WHEEL_DIR_CHOICE_COUNT] = { { "Normal", "normal", MOUSE_WHEEL_DIR_NORMAL }, { "Reversed", "reversed", MOUSE_WHEEL_DIR_REVERSED } }; // ============================================================ // Prototypes // ============================================================ static void addEntry(PrefsHandleT *h, PrefsLineE kind, const char *section, const char *key, const char *value, const char *raw); static void appendText(char **buf, const char *a, const char *b, const char *c); static int32_t findEntry(PrefsHandleT *h, const char *section, const char *key); static int32_t findSection(PrefsHandleT *h, const char *section); static char *formatText(const PrefsHandleT *h); static void freeEntry(PrefsEntryT *e); static bool matchWord(const char *val, const char *const *words, int32_t count); static char *memFileCopy(const char *path, int32_t *len); static bool memFileStore(const char *path, const char *text, int32_t len); static void parseLine(PrefsHandleT *h, char *line, char **currentSection); static char *strdupOrNull(const char *s); static char *trimInPlace(char *buf); static bool writeText(const char *filename, const char *text, int32_t len); // Appends one entry; every string is copied (NULL stays NULL). Out of // memory drops the entry: a half-copied one would put NULL names in front // of findEntry's strcasecmp. static void addEntry(PrefsHandleT *h, PrefsLineE kind, const char *section, const char *key, const char *value, const char *raw) { PrefsEntryT e = {0}; e.kind = kind; e.section = strdupOrNull(section); e.key = strdupOrNull(key); e.value = strdupOrNull(value); e.raw = strdupOrNull(raw); if ((section && !e.section) || (key && !e.key) || (value && !e.value) || (raw && !e.raw)) { freeEntry(&e); return; } arrput(h->entries, e); } // Add a, b and c (each may be NULL) to the stb_ds char array *buf. static void appendText(char **buf, const char *a, const char *b, const char *c) { const char *parts[] = { a, b, c }; for (int32_t i = 0; i < (int32_t)(sizeof(parts) / sizeof(parts[0])); i++) { for (const char *p = parts[i]; p && *p; p++) { arrput(*buf, *p); } } } static int32_t findEntry(PrefsHandleT *h, const char *section, const char *key) { for (int32_t i = 0; i < arrlen(h->entries); i++) { PrefsEntryT *e = &h->entries[i]; if (e->kind == PrefsLineKeyE && strcasecmp(e->section, section) == 0 && strcasecmp(e->key, key) == 0) { return i; } } return -1; } static int32_t findSection(PrefsHandleT *h, const char *section) { for (int32_t i = 0; i < arrlen(h->entries); i++) { PrefsEntryT *e = &h->entries[i]; if (e->kind == PrefsLineSectionE && strcasecmp(e->section, section) == 0) { return i; } } return -1; } // The file's text: every entry, CR LF after each (stb_ds char array, no // NUL; NULL when there are no entries). static char *formatText(const PrefsHandleT *h) { char *buf = NULL; for (int32_t i = 0; i < arrlen(h->entries); i++) { const PrefsEntryT *e = &h->entries[i]; if (e->raw) { appendText(&buf, e->raw, PREFS_EOL, NULL); } else if (e->kind == PrefsLineSectionE) { appendText(&buf, "[", e->section, "]" PREFS_EOL); } else if (e->kind == PrefsLineKeyE) { appendText(&buf, e->key, " = ", e->value); appendText(&buf, PREFS_EOL, NULL, NULL); } } return buf; } static void freeEntry(PrefsEntryT *e) { free(e->section); free(e->key); free(e->value); free(e->raw); e->section = NULL; e->key = NULL; e->value = NULL; e->raw = NULL; } // True when val equals one of words, ignoring case. static bool matchWord(const char *val, const char *const *words, int32_t count) { for (int32_t i = 0; i < count; i++) { if (strcasecmp(val, words[i]) == 0) { return true; } } return false; } // A copy of the text held for path (NUL-terminated, for free); NULL when // none is held. static char *memFileCopy(const char *path, int32_t *len) { for (int32_t i = 0; i < arrlen(sMemFiles); i++) { if (platformPathSame(sMemFiles[i].path, path)) { char *copy = (char *)malloc((size_t)sMemFiles[i].len + 1); if (copy) { memcpy(copy, sMemFiles[i].text, (size_t)sMemFiles[i].len); copy[sMemFiles[i].len] = '\0'; *len = sMemFiles[i].len; } return copy; } } return NULL; } // Hold text as the content of path (replacing what was held). static bool memFileStore(const char *path, const char *text, int32_t len) { char *copy = (char *)malloc((size_t)len + 1); if (!copy) { return false; } memcpy(copy, text, (size_t)len); copy[len] = '\0'; for (int32_t i = 0; i < arrlen(sMemFiles); i++) { if (platformPathSame(sMemFiles[i].path, path)) { free(sMemFiles[i].text); sMemFiles[i].text = copy; sMemFiles[i].len = len; return true; } } PrefsMemFileT m = { strdup(path), copy, len }; if (!m.path) { free(copy); return false; } arrput(sMemFiles, m); return true; } // Classifies one physical line (modified in place) and appends it. A // "[name]" line starts a section; a line with '=' that is not a comment // is a key (key and value trimmed); everything else is kept verbatim. static void parseLine(PrefsHandleT *h, char *line, char **currentSection) { char *raw = strdup(line); if (!raw) { return; } dvxTrimRight(line); char *p = (char *)dvxSkipWs(line); char *close = (*p == '[') ? strchr(p, ']') : NULL; char *eq = strchr(p, '='); if (close) { *close = '\0'; char *name = strdup(trimInPlace(p + 1)); if (name) { free(*currentSection); *currentSection = name; addEntry(h, PrefsLineSectionE, name, NULL, NULL, raw); } } else if (*p == '\0' || *p == ';' || *p == '#' || *p == '[' || !eq) { addEntry(h, PrefsLineOtherE, *currentSection, NULL, NULL, raw); } else { *eq = '\0'; addEntry(h, PrefsLineKeyE, *currentSection, trimInPlace(p), trimInPlace(eq + 1), raw); } free(raw); } // Index of the choice whose INI spelling is name (case-insensitive), or -1. int32_t prefsChoiceIndex(const PrefsChoiceT *choices, int32_t count, const char *name) { if (!name) { return -1; } for (int32_t i = 0; i < count; i++) { if (strcasecmp(name, choices[i].iniName) == 0) { return i; } } return -1; } // Index of the choice whose value is value, or -1. int32_t prefsChoiceIndexOfValue(const PrefsChoiceT *choices, int32_t count, int32_t value) { for (int32_t i = 0; i < count; i++) { if (choices[i].value == value) { return i; } } return -1; } void prefsChoiceLabels(const PrefsChoiceT *choices, int32_t count, const char **labels) { for (int32_t i = 0; i < count; i++) { labels[i] = choices[i].label; } } void prefsClose(PrefsHandleT *h) { if (!h) { return; } for (int32_t i = 0; i < arrlen(h->entries); i++) { freeEntry(&h->entries[i]); } arrfree(h->entries); free(h->filePath); free(h); } PrefsHandleT *prefsCreate(void) { PrefsHandleT *h = (PrefsHandleT *)calloc(1, sizeof(PrefsHandleT)); return h; } bool prefsGetBool(PrefsHandleT *h, const char *section, const char *key, bool defaultVal) { const char *val = prefsGetString(h, section, key, NULL); if (!val) { return defaultVal; } if (matchWord(val, sTrueWords, DVX_ARRAY_LEN(sTrueWords))) { return true; } if (matchWord(val, sFalseWords, DVX_ARRAY_LEN(sFalseWords))) { return false; } return defaultVal; } int32_t prefsGetChoice(PrefsHandleT *h, const char *section, const char *key, const PrefsChoiceT *choices, int32_t count, const char *defaultName) { int32_t idx = prefsChoiceIndex(choices, count, prefsGetString(h, section, key, NULL)); return (idx >= 0) ? idx : prefsChoiceIndex(choices, count, defaultName); } int32_t prefsGetInt(PrefsHandleT *h, const char *section, const char *key, int32_t defaultVal) { const char *val = prefsGetString(h, section, key, NULL); if (!val) { return defaultVal; } char *end = NULL; long n = strtol(val, &end, 10); if (end == val) { return defaultVal; } return (int32_t)n; } const char *prefsGetString(PrefsHandleT *h, const char *section, const char *key, const char *defaultVal) { if (!h || !section || !key) { return defaultVal; } int32_t idx = findEntry(h, section, key); if (idx < 0) { return defaultVal; } return h->entries[idx].value; } void prefsGetVideoMode(PrefsHandleT *h, int32_t *width, int32_t *height, int32_t *bpp) { *width = prefsGetInt(h, PREFS_VIDEO_SECTION, PREFS_VIDEO_WIDTH, DVX_DEFAULT_VIDEO_W); *height = prefsGetInt(h, PREFS_VIDEO_SECTION, PREFS_VIDEO_HEIGHT, DVX_DEFAULT_VIDEO_H); *bpp = prefsGetInt(h, PREFS_VIDEO_SECTION, PREFS_VIDEO_BPP, DVX_DEFAULT_VIDEO_BPP); } // Walks the section's keys in file order, counting only the first of any // repeated name (the one findEntry, and so every getter, returns). const char *prefsKeyName(PrefsHandleT *h, const char *section, int32_t index) { if (!h || !section || index < 0) { return NULL; } int32_t seen = 0; for (int32_t i = 0; i < arrlen(h->entries); i++) { const PrefsEntryT *e = &h->entries[i]; if (e->kind != PrefsLineKeyE || strcasecmp(e->section, section) != 0 || findEntry(h, section, e->key) != i) { continue; } if (seen == index) { return e->key; } seen++; } return NULL; } // Reads the whole file and splits it on LF (a CR before the LF is part // of the line ending), so no line length limit applies. PrefsHandleT *prefsLoad(const char *filename) { PrefsHandleT *h = filename ? prefsCreate() : NULL; if (!h) { return NULL; } h->filePath = strdup(filename); int32_t len = 0; char *text = memFileCopy(filename, &len); struct stat st; char bakPath[DVX_MAX_PATH]; if (!text) { text = platformReadFile(filename, &len); } // Missing: a crash inside platformReplaceFile may have left the only // copy under the backup name. A save then writes it back to filename. if (!text && stat(filename, &st) != 0 && platformPathSibling(bakPath, sizeof(bakPath), filename, PLATFORM_BAK_EXT)) { text = platformReadFile(bakPath, &len); } if (!text) { // Present but unreadable: never let a save replace it with the // defaults this handle would hold. h->loadFailed = (stat(filename, &st) == 0); return h; } char *currentSection = strdup(""); char *line = text; char *end = text + len; while (currentSection && line < end) { char *eol = (char *)memchr(line, '\n', (size_t)(end - line)); char *next = eol ? eol + 1 : end; char *stop = eol ? eol : end; if (stop > line && stop[-1] == '\r') { stop--; } *stop = '\0'; parseLine(h, line, ¤tSection); line = next; } free(currentSection); free(text); return h; } void prefsRemove(PrefsHandleT *h, const char *section, const char *key) { if (!h || !section || !key) { return; } int32_t idx = findEntry(h, section, key); if (idx >= 0) { freeEntry(&h->entries[idx]); arrdel(h->entries, idx); } } bool prefsSave(PrefsHandleT *h) { if (!h || !h->filePath || h->loadFailed) { return false; } return prefsSaveAs(h, h->filePath); } // Writes the file (see writeText). When DVX's folder cannot be written // to (read-only media) and the write fails, the text is kept in memory // instead and prefsLoad reads it back until DVX exits. bool prefsSaveAs(PrefsHandleT *h, const char *filename) { if (!h || !filename) { return false; } char *text = formatText(h); int32_t len = (int32_t)arrlen(text); bool ok = writeText(filename, text ? text : "", len); if (!ok && platformIsReadOnly()) { ok = memFileStore(filename, text ? text : "", len); } arrfree(text); return ok; } void prefsSetBool(PrefsHandleT *h, const char *section, const char *key, bool value) { prefsSetString(h, section, key, value ? "true" : "false"); } void prefsSetInt(PrefsHandleT *h, const char *section, const char *key, int32_t value) { char buf[PREFS_INT_TEXT_MAX]; snprintf(buf, sizeof(buf), "%ld", (long)value); prefsSetString(h, section, key, buf); } void prefsSetString(PrefsHandleT *h, const char *section, const char *key, const char *value) { if (!h || !section || !key) { return; } // Normalize NULL to empty so every stored value is a valid string: // otherwise prefsGetString returns NULL instead of the default and // prefsSaveAs silently drops the key. if (!value) { value = ""; } int32_t idx = findEntry(h, section, key); if (idx >= 0) { PrefsEntryT *e = &h->entries[idx]; // An unchanged value keeps the line's original spelling if (strcmp(e->value, value) == 0) { return; } char *copy = strdup(value); if (copy) { free(e->value); free(e->raw); e->value = copy; e->raw = NULL; } return; } int32_t secIdx = findSection(h, section); if (secIdx < 0) { if (arrlen(h->entries) > 0) { addEntry(h, PrefsLineOtherE, section, NULL, NULL, ""); } addEntry(h, PrefsLineSectionE, section, NULL, NULL, NULL); secIdx = findSection(h, section); if (secIdx < 0) { return; } } // Insert after the section's last key. Blank lines and comments // after it are tagged with this section but belong visually to the // next one (a separator, "; about [b]"), so the new key goes above // them. int32_t insertAt = secIdx + 1; for (int32_t i = secIdx + 1; i < arrlen(h->entries); i++) { PrefsEntryT *e = &h->entries[i]; if (e->kind == PrefsLineSectionE) { break; } if (e->kind == PrefsLineKeyE) { insertAt = i + 1; } } PrefsEntryT newEntry = {0}; newEntry.kind = PrefsLineKeyE; newEntry.section = strdup(section); newEntry.key = strdup(key); newEntry.value = strdup(value); if (!newEntry.section || !newEntry.key || !newEntry.value) { freeEntry(&newEntry); return; } arrins(h->entries, insertAt, newEntry); } void prefsSetVideoMode(PrefsHandleT *h, int32_t width, int32_t height, int32_t bpp) { prefsSetInt(h, PREFS_VIDEO_SECTION, PREFS_VIDEO_WIDTH, width); prefsSetInt(h, PREFS_VIDEO_SECTION, PREFS_VIDEO_HEIGHT, height); prefsSetInt(h, PREFS_VIDEO_SECTION, PREFS_VIDEO_BPP, bpp); } static char *strdupOrNull(const char *s) { return s ? strdup(s) : NULL; } static char *trimInPlace(char *buf) { char *p = (char *)dvxSkipWs(buf); dvxTrimRight(p); return p; } // Writes text to the .$ sibling (PLATFORM_TMP_EXT; the // extension is replaced, so 8.3 names stay valid) and swaps it in only // when every write succeeded, so a full disk or I/O error leaves the // previous file intact and reports failure. static bool writeText(const char *filename, const char *text, int32_t len) { char tmpPath[DVX_MAX_PATH]; char bakPath[DVX_MAX_PATH]; if (!platformPathSibling(tmpPath, sizeof(tmpPath), filename, PLATFORM_TMP_EXT) || !platformPathSibling(bakPath, sizeof(bakPath), filename, PLATFORM_BAK_EXT)) { return false; } FILE *fp = fopen(tmpPath, "wb"); if (!fp) { return false; } bool ok = (int32_t)fwrite(text, 1, (size_t)len, fp) == len; // fclose flushes: a late write error surfaces here ok = (fclose(fp) == 0) && ok; if (!ok) { remove(tmpPath); return false; } return platformReplaceFile(tmpPath, filename, bakPath); }