Why Reverse: 1999 stutters on PC, and how I fixed it from the outside
Reverse: 1999 runs at a smooth 120 fps on my PC, except when it doesn’t. Every few seconds while moving through menus, and a moment after leaving almost any screen, the game freezes for a quarter to three quarters of a second. Not a dropped frame or two: a visible hitch, several times a minute.
This is the story of finding out why, measured on the Steam version V3.8.0-111.0.1050-71 with a throwaway test account, and of a small client-side fix that removes almost all of it.
The game’s own code appears below as pseudo-code; the Lua, JavaScript and C blocks are shortened excerpts from my tools and the fix.
A garbage collector, on purpose
The first step was a system-wide CPU profile while reproducing the freezes, using Windows’ own event tracing, which needs no changes to the game. The result was unusually clear. In every freeze of around 300 ms, nearly all of the main thread’s time (87-94 %) was spent in one function: LuaJIT’s full garbage collection.
That needs some context. Like many mobile-first games, Reverse: 1999 is a Unity game whose actual game logic is written in Lua, running in LuaJIT through a binding library called tolua. The C# side is mostly engine and glue. So the interesting code is Lua, and the game’s Lua is encrypted on disk.
It isn’t encrypted in memory, though. With a small injected hook I could run a few lines of Lua inside the game’s own Lua state, find the functions involved and dump their bytecode, which carries enough detail to reconstruct the original code by hand.
// Frida: run a Lua chunk at the next call from C# into Lua, on the game's main thread
Interceptor.attach(tolua.getExportByName('lua_pcall'), {
onEnter(args) {
const L = args[0], top = lua_gettop(L);
if (luaL_loadstring(L, Memory.allocUtf8String(SRC)) === 0) lua_pcall(L, 0, 1, 0);
send(readString(L, -1)); // e.g. the hex of string.dump(SomeModule.someFunction)
lua_settop(L, top);
}
});
The culprit was a small manager called GameGCMgr. It does exactly
what its name suggests: after a full-screen view is destroyed, after a scene loads, after a popup closes and
in a few other places, it forces a complete garbage collection of the whole Lua heap.
-- pseudo-code: the game's GC policy
on full-screen view destroyed: after 1.5 s -> fullGC()
on scene prepared: after 0.1 s -> fullGC()
on popup closed: after 0.1 s -> fullGC()
fullGC():
if less than 2 s since the last one: return
collect the whole Lua heap -- one frame, 250-300 ms
unload unused Unity assets -- more on this later
The Lua heap is large: about 290 MB on a brand-new account, and about 350 MB already at the login screen, before any account data exists. A full collection walks all of it, on the main thread, in one frame. Each one took 250-300 ms and freed 1-4 MB. In five and a half minutes of menu navigation the game did this 24 times.
Two wrong turns and one right one
The obvious idea was to do it less often. I raised the minimum interval between forced collections from 2 seconds to 5 minutes. The freezes became rarer, but each one got longer (340-450 ms). LuaJIT’s default settings let the heap grow to about twice its live size before collecting, so a full collection that waits longer simply has more garbage to sweep.
The better idea was to not force it at all. LuaJIT has an incremental collector that does the same work in small slices spread over many frames. It had been effectively idle all along: the forced collections kept the heap below the point where it would ever start. So I disabled the forced call and told the incremental collector to start earlier.
The first attempt failed in an instructive way: the heap grew from 300 to 491 MB in 18 minutes without a single incremental cycle finishing. The reason is a LuaJIT detail: the setting that decides when the next cycle starts only takes effect when the current cycle ends, and since no cycle ever ended, my new setting never applied. Running one last full collection right after changing it fixed that. With the collector also allowed to do more work per step, cycles completed every few minutes, freeing about 70 MB each. The longest single step was under 10 ms, and none of the remaining hitches had the collector as its cause. The big freezes were gone.
collectgarbage('setpause', 120) -- next cycle at 120 % of the heap after the last one (default 200)
collectgarbage('collect') -- setpause only applies when a cycle ends, so finish one now
collectgarbage('setstepmul', 1000) -- more work per incremental step (default 200)
SLFramework.UnityHelper.LuaGC = function() end -- the game's forced full collection becomes a no-op
The Lua heap, sampled every 10 seconds. Grey: as shipped, with forced full collections. Red: incremental collector with only the start threshold changed: no cycle ever finishes. Green: with the threshold applied and more work per step: a cycle completes and frees about 70 MB.
What was left: the transitions
The game felt smooth now, except at transitions: opening a screen, entering a battle, coming back to the main menu. A second profile showed where that time went: Unity loading assets from asset bundles, synchronously, inside the frame that opens the screen. The game’s Lua decides what to load, the C# side asks Unity for it, and Unity does all the work right then.
-- pseudo-code: how a screen gets its prefab
loadBundle(path, function(item)
prefab = item.GetResource(path) -- Unity's AssetBundle.LoadAsset, synchronous
view = clone(prefab) -- instantiate, same frame
end)
Unity offers an asynchronous variant for exactly this, and the game’s own asset class even wraps it. None of the game’s loaders use it.
To see which assets and how often, I wrapped the loading functions with timers. The first round of measurements came back with every single load taking 0.0 ms. It turned out tolua replaces Unity’s “real time since startup” with a Lua copy that is updated once per frame, so two readings within the same frame are always identical. Switching to a plain wall clock fixed the measurements, and also invalidated one earlier number I had trusted. Lesson noted: before believing that something takes no time at all, check what the clock measures.
-- tolua keeps C# methods in the class metatable; replacing them there wraps every instance
local mt = getmetatable(SLFramework.AssetItem)
local getResource = rawget(mt, 'GetResource')
rawset(mt, 'GetResource', function(self, path)
local t0 = os.clock() -- not UnityEngine.Time.realtimeSinceStartup: tolua caches it per frame
local res = getResource(self, path)
record(path, (os.clock() - t0) * 1000)
return res
end)
With working timers, a pattern appeared. Every time a screen was reopened, it paid its full loading cost again: the store 43-77 ms, the character screen 32-40 ms, the story map 24-27 ms. Nothing stayed loaded. Meanwhile, in 15 minutes of normal play, the game issued 7,764 load requests for just 714 different assets, one small effect prefab alone 734 times.
The spike after the close
To measure transitions repeatably I scripted the game: open a screen, wait, close it, repeat, through the same code paths the game’s own buttons use. While it ran, I watched the screen and saw what the numbers had been hinting at: the worst hitch didn’t come when a screen opened. It came a moment after a screen had closed, once the main menu was already back.
That moment matched a second call in the same GameGCMgr: after every forced garbage collection it also
tells Unity to unload all assets that are no longer in use. To do that, Unity has to find out what’s still
referenced, which means walking every loaded object, again on the main thread.
An A/B test settled it. With the unload as shipped, every screen close was followed about 1.5 seconds later by one frame of 140-336 ms. With only that unload skipped, there was no hitch above 107 ms after any close, and reopened screens no longer had to load their prefab again, because it was never thrown away. Memory stayed flat (1.4-1.5 GB VRAM, about 4 GB RAM).
Every frame of 50 ms or more during the same scripted screen cycles; dashed lines mark screen closes. Top: as shipped, where each close is followed by an unload and a 140-196 ms frame. Bottom: the unload skipped.
Unity itself confirms the cost: it writes a line to its log for every unload, including how long it took. Over one long test session there were 200 of them, median 159 ms, worst 479 ms. That’s also a neat way to measure the problem on any account without touching the game at all.
How long Unity’s unloads took over one test session, as reported in its own log.
Why the game works this way
The view code explained the pattern. When a screen is closed, the game destroys it immediately: the per-screen “destroy after” delay is zero for 2,370 of 2,413 screens. Destroying a full-screen view is what triggers the forced collection and the unload, and it’s why the next open has to load and build everything again. The engine supports keeping a closed screen alive and hidden; a few dozen screens already use it.
My reading, which I can’t prove: this is a phone memory policy carried over to PC unchanged. On a phone the operating system kills apps that use too much memory, so releasing everything aggressively is reasonable. One of the triggers for the forced collection is even the operating system’s low-memory warning. On a PC with plenty of RAM and VRAM, the same policy trades a few hundred megabytes for a visible freeze after every screen.
Unloading only when nobody can see it
Never unloading at all would let memory grow over a long session, so the question became when to unload. Some unload requests come during a scene change, while a loading screen is already up; a hitch there is invisible. Others come after closing a screen, or just after a loading screen has disappeared, and those are exactly the ones you see.
The scene manager has a flag that is true exactly while a loading screen is up, and I confirmed live that the invisible unloads are precisely the ones that happen while it’s set. So the rule became: unload only while a loading screen is showing. In a 20-minute stress run (75 screen cycles and 5 battles) there was no hitch after any screen close, and memory showed no upward trend.
local unload = SLFramework.UnityHelper.ResGC
SLFramework.UnityHelper.ResGC = function(...)
if GameSceneMgr.instance:isLoading() then -- a loading screen hides the hitch
return unload(...)
end
-- otherwise skip it (optionally: unload anyway above a RAM/VRAM budget)
end
Two more changes followed from the same analysis:
-
Keep frequently used screens alive. With the “destroy after” delay set to a day for the main screens, a reopened screen loads nothing. From the second open on, those screens opened without a single load over 10 ms and without a frame over 60 ms. As a bonus, they no longer trigger the collection at all.
for _, name in ipairs({ 'StoreView', 'CharacterView', 'DungeonView', 'PlayerView' }) do ViewMgr.instance:getSetting(name).destroy = 86400 -- keep the closed screen alive for a day endOn my main account this turned out to be a trap. A screen that is kept alive isn’t rebuilt, and not every screen refreshes its contents when it’s shown again: reopened screens showed stale character stats, missed level-ups and newly obtained characters, and one replayed battle got stuck. The fix I ship leaves this off. Done properly, inside the game, it needs each screen to refresh its data when it reappears.
-
Keep the battle UI loaded. Every battle started with one frame of 341-618 ms. The game’s battle preloader does load the right asset bundles during the loading screen, but it never creates the actual objects, so the battle screen still builds itself synchronously in its first frame. Keeping those objects referenced brought that frame down to 135-142 ms.
-- keep the battle UI's prefab objects alive across scene changes loadAbAsset(path, false, function(_, item) item:Retain() pinned[path] = { item = item, obj = item:GetResource(path) } end)
Two crashes along the way
Testing all this crashed the game twice, both times inside LuaJIT’s error handling, while it walked the Lua call stack. Before the first, one of my injected inspection scripts had failed halfway and left a broken frame on the Lua stack. Before the second, I had accidentally sent a battle request during the tutorial while scripted screen changes kept running on top of it.
Since both crashes also came shortly after enabling the keep-alive and preload changes, I tested the fix again in three stages on a fresh game, strictly one action at a time: first the garbage collection and unload rules alone, then with screens kept alive, then everything. No crash in any stage. The lesson for injected code: it must never raise an error inside the game’s Lua state, and after anything goes wrong, restart the game before measuring further.
The result
| as shipped | with the fix | |
|---|---|---|
| forced full Lua garbage collection | 250-300 ms each, several per minute | none (incremental, steps under 10 ms) |
| hitch a moment after closing a screen | 140-479 ms | none |
| battle start | one 341-618 ms frame | 135-142 ms |
Memory stayed bounded throughout: VRAM under 1.5 GB, with no growth over the stress runs.
Shipping it
I already ran the game with Special K, which can load extra DLLs at start-up, so the fix ships as a small Special K plug-in. It waits for the game’s Lua runtime, runs a short Lua script inside it once, and then gets out of the way. It shows a toast in the game when it’s active and reads its settings from a small INI file, so each part can be switched off.
// lua_pcall's first bytes are replaced by a jump here; this restores them first, so no trampoline is needed
static int on_pcall(lua_State *L, int nargs, int nresults, int errfunc) {
patch(lua_pcall, saved);
if (!game_ready(L)) { // game's Lua modules not loaded yet: re-arm and carry on
int rc = lua_pcall(L, nargs, nresults, errfunc);
patch(lua_pcall, jump);
return rc;
}
run_payload(L); // r99fix.ini -> config table, then r99fix.lua
return lua_pcall(L, nargs, nresults, errfunc);
}
Getting there produced one last crash: the first build registered its helper functions as ordinary Lua globals, and the game’s global table has hooks that raised an error at that moment, outside any error handler. Writing the globals directly, bypassing those hooks, fixed it.
// lua_setfield(L, LUA_GLOBALSINDEX, name) runs _G's __newindex, which raised an error here
static void raw_setglobal(lua_State *L, const char *name, lua_CFunction f) {
lua_pushstring(L, name);
lua_pushcclosure(L, f, 0);
lua_rawset(L, LUA_GLOBALSINDEX); // no metamethods, cannot raise
}
The source
I’m not offering a compiled DLL, but here is the complete source: the Lua script that does the work and the small C loader that runs it inside the game. Build it yourself, read it first, use it at your own risk.
-
Save the two listings below as
r99fix.luaandr99fix.c. -
Open the “x64 Native Tools Command Prompt for VS” (Visual Studio with the C++ workload) and run:
cl /O2 /LD r99fix.c dxgi.lib dxguid.lib psapi.lib -
Put
r99fix.dllandr99fix.luainto one folder. With the game closed, add this to the game’sSpecialK.iniin its Special K profile (keep the file’s encoding when you save it):[Import.R99Fix] Architecture=x64 Role=PlugIn When=PlugIn Filename=C:\path\to\r99fix.dll Mode=Normal
On the next start, a toast “r99fix 0.1.0 active” shows during the first loading screen, and r99fix.log
appears next to the DLL. An optional r99fix.ini in the same folder overrides the defaults at the top of the
script, for example:
[lua_gc]
pause=120
stepmul=1000
[unload]
enabled=1
[pin_views]
names=FightView,FightSuccView,FightFailView,FightSkillSelectView
r99fix.lua:
local VERSION = '0.1.0'
if rawget(_G, 'R99FIX') then return 'r99fix already active' end
local DEFAULTS = {
notify = true,
lua_gc = {
enabled = true,
pause = 120,
stepmul = 1000,
disable_forced = true,
},
unload = {
enabled = true,
ram_budget_mb = 0,
vram_budget_mb = 0,
max_age_s = 0,
},
keep_views = { names = {}, seconds = 86400 },
pin_views = { 'FightView', 'FightSuccView', 'FightFailView', 'FightSkillSelectView' },
}
local function merge(d, c)
local r = {}
for k, v in pairs(d) do
if type(v) == 'table' and type(c) == 'table' and type(c[k]) == 'table' and #v == 0 then r[k] = merge(v, c[k])
elseif type(c) == 'table' and c[k] ~= nil then r[k] = c[k]
else r[k] = v end
end
return r
end
local CFG = merge(DEFAULTS, rawget(_G, 'R99FIX_CONFIG'))
local R = { version = VERSION, cfg = CFG, log = {}, undo = {}, unloads = 0, skips = 0 }
rawset(_G, 'R99FIX', R)
local function note(s)
if #R.log >= 200 then table.remove(R.log, 1) end
R.log[#R.log + 1] = os.date('%H:%M:%S') .. ' ' .. s
end
local function lines(f) local i = f and debug.getinfo(f, 'S'); return i and (i.linedefined .. '-' .. i.lastlinedefined) end
local GC, SM, VM = rawget(_G, 'GameGCMgr'), rawget(_G, 'GameSceneMgr'), rawget(_G, 'ViewMgr')
local buildOk = GC ~= nil and lines(GC._resGC) == '74-84' and SM ~= nil and SM.instance ~= nil
and type(SM.instance.isLoading) == 'function' and VM ~= nil and type(VM.instance.getSetting) == 'function'
note('build check ' .. tostring(buildOk))
local U = SLFramework.UnityHelper
if CFG.lua_gc.enabled then
local oldPause = collectgarbage('setpause', CFG.lua_gc.pause)
collectgarbage('collect')
local oldMul = collectgarbage('setstepmul', CFG.lua_gc.stepmul)
R.undo[#R.undo + 1] = function() collectgarbage('setpause', oldPause); collectgarbage('setstepmul', oldMul) end
if CFG.lua_gc.disable_forced then
local orig = U.LuaGC
U.LuaGC = function() end
R.undo[#R.undo + 1] = function() U.LuaGC = orig end
end
note(string.format('lua gc: pause %s -> %s, stepmul %s -> %s', oldPause, CFG.lua_gc.pause, oldMul, CFG.lua_gc.stepmul))
end
if CFG.unload.enabled and buildOk then
local PLUMBING = { 'gc/GameGCMgr', 'eventsystem/', 'taskdispatcher/', 'event%.lua' }
local function origin()
for l = 3, 30 do
local i = debug.getinfo(l, 'Sl')
if not i or i.what == 'C' then return '?' end
local src, plumbing = tostring(i.short_src), false
for _, p in ipairs(PLUMBING) do if src:find(p) then plumbing = true; break end end
if not plumbing then return src .. ':' .. tostring(i.currentline) end
end
return '?'
end
local scene = GameSceneMgr.instance
local lastUnload = os.clock()
local resGC = U.ResGC
U.ResGC = function(...)
local why = scene:isLoading() and 'loading'
if not why and (CFG.unload.ram_budget_mb > 0 or CFG.unload.vram_budget_mb > 0) then
local memf = rawget(_G, 'R99FIX_MEM')
if type(memf) == 'function' then
local ram, vram = memf()
if (CFG.unload.ram_budget_mb > 0 and (ram or 0) > CFG.unload.ram_budget_mb)
or (CFG.unload.vram_budget_mb > 0 and (vram or 0) > CFG.unload.vram_budget_mb) then
why = string.format('budget ram %d vram %d', ram or -1, vram or -1)
end
end
end
if not why and CFG.unload.max_age_s > 0 and os.clock() - lastUnload >= CFG.unload.max_age_s then why = 'max_age' end
if why then
lastUnload = os.clock(); R.unloads = R.unloads + 1
note('unload (' .. why .. ') from ' .. origin())
return resGC(...)
end
R.skips = R.skips + 1
note('skip from ' .. origin())
end
R.undo[#R.undo + 1] = function() U.ResGC = resGC end
elseif CFG.unload.enabled then
note('unload policy off: unknown build')
end
if buildOk then
for _, name in ipairs(CFG.keep_views.names) do
local st = ViewMgr.instance:getSetting(name)
if type(st) == 'table' then
local old = rawget(st, 'destroy')
st.destroy = CFG.keep_views.seconds
R.undo[#R.undo + 1] = function() st.destroy = old end
else
note('keep: no view ' .. tostring(name))
end
end
end
if buildOk and #CFG.pin_views > 0 then
R.pinned = {}
local function pin(path)
if R.pinned[path] ~= nil then return end
R.pinned[path] = false
loadAbAsset(path, false, function(_, item)
if not item or not item.IsLoadSuccess then note('pin failed ' .. path); return end
item:Retain()
R.pinned[path] = { item = item, obj = item:GetResource(path) }
end, R)
end
for _, name in ipairs(CFG.pin_views) do
local st = ViewMgr.instance:getSetting(name)
if type(st) == 'table' and type(rawget(st, 'mainRes')) == 'string' then
pin(st.mainRes)
local other = rawget(st, 'otherRes')
if type(other) == 'table' then
for _, p in pairs(other) do if type(p) == 'string' and p:find('%.prefab$') then pin(p) end end
end
else
note('pin: no view ' .. tostring(name))
end
end
R.undo[#R.undo + 1] = function()
for _, e in pairs(R.pinned) do if e then e.item:Release() end end
end
end
R.remove = function()
for i = #R.undo, 1, -1 do R.undo[i]() end
rawset(_G, 'R99FIX', nil)
return 'r99fix removed'
end
if CFG.notify then
local tc = rawget(_G, 'ToastController')
if type(tc) == 'table' and tc.instance then
tc.instance:showToastWithString('r99fix ' .. VERSION .. ' active' .. (buildOk and '' or ' (GC only: unknown build)'))
end
end
return 'r99fix ' .. VERSION .. ' active; ' .. table.concat(R.log, '; ')
r99fix.c:
#define WIN32_LEAN_AND_MEAN
#define COBJMACROS
#include <windows.h>
#include <psapi.h>
#include <dxgi1_4.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <ctype.h>
typedef struct lua_State lua_State;
typedef int (*lua_CFunction)(lua_State *);
typedef int (*pcall_t)(lua_State *, int, int, int);
typedef int (*loadbuffer_t)(lua_State *, const char *, size_t, const char *);
typedef int (*gettop_t)(lua_State *);
typedef void (*settop_t)(lua_State *, int);
typedef const char *(*tolstring_t)(lua_State *, int, size_t *);
typedef void (*pushcclosure_t)(lua_State *, lua_CFunction, int);
typedef void (*setfield_t)(lua_State *, int, const char *);
typedef void (*pushnumber_t)(lua_State *, double);
typedef void (*getfield_t)(lua_State *, int, const char *);
typedef void (*rawget_t)(lua_State *, int);
typedef void (*rawset_t)(lua_State *, int);
typedef void (*pushstring_t)(lua_State *, const char *);
typedef int (*type_t)(lua_State *, int);
#define LUA_GLOBALSINDEX (-10002)
#define R99FIX_VERSION "0.1.0"
static pcall_t p_pcall;
static loadbuffer_t p_loadbuffer;
static gettop_t p_gettop;
static settop_t p_settop;
static tolstring_t p_tolstring;
static pushcclosure_t p_pushcclosure;
static setfield_t p_setfield;
static pushnumber_t p_pushnumber;
static getfield_t p_getfield;
static type_t p_type;
static rawget_t p_rawget;
static rawset_t p_rawset;
static pushstring_t p_pushstring;
static BYTE saved[14], jmp[14];
static wchar_t dir[MAX_PATH];
static IDXGIAdapter3 *adapter;
static void logmsg(const char *fmt, ...) {
wchar_t path[MAX_PATH];
_snwprintf_s(path, MAX_PATH, _TRUNCATE, L"%s\\r99fix.log", dir);
FILE *f;
if (_wfopen_s(&f, path, L"a") != 0) return;
SYSTEMTIME t;
GetLocalTime(&t);
fprintf(f, "%02d:%02d:%02d ", t.wHour, t.wMinute, t.wSecond);
va_list a;
va_start(a, fmt);
vfprintf(f, fmt, a);
va_end(a);
fputc('\n', f);
fclose(f);
}
static char *read_file(const wchar_t *name, size_t *len) {
wchar_t path[MAX_PATH];
_snwprintf_s(path, MAX_PATH, _TRUNCATE, L"%s\\%s", dir, name);
FILE *f;
if (_wfopen_s(&f, path, L"rb") != 0) return NULL;
fseek(f, 0, SEEK_END);
long n = ftell(f);
fseek(f, 0, SEEK_SET);
char *buf = (char *)malloc(n > 0 ? n : 1);
*len = buf ? fread(buf, 1, n, f) : 0;
fclose(f);
return buf;
}
static int l_mem(lua_State *L) {
PROCESS_MEMORY_COUNTERS_EX pmc = {sizeof(pmc)};
GetProcessMemoryInfo(GetCurrentProcess(), (PROCESS_MEMORY_COUNTERS *)&pmc, sizeof(pmc));
double vram = -1;
DXGI_QUERY_VIDEO_MEMORY_INFO info;
if (adapter && SUCCEEDED(IDXGIAdapter3_QueryVideoMemoryInfo(adapter, 0, DXGI_MEMORY_SEGMENT_GROUP_LOCAL, &info)))
vram = (double)info.CurrentUsage / (1024 * 1024);
p_pushnumber(L, (double)pmc.PrivateUsage / (1024 * 1024));
p_pushnumber(L, vram);
return 2;
}
static void open_adapter(void) {
IDXGIFactory1 *factory;
IDXGIAdapter1 *a1;
if (FAILED(CreateDXGIFactory1(&IID_IDXGIFactory1, (void **)&factory))) return;
if (SUCCEEDED(IDXGIFactory1_EnumAdapters1(factory, 0, &a1))) {
IDXGIAdapter1_QueryInterface(a1, &IID_IDXGIAdapter3, (void **)&adapter);
IDXGIAdapter1_Release(a1);
}
IDXGIFactory1_Release(factory);
}
static void ini_keys(char *out, size_t cap, const wchar_t *ini, const char *section, const char *const *keys, const char *kinds) {
char val[1024], path[MAX_PATH * 3];
WideCharToMultiByte(CP_ACP, 0, ini, -1, path, sizeof(path), NULL, NULL);
for (int i = 0; keys[i]; i++) {
GetPrivateProfileStringA(section, keys[i], "\x01", val, sizeof(val), path);
if (val[0] == '\x01') continue;
char *cut = strchr(val, ';');
if (cut) *cut = 0;
for (size_t n = strlen(val); n && (val[n - 1] == ' ' || val[n - 1] == '\t'); n--) val[n - 1] = 0;
char item[1200];
if (kinds[i] == 'b')
_snprintf_s(item, sizeof(item), _TRUNCATE, "%s=%s,", keys[i], (val[0] == '1' || val[0] == 't' || val[0] == 'T' || val[0] == 'y') ? "true" : "false");
else if (kinds[i] == 'n')
_snprintf_s(item, sizeof(item), _TRUNCATE, "%s=%g,", keys[i], atof(val));
else {
char list[1100] = "{";
char *ctx = NULL;
for (char *tok = strtok_s(val, ", ", &ctx); tok; tok = strtok_s(NULL, ", ", &ctx)) {
int ok = 1;
for (char *c = tok; *c; c++) if (!(isalnum((unsigned char)*c) || *c == '_')) ok = 0;
if (ok) { strcat_s(list, sizeof(list), "'"); strcat_s(list, sizeof(list), tok); strcat_s(list, sizeof(list), "',"); }
}
strcat_s(list, sizeof(list), "}");
_snprintf_s(item, sizeof(item), _TRUNCATE, "%s=%s,", keys[i], list);
}
strcat_s(out, cap, item);
}
}
static void build_config(char *out, size_t cap) {
wchar_t ini[MAX_PATH];
_snwprintf_s(ini, MAX_PATH, _TRUNCATE, L"%s\\r99fix.ini", dir);
static const char *const general[] = {"notify", NULL};
static const char *const gc[] = {"enabled", "pause", "stepmul", "disable_forced", NULL};
static const char *const unload[] = {"enabled", "ram_budget_mb", "vram_budget_mb", "max_age_s", NULL};
static const char *const keep[] = {"names", "seconds", NULL};
char part[4096];
strcpy_s(out, cap, "rawset(_G, 'R99FIX_CONFIG', {");
part[0] = 0; ini_keys(part, sizeof(part), ini, "general", general, "b"); strcat_s(out, cap, part);
part[0] = 0; ini_keys(part, sizeof(part), ini, "lua_gc", gc, "bnnb");
strcat_s(out, cap, "lua_gc={"); strcat_s(out, cap, part); strcat_s(out, cap, "},");
part[0] = 0; ini_keys(part, sizeof(part), ini, "unload", unload, "bnnn");
strcat_s(out, cap, "unload={"); strcat_s(out, cap, part); strcat_s(out, cap, "},");
part[0] = 0; ini_keys(part, sizeof(part), ini, "keep_views", keep, "ln");
strcat_s(out, cap, "keep_views={"); strcat_s(out, cap, part); strcat_s(out, cap, "},");
static const char *const pin[] = {"names", NULL};
part[0] = 0; ini_keys(part, sizeof(part), ini, "pin_views", pin, "l");
if (part[0]) {
strcat_s(out, cap, "pin_views="); strcat_s(out, cap, part + 6);
}
strcat_s(out, cap, "})");
}
static void run_chunk(lua_State *L, const wchar_t *name, int required) {
size_t len;
char *src = read_file(name, &len);
if (!src) {
if (required) logmsg("missing %ls", name);
return;
}
char chunkname[MAX_PATH];
_snprintf_s(chunkname, sizeof(chunkname), _TRUNCATE, "@%ls", name);
int top = p_gettop(L);
int rc = p_loadbuffer(L, src, len, chunkname);
if (rc == 0) rc = p_pcall(L, 0, 1, 0);
const char *s = p_tolstring(L, -1, NULL);
logmsg("%ls rc=%d %s", name, rc, s ? s : "");
p_settop(L, top);
free(src);
}
static void patch(BYTE *at, const BYTE *bytes) {
DWORD old;
VirtualProtect(at, sizeof(saved), PAGE_EXECUTE_READWRITE, &old);
memcpy(at, bytes, sizeof(saved));
VirtualProtect(at, sizeof(saved), old, &old);
FlushInstructionCache(GetCurrentProcess(), at, sizeof(saved));
}
static void raw_getfield(lua_State *L, int idx, const char *k) {
if (idx < 0 && idx > LUA_GLOBALSINDEX) idx--;
p_pushstring(L, k);
p_rawget(L, idx);
}
static void raw_setglobal(lua_State *L, const char *k, lua_CFunction f) {
p_pushstring(L, k);
p_pushcclosure(L, f, 0);
p_rawset(L, LUA_GLOBALSINDEX);
}
static int game_ready(lua_State *L) {
int top = p_gettop(L), ready = 0;
raw_getfield(L, LUA_GLOBALSINDEX, "GameGCMgr");
if (p_type(L, -1) == 5) {
raw_getfield(L, -1, "instance");
ready = p_type(L, -1) > 0;
}
p_settop(L, top);
return ready;
}
static int on_pcall(lua_State *L, int nargs, int nresults, int errfunc) {
patch((BYTE *)p_pcall, saved);
if (!game_ready(L)) {
int rc = p_pcall(L, nargs, nresults, errfunc);
patch((BYTE *)p_pcall, jmp);
return rc;
}
int top = p_gettop(L);
raw_setglobal(L, "R99FIX_MEM", l_mem);
static char cfg[16384];
build_config(cfg, sizeof(cfg));
logmsg("config: %s", cfg);
int ctop = p_gettop(L);
if (p_loadbuffer(L, cfg, strlen(cfg), "=r99fix.ini") == 0) p_pcall(L, 0, 0, 0);
p_settop(L, ctop);
run_chunk(L, L"r99fix.lua", 1);
p_settop(L, top);
return p_pcall(L, nargs, nresults, errfunc);
}
static DWORD WINAPI waiter(LPVOID unused) {
HMODULE tolua;
while (!(tolua = GetModuleHandleW(L"tolua.dll"))) Sleep(50);
#define GET(var, name) var = (void *)GetProcAddress(tolua, name); if (!var) { logmsg("tolua.dll lacks %s", name); return 0; }
GET(p_pcall, "lua_pcall");
GET(p_loadbuffer, "luaL_loadbuffer");
GET(p_gettop, "lua_gettop");
GET(p_settop, "lua_settop");
GET(p_tolstring, "lua_tolstring");
GET(p_pushcclosure, "lua_pushcclosure");
GET(p_setfield, "lua_setfield");
GET(p_pushnumber, "lua_pushnumber");
GET(p_getfield, "lua_getfield");
GET(p_type, "lua_type");
GET(p_rawget, "lua_rawget");
GET(p_rawset, "lua_rawset");
GET(p_pushstring, "lua_pushstring");
open_adapter();
const BYTE code[14] = {0x48, 0xB8, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF, 0xE0, 0x90, 0x90};
memcpy(jmp, code, sizeof(jmp));
void *target = (void *)on_pcall;
memcpy(jmp + 2, &target, 8);
memcpy(saved, (void *)p_pcall, sizeof(saved));
patch((BYTE *)p_pcall, jmp);
logmsg("r99fix " R99FIX_VERSION ": hooked lua_pcall at %p", (void *)p_pcall);
return 0;
}
__declspec(dllexport) BOOL WINAPI SKPlugIn_Init(HMODULE hSpecialK) {
logmsg("loaded as a Special K plug-in");
return TRUE;
}
BOOL WINAPI DllMain(HINSTANCE self, DWORD reason, LPVOID unused) {
if (reason == DLL_PROCESS_ATTACH) {
DisableThreadLibraryCalls(self);
GetModuleFileNameW(self, dir, MAX_PATH);
wchar_t *slash = wcsrchr(dir, L'\\');
if (slash) *slash = 0;
CloseHandle(CreateThread(NULL, 0, waiter, NULL, 0, NULL));
}
return TRUE;
}
What the developers could do
The fixes belong in the game, not in a plug-in, and they’re small:
- Don’t force full Lua garbage collections on UI transitions; let LuaJIT’s incremental collector work.
- Don’t unload unused assets after every screen on PC; do it during loading screens, or when memory actually runs short.
- Keep frequently used screens alive instead of destroying them on close, and refresh their data when they reappear.
- Create the battle UI during the battle’s loading screen, and load screens asynchronously, which the game’s own code already supports.
The full measurements are in the report for the developers.
Caveats
Everything here was measured on one PC and a new account with few characters. A large account has a bigger Lua heap and more loaded assets, so its freezes are probably longer and the fix probably helps more, but I haven’t measured that yet. The idea that this is a mobile memory policy is my interpretation of the code, not something the developers have said. And a plug-in that changes a game’s behaviour from the inside is a workaround: a game update can break it. That’s why it checks that the game’s code matches what it was written for; if not, it only applies the garbage collection change and leaves everything else as shipped.