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
forced full GC (as shipped)incremental, pause 120 onlyincremental, pause 120 + stepmul 1000
250300350400450500↑ Lua heap (MB)0246810121416minutes →

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).

01002003004000100200300400↑ frame time (ms)20406080100120140seconds →unload after every close (as shipped)unload skipped

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.

02468101214161820↑ unloads050100150200250300350400450500unload time (ms) →median 159 ms

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.

Screen closed View destroyedright away 1.5 s later:full Lua GC250-300 ms Unload unusedUnity assetsmedian 159 ms Next open:load and buildthe screen again

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
    end

    On 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.

  1. Save the two listings below as r99fix.lua and r99fix.c.

  2. 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
  3. Put r99fix.dll and r99fix.lua into one folder. With the game closed, add this to the game’s SpecialK.ini in 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:

  1. Don’t force full Lua garbage collections on UI transitions; let LuaJIT’s incremental collector work.
  2. Don’t unload unused assets after every screen on PC; do it during loading screens, or when memory actually runs short.
  3. Keep frequently used screens alive instead of destroying them on close, and refresh their data when they reappear.
  4. 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.

11 min

Keys

Global
hHome
tTags
rRSS
uUp
?Help
EscClose
Post
jScroll down
kScroll up
]Next section
[Previous section
gTop
GBottom