Cache locale string lookups instead of re-scanning the PO file every call

assetLocaleGetString rewinds and linearly scans/re-decompresses the
whole locale file from byte 0 on every single call, with no caching -
a text-heavy screen can easily make 10+ of these in a row (e.g. opening
the game menu), and on PSP the containing archive is already fully
resident in RAM, so the repeated cost is pure CPU (decompression +
scanning), not I/O.

Adds a fixed 128-entry move-to-front LRU cache keyed by
(messageId, pluralCount), capped at 64/256 bytes per key/value (~40KB
total) so the cost stays bounded no matter how large the game's script
ends up being, rather than caching the whole locale file's text.

The cache is a lazily-allocated pointer on assetlocalefile_t, not
embedded inline - that struct lives inside the assetloaderoutput_t
union shared by every asset type, and all ASSET_ENTRY_COUNT_MAX asset
slots carry that union directly, so embedding it would have sized every
slot up by ~40KB regardless of what asset type occupies it.

Also fixes a bug this surfaced in test_assetlocale.c's own fixture:
locale_teardown zeroed the locale struct directly instead of going
through assetLocaleDispose, which would have leaked the new cache
allocation across tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 16:16:52 -05:00
parent 7a858cc424
commit 320c5e6ce5
3 changed files with 484 additions and 10 deletions
@@ -71,6 +71,12 @@ errorret_t assetLocaleDispose(assetentry_t *entry) {
assertIsMainThread("Must be called from the main thread.");
assetlocalefile_t *localeFile = &entry->data.locale;
if(localeFile->cache != NULL) {
memoryFree(localeFile->cache);
localeFile->cache = NULL;
}
errorChain(assetFileClose(&localeFile->file));
return assetFileDispose(&localeFile->file);
}
@@ -470,6 +476,83 @@ errorret_t assetLocaleLineUnbuffer(
errorOk();
}
errorret_t assetLocaleCacheFind(
assetlocalefile_t *file,
const char_t *messageId,
const int32_t pluralCount,
char_t *stringBuffer,
const size_t stringBufferSize,
bool_t *outHit
) {
assertNotNull(file, "Asset file cannot be NULL.");
assertNotNull(messageId, "Message ID cannot be NULL.");
assertNotNull(outHit, "outHit cannot be NULL.");
*outHit = false;
if(file->cache == NULL) errorOk();
assetlocalecache_t *cache = file->cache;
for(uint8_t i = 0; i < ASSET_LOCALE_CACHE_COUNT; i++) {
assetlocalecacheentry_t *entry = &cache->entries[i];
if(entry->messageId[0] == '\0') break;// unused tail - nothing further
if(entry->pluralCount != pluralCount) continue;
if(!stringEquals(entry->messageId, messageId)) continue;
// Move to the front of the LRU order (a no-op when already there).
if(i > 0) {
assetlocalecacheentry_t hit = *entry;
memoryMove(
&cache->entries[1], &cache->entries[0], i * sizeof(assetlocalecacheentry_t)
);
cache->entries[0] = hit;
}
size_t len = strlen(cache->entries[0].value);
if(len >= stringBufferSize) errorThrow("String buffer overflow");
memoryCopy(stringBuffer, cache->entries[0].value, len + 1);
*outHit = true;
errorOk();
}
errorOk();
}
void assetLocaleCacheInsert(
assetlocalefile_t *file,
const char_t *messageId,
const int32_t pluralCount,
const char_t *value
) {
assertNotNull(file, "Asset file cannot be NULL.");
assertNotNull(messageId, "Message ID cannot be NULL.");
assertNotNull(value, "Value cannot be NULL.");
// Caching is a pure optimization - a message ID or value too long for a
// fixed cache slot is just never cached, not an error.
if(strlen(messageId) >= ASSET_LOCALE_CACHE_KEY_MAX) return;
if(strlen(value) >= ASSET_LOCALE_CACHE_VALUE_MAX) return;
if(file->cache == NULL) {
file->cache = (assetlocalecache_t *)memoryAllocate(sizeof(assetlocalecache_t));
memoryZero(file->cache, sizeof(assetlocalecache_t));
}
// Shift every entry down one slot, dropping the least-recently-used one
// off the end, to make room for the new entry at the front. This assumes
// messageId is never already present elsewhere in the cache, which holds
// as long as callers only insert after a confirmed assetLocaleCacheFind
// miss (a hit would have returned before reaching this point).
memoryMove(
&file->cache->entries[1], &file->cache->entries[0],
(ASSET_LOCALE_CACHE_COUNT - 1) * sizeof(assetlocalecacheentry_t)
);
assetlocalecacheentry_t *entry = &file->cache->entries[0];
stringCopy(entry->messageId, messageId, ASSET_LOCALE_CACHE_KEY_MAX);
entry->pluralCount = pluralCount;
stringCopy(entry->value, value, ASSET_LOCALE_CACHE_VALUE_MAX);
}
errorret_t assetLocaleGetString(
assetlocalefile_t *file,
const char_t *messageId,
@@ -482,6 +565,13 @@ errorret_t assetLocaleGetString(
assertTrue(pluralCount >= 0, "Plural index cannot be negative.");
assertNotNull(stringBuffer, "String buffer cannot be NULL.");
assertTrue(stringBufferSize > 0, "String buffer size must be > 0");
bool_t cacheHit = false;
errorChain(assetLocaleCacheFind(
file, messageId, pluralCount, stringBuffer, stringBufferSize, &cacheHit
));
if(cacheHit) errorOk();
assetfilelinereader_t reader;
bool_t msgidFound = false, msgidPluralFound = false, msgstrFound = false;
@@ -625,6 +715,8 @@ errorret_t assetLocaleGetString(
errorThrow("Failed to find msgstr for message ID: %s", messageId);
}
assetLocaleCacheInsert(file, messageId, pluralCount, stringBuffer);
errorOk();
}
@@ -70,6 +70,41 @@ typedef struct {
};
} assetlocalearg_t;
/** Number of recently resolved strings @ref assetlocalecache_t remembers. */
#define ASSET_LOCALE_CACHE_COUNT 128
/** Max length (excluding null terminator) of a cacheable message ID. */
#define ASSET_LOCALE_CACHE_KEY_MAX 64
/** Max length (excluding null terminator) of a cacheable resolved string. */
#define ASSET_LOCALE_CACHE_VALUE_MAX 256
/** One (messageId, pluralCount) -> resolved string cache slot. */
typedef struct {
char_t messageId[ASSET_LOCALE_CACHE_KEY_MAX];
int32_t pluralCount;
char_t value[ASSET_LOCALE_CACHE_VALUE_MAX];
} assetlocalecacheentry_t;
/**
* Fixed-size move-to-front LRU cache of recently resolved locale strings.
* assetLocaleGetString() re-scans and re-decompresses the whole PO file on
* every miss, which is expensive to repeat for strings a screen fetches
* every time it opens (menu labels, etc) - this cache lets identical
* (messageId, pluralCount) lookups skip that entirely.
*
* Lazily allocated on the first cache insert (see assetlocalefile_t.cache)
* so locale entries that are never queried, or a file that's disposed
* before anything is cached, never pay for it.
*/
typedef struct {
/**
* Entries ordered most-recently-used first. An entry with
* messageId[0] == '\0' (and every entry after it) is unused.
*/
assetlocalecacheentry_t entries[ASSET_LOCALE_CACHE_COUNT];
} assetlocalecache_t;
/**
* Runtime state for an open locale file.
*
@@ -98,6 +133,16 @@ typedef struct {
/** Form index used when no conditional clause matches. */
uint8_t pluralDefaultIndex;
/**
* Recently resolved string cache, or NULL if nothing has been cached yet.
* Heap-allocated rather than embedded because assetlocalefile_t lives
* inside the shared assetloaderoutput_t union alongside every other
* asset type - embedding a 128-entry cache there would size that union
* (and therefore every one of the ASSET_ENTRY_COUNT_MAX asset slots,
* regardless of what type of asset occupies it) up by the same amount.
*/
assetlocalecache_t *cache;
} assetlocalefile_t;
/** Convenience alias - the loaded output type of a locale asset entry. */
@@ -203,12 +248,64 @@ errorret_t assetLocaleLineUnbuffer(
const size_t stringBufferSize
);
/**
* Searches a locale file's cache for a previously resolved
* (messageId, pluralCount) pair.
*
* On a hit, copies the cached value into `stringBuffer` (erroring if it
* doesn't fit, same contract as @ref assetLocaleGetString) and moves the
* entry to the front of the cache's move-to-front LRU order. On a miss,
* `stringBuffer` is left untouched.
*
* @param file Locale file whose cache to search. Its cache may be NULL
* (nothing cached yet), which is treated as a miss.
* @param messageId Message ID to look up.
* @param pluralCount Plural count the original lookup used.
* @param stringBuffer Destination buffer, filled only on a hit.
* @param stringBufferSize Capacity of `stringBuffer` in bytes.
* @param outHit Set to true on a cache hit, false on a miss.
* @return OK on success (hit or miss), error if a hit's cached value does
* not fit `stringBuffer`.
*/
errorret_t assetLocaleCacheFind(
assetlocalefile_t *file,
const char_t *messageId,
const int32_t pluralCount,
char_t *stringBuffer,
const size_t stringBufferSize,
bool_t *outHit
);
/**
* Records a resolved (messageId, pluralCount) -> value string at the front
* of the cache's move-to-front LRU order, evicting the least-recently-used
* entry if the cache is already full. Lazily allocates the cache (see
* assetlocalefile_t.cache) on the first call.
*
* Caching is a pure optimization: if `messageId` or `value` is too long to
* fit the cache's fixed-size slots, this silently does nothing rather than
* erroring or growing the cache.
*
* @param file Locale file whose cache to insert into.
* @param messageId Message ID that was looked up.
* @param pluralCount Plural count the lookup was made with.
* @param value Resolved string to cache.
*/
void assetLocaleCacheInsert(
assetlocalefile_t *file,
const char_t *messageId,
const int32_t pluralCount,
const char_t *value
);
/**
* Looks up a translated string by message ID from the open locale file.
*
* Rewinds the file and scans from the beginning on every call. For plural
* entries (`msgid_plural`) the `pluralCount` is evaluated against the loaded
* plural rules to select the correct `msgstr[N]` form.
* Checks the file's cache first (see @ref assetLocaleCacheFind); on a miss,
* rewinds the file and scans from the beginning, then caches the result
* (see @ref assetLocaleCacheInsert) before returning. For plural entries
* (`msgid_plural`) the `pluralCount` is evaluated against the loaded plural
* rules to select the correct `msgstr[N]` form.
*
* @param file Locale file to search. Must be open.
* @param messageId PO message ID to find (`""` retrieves the header entry).