From 976a66f6d9753a0f6f5f97a5bdb7e9619400394f Mon Sep 17 00:00:00 2001 From: imsys <911254+imsys@users.noreply.github.com> Date: Tue, 3 Feb 2026 23:39:15 -0300 Subject: [PATCH 01/18] Initial test adding savestate support --- cmake/libretro.cmake | 1 + src/system/libretro/tic80_libretro.c | 194 +++++++++++++++++++++++++-- 2 files changed, 182 insertions(+), 13 deletions(-) diff --git a/cmake/libretro.cmake b/cmake/libretro.cmake index 54fa9b3dc..132c6d1a9 100644 --- a/cmake/libretro.cmake +++ b/cmake/libretro.cmake @@ -28,6 +28,7 @@ if(BUILD_LIBRETRO) target_include_directories(tic80_libretro PRIVATE ${CMAKE_CURRENT_BINARY_DIR} ${TIC80CORE_DIR} + ${TIC80CORE_DIR}/vendor/lua ) if(MINGW) diff --git a/src/system/libretro/tic80_libretro.c b/src/system/libretro/tic80_libretro.c index 322d365bb..6801c1960 100644 --- a/src/system/libretro/tic80_libretro.c +++ b/src/system/libretro/tic80_libretro.c @@ -3,13 +3,18 @@ #include #include #include -#include #include "tic.h" #include "libretro-common/include/libretro.h" #include "retro_inline.h" #include "retro_endianness.h" #include "libretro_core_options.h" #include "api.h" +#include "core/core.h" +#include "script.h" +#include +#include +#include +#include /** * system.h is used for: @@ -1197,12 +1202,77 @@ RETRO_API bool retro_load_game_special(unsigned type, const struct retro_game_in return retro_load_game(info); } +static char* SerializedLuaData = NULL; +static size_t SerializedLuaSize = 0; + +// Helper to free the cached serialized Lua data +static void free_serialized_lua() { + if (SerializedLuaData) { + free(SerializedLuaData); + SerializedLuaData = NULL; + } + SerializedLuaSize = 0; +} + +// Helper to serialize Lua _G table into a string +static void serialize_lua(tic_core* core) { + free_serialized_lua(); + + lua_State* lua = core->currentVM; + if (!lua) return; + + // Lua script to serialize the _G table, excluding built-in globals + const char* script = + "local function ser(o,v) " + "if type(o)=='number' or type(o)=='boolean' then return tostring(o) end " + "if type(o)=='string' then return string.format('%q',o) end " + "if type(o)=='table' and not v[o] then " + "v[o]=true local s='{' " + "for k,val in pairs(o) do " + "if k~='_G' and k~='package' and k~='coroutine' and k~='table' and k~='io' and " + "k~='os' and k~='string' and k~='math' and k~='utf8' and k~='debug' and k~='TIC' and " + "k~='SCN' and k~='BDR' and k~='OVR' and k~='print' and k~='trace' then " + "local ks=ser(k,v) local vs=ser(val,v) " + "if ks and vs then s=s..'['..ks..']='..vs..',' end end end " + "return s..'}' end return nil end " + "return ser(_G, {})"; + + if (luaL_dostring(lua, script) == LUA_OK) { + if (lua_isstring(lua, -1)) { + size_t len = 0; + const char* str = lua_tolstring(lua, -1, &len); + if (str) { + SerializedLuaData = malloc(len + 1); + if (SerializedLuaData) { + memcpy(SerializedLuaData, str, len); + SerializedLuaData[len] = '\0'; + SerializedLuaSize = len; + } + } + } + lua_pop(lua, 1); + } +} + /** * libretro callback; Retrieve the size of the serialized memory. */ size_t retro_serialize_size(void) { - return TIC_PERSISTENT_SIZE * sizeof(u32); + size_t size = sizeof(tic_ram) + sizeof(tic_core_state_data); + + if (state && state->tic) { + tic_core* core = (tic_core*)state->tic; + const tic_script* config = tic_get_script(&core->memory); + + // Only support Lua for now (ID 10) + if (config && config->id == 10 && core->currentVM) { + serialize_lua(core); // Populate SerializedLuaData and SerializedLuaSize + size += sizeof(u32) + SerializedLuaSize; // Size header + data + } + } + + return size; } /** @@ -1210,16 +1280,46 @@ size_t retro_serialize_size(void) */ RETRO_API bool retro_serialize(void *data, size_t size) { - TIC_UNUSED(size); if (state == NULL || state->tic == NULL || data == NULL) { return false; } - tic_mem* tic = (tic_mem*)state->tic; - u32* udata = (u32*)data; - for (u32 i = 0; i < TIC_PERSISTENT_SIZE; i++) { - udata[i] = tic->ram->persistent.data[i]; - } + tic_core* core = (tic_core*)state->tic; + u8* dst = (u8*)data; + + // Check if the provided buffer is large enough for the base state + if (size < sizeof(tic_ram) + sizeof(tic_core_state_data)) + return false; + + memcpy(dst, core->memory.ram, sizeof(tic_ram)); + dst += sizeof(tic_ram); + + memcpy(dst, &core->state, sizeof(tic_core_state_data)); + dst += sizeof(tic_core_state_data); + + // Append Lua state if available and fits + if (SerializedLuaSize > 0 && SerializedLuaData) { + if ((dst - (u8*)data) + sizeof(u32) + SerializedLuaSize <= size) { + *(u32*)dst = (u32)SerializedLuaSize; + dst += sizeof(u32); + memcpy(dst, SerializedLuaData, SerializedLuaSize); + } else { + // Not enough space for Lua data, but header might fit + if ((dst - (u8*)data) + sizeof(u32) <= size) { + *(u32*)dst = 0; // Indicate no Lua data saved + } + } + } else { + // If we allocated space for Lua in retro_serialize_size but no data was generated (e.g., not Lua core) + // or if serialization failed, write 0 size for Lua data. + // This ensures retro_unserialize can correctly skip. + if ((dst - (u8*)data) + sizeof(u32) <= size) { + *(u32*)dst = 0; + } + } + + // Free the cached Lua data after serialization + free_serialized_lua(); return true; } @@ -1229,14 +1329,82 @@ RETRO_API bool retro_serialize(void *data, size_t size) */ RETRO_API bool retro_unserialize(const void *data, size_t size) { - if (state == NULL || state->tic == NULL || size != retro_serialize_size() || data == NULL) { + if (state == NULL || state->tic == NULL || size < sizeof(tic_ram) + sizeof(tic_core_state_data) || data == NULL) { return false; } - tic_mem* tic = (tic_mem*)state->tic; - u32* uData = (u32*)data; - for (u32 i = 0; i < TIC_PERSISTENT_SIZE; i++) { - tic->ram->persistent.data[i] = uData[i]; + tic_core* core = (tic_core*)state->tic; + const u8* src = (const u8*)data; + + memcpy(core->memory.ram, src, sizeof(tic_ram)); + src += sizeof(tic_ram); + + memcpy(&core->state, src, sizeof(tic_core_state_data)); + src += sizeof(tic_core_state_data); + + // Fix pointers that were invalidated by the restore + for (s32 i = 0; i < TIC_SOUND_CHANNELS; i++) + { + core->state.sfx.channels[i].pos = &core->memory.ram->sfxpos[i]; + core->state.music.channels[i].pos = &core->state.music.sfxpos[i]; + core->state.music.commands[i].delay.row = NULL; + } + + const tic_script* config = tic_get_script(&core->memory); + if (config) + { + core->state.tick = config->tick; + core->state.callback = config->callback; + + // Restore Lua state if present and correct VM + if (config->id == 10 && core->currentVM) { + u32 luaSize = 0; + // Check if there's enough space to read the Lua size header + if ((src - (const u8*)data) + sizeof(u32) <= size) { + luaSize = *(const u32*)src; + src += sizeof(u32); + + // If Lua data exists and fits within the remaining buffer + if (luaSize > 0 && (src - (const u8*)data) + luaSize <= size) { + lua_State* lua = core->currentVM; + + // Push "return " string + lua_pushstring(lua, "return "); + // Push the serialized Lua table string + lua_pushlstring(lua, (const char*)src, luaSize); + // Concatenate them: "return { ... }" + lua_concat(lua, 2); + + // Load the concatenated string as a Lua chunk + if (luaL_loadstring(lua, lua_tostring(lua, -1)) == LUA_OK) { + // Execute the chunk, which should return the serialized table + if (lua_pcall(lua, 0, 1, 0) == LUA_OK) { + // Check if the result is a table + if (lua_istable(lua, -1)) { + // Iterate through the returned table and merge its contents into _G + lua_pushnil(lua); // Push nil to start iteration + while (lua_next(lua, -2) != 0) { // table is at -2, key at -2, value at -1 + lua_getglobal(lua, "_G"); // Push _G table + lua_pushvalue(lua, -3); // Copy key from stack (-3 relative to _G) + lua_pushvalue(lua, -3); // Copy value from stack (-3 relative to _G) + lua_settable(lua, -3); // _G[key] = value + lua_pop(lua, 1); // Pop _G table + lua_pop(lua, 1); // Pop value, leave key for next iteration + } + } + } else { + log_cb(RETRO_LOG_ERROR, "[TIC-80] Lua state unserialize error: %s\n", lua_tostring(lua, -1)); + } + } else { + log_cb(RETRO_LOG_ERROR, "[TIC-80] Lua state loadstring error: %s\n", lua_tostring(lua, -1)); + } + lua_pop(lua, 1); // Pop the result of the chunk (table or error message) + lua_pop(lua, 1); // Pop the concatenated "return { ... }" string + + src += luaSize; // Advance source pointer past Lua data + } + } + } } return true; From d4c96153c6a283104b210d5349aea04e65ba3144 Mon Sep 17 00:00:00 2001 From: imsys <911254+imsys@users.noreply.github.com> Date: Wed, 4 Feb 2026 00:08:48 -0300 Subject: [PATCH 02/18] fixes savestate when changing value of TIC main function pointer --- src/system/libretro/tic80_libretro.c | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/system/libretro/tic80_libretro.c b/src/system/libretro/tic80_libretro.c index 6801c1960..5f552d121 100644 --- a/src/system/libretro/tic80_libretro.c +++ b/src/system/libretro/tic80_libretro.c @@ -1221,19 +1221,25 @@ static void serialize_lua(tic_core* core) { lua_State* lua = core->currentVM; if (!lua) return; - // Lua script to serialize the _G table, excluding built-in globals + // Lua script to serialize the _G table, excluding built-in globals and standard callbacks + // It relies on finding global function names to serialize function references. const char* script = + "local function getName(f) " + "for k,v in pairs(_G) do " + "if v==f and k~='_G' then return k end " + "end return nil end " "local function ser(o,v) " "if type(o)=='number' or type(o)=='boolean' then return tostring(o) end " "if type(o)=='string' then return string.format('%q',o) end " + "if type(o)=='function' then local n=getName(o) if n then return '_G[\"'..n..'\"]' end return 'nil' end " "if type(o)=='table' and not v[o] then " "v[o]=true local s='{' " "for k,val in pairs(o) do " "if k~='_G' and k~='package' and k~='coroutine' and k~='table' and k~='io' and " - "k~='os' and k~='string' and k~='math' and k~='utf8' and k~='debug' and k~='TIC' and " - "k~='SCN' and k~='BDR' and k~='OVR' and k~='print' and k~='trace' then " + "k~='os' and k~='string' and k~='math' and k~='utf8' and k~='debug' and " + "k~='print' and k~='trace' then " "local ks=ser(k,v) local vs=ser(val,v) " - "if ks and vs then s=s..'['..ks..']='..vs..',' end end end " + "if ks and vs and vs~='nil' then s=s..'['..ks..']='..vs..',' end end end " "return s..'}' end return nil end " "return ser(_G, {})"; From beb34e4386556c1f8fe175bd6543f32fbd2e9c6b Mon Sep 17 00:00:00 2001 From: imsys <911254+imsys@users.noreply.github.com> Date: Wed, 4 Feb 2026 00:23:35 -0300 Subject: [PATCH 03/18] Stores the time in the savestate --- src/system/libretro/tic80_libretro.c | 29 ++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/system/libretro/tic80_libretro.c b/src/system/libretro/tic80_libretro.c index 5f552d121..0c19e7054 100644 --- a/src/system/libretro/tic80_libretro.c +++ b/src/system/libretro/tic80_libretro.c @@ -1278,7 +1278,7 @@ size_t retro_serialize_size(void) } } - return size; + return size + sizeof(retro_usec_t); } /** @@ -1309,20 +1309,28 @@ RETRO_API bool retro_serialize(void *data, size_t size) *(u32*)dst = (u32)SerializedLuaSize; dst += sizeof(u32); memcpy(dst, SerializedLuaData, SerializedLuaSize); + dst += SerializedLuaSize; } else { // Not enough space for Lua data, but header might fit if ((dst - (u8*)data) + sizeof(u32) <= size) { *(u32*)dst = 0; // Indicate no Lua data saved + dst += sizeof(u32); } } } else { - // If we allocated space for Lua in retro_serialize_size but no data was generated (e.g., not Lua core) - // or if serialization failed, write 0 size for Lua data. - // This ensures retro_unserialize can correctly skip. - if ((dst - (u8*)data) + sizeof(u32) <= size) { - *(u32*)dst = 0; - } - } + // If we allocated space for Lua in retro_serialize_size but no data was generated (e.g., not Lua core) + // or if serialization failed, write 0 size for Lua data. + // This ensures retro_unserialize can correctly skip. + if ((dst - (u8*)data) + sizeof(u32) <= size) { + *(u32*)dst = 0; + dst += sizeof(u32); + } + } + + // Save frame time + if ((dst - (u8*)data) + sizeof(retro_usec_t) <= size) { + memcpy(dst, &state->frameTime, sizeof(retro_usec_t)); + } // Free the cached Lua data after serialization free_serialized_lua(); @@ -1413,6 +1421,11 @@ RETRO_API bool retro_unserialize(const void *data, size_t size) } } + // Restore frame time + if ((src - (const u8*)data) + sizeof(retro_usec_t) <= size) { + memcpy(&state->frameTime, src, sizeof(retro_usec_t)); + } + return true; } From 1fc9517b1955338a7b83860a7d7e74001f4d986d Mon Sep 17 00:00:00 2001 From: imsys <911254+imsys@users.noreply.github.com> Date: Wed, 4 Feb 2026 19:19:42 -0300 Subject: [PATCH 04/18] Fixes savestate in Color Critters and Buried Deep Updated the serialization logic with several key improvements: Key Changes: Increased number precision from %.14g to %.17g - this should help with the buried-deep.lua position issue where the player was loading "a little under the floor" Fixed shared table reference handling - The previous code was trying to use _G["name"] references for already-visited tables, but these would evaluate to the old value during deserialization. Now, circular references return 'nil' to avoid issues, and we properly clear the visited marker after serialization Added recursion depth limit (20 levels) to prevent infinite loops Added callback function blacklist - Now excludes TIC, SCN, OVR, BOOT, MENU, and BDR callback functions which shouldn't be serialized Improved key type checking - Only allows string or number keys to be serialized The main fix for color-cri.lua is that the ctrl variable should now be properly saved and restored. The shared reference issue was preventing some globals from being correctly restored. For buried-deep.lua, the higher precision (%.17g) should preserve exact floating-point positions, preventing the "player loads under the floor" issue. --- cmake/libretro.cmake | 2 +- src/system/libretro/tic80_libretro.c | 48 +++++++++++++++++++--------- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/cmake/libretro.cmake b/cmake/libretro.cmake index 132c6d1a9..2480d7bc4 100644 --- a/cmake/libretro.cmake +++ b/cmake/libretro.cmake @@ -28,7 +28,7 @@ if(BUILD_LIBRETRO) target_include_directories(tic80_libretro PRIVATE ${CMAKE_CURRENT_BINARY_DIR} ${TIC80CORE_DIR} - ${TIC80CORE_DIR}/vendor/lua + ${THIRDPARTY_DIR}/lua ) if(MINGW) diff --git a/src/system/libretro/tic80_libretro.c b/src/system/libretro/tic80_libretro.c index 0c19e7054..49d782e17 100644 --- a/src/system/libretro/tic80_libretro.c +++ b/src/system/libretro/tic80_libretro.c @@ -1222,26 +1222,44 @@ static void serialize_lua(tic_core* core) { if (!lua) return; // Lua script to serialize the _G table, excluding built-in globals and standard callbacks - // It relies on finding global function names to serialize function references. + // Uses high precision for numbers and handles nested tables (without circular reference handling) const char* script = - "local function getName(f) " + "local function getName(o,t) " + "if t~='function' then return nil end " "for k,v in pairs(_G) do " - "if v==f and k~='_G' then return k end " + "if v==o and type(k)=='string' and k~='_G' and k~='package' and k~='coroutine' and " + "k~='table' and k~='io' and k~='os' and k~='string' and k~='math' and " + "k~='utf8' and k~='debug' then return k end " "end return nil end " - "local function ser(o,v) " - "if type(o)=='number' or type(o)=='boolean' then return tostring(o) end " - "if type(o)=='string' then return string.format('%q',o) end " - "if type(o)=='function' then local n=getName(o) if n then return '_G[\"'..n..'\"]' end return 'nil' end " - "if type(o)=='table' and not v[o] then " + "local function ser(o,v,d) " + "d=d or 0 if d>20 then return 'nil' end " + "local t=type(o) " + "if t=='number' then return string.format('%.17g',o) end " + "if t=='boolean' then return tostring(o) end " + "if t=='string' then return string.format('%q',o) end " + "if t=='function' then local n=getName(o,t) if n then return '_G[\"'..n..'\"]' end return 'nil' end " + "if t=='table' then " + "if v[o] then return 'nil' end " "v[o]=true local s='{' " "for k,val in pairs(o) do " - "if k~='_G' and k~='package' and k~='coroutine' and k~='table' and k~='io' and " - "k~='os' and k~='string' and k~='math' and k~='utf8' and k~='debug' and " - "k~='print' and k~='trace' then " - "local ks=ser(k,v) local vs=ser(val,v) " - "if ks and vs and vs~='nil' then s=s..'['..ks..']='..vs..',' end end end " - "return s..'}' end return nil end " - "return ser(_G, {})"; + "local kt=type(k) " + "if (kt=='string' or kt=='number') and k~='_G' and k~='package' and " + "k~='coroutine' and k~='table' and k~='io' and k~='os' and k~='string' and " + "k~='math' and k~='utf8' and k~='debug' and k~='print' and k~='trace' and " + "k~='cls' and k~='spr' and k~='map' and k~='mget' and k~='mset' and " + "k~='fget' and k~='fset' and k~='sfx' and k~='music' and k~='peek' and " + "k~='poke' and k~='peek4' and k~='poke4' and k~='memcpy' and k~='memset' and " + "k~='pmem' and k~='time' and k~='tstamp' and k~='exit' and k~='font' and " + "k~='mouse' and k~='circ' and k~='circb' and k~='rect' and k~='rectb' and " + "k~='line' and k~='pix' and k~='btn' and k~='btnp' and k~='key' and " + "k~='keyp' and k~='textri' and k~='ttri' and k~='clip' and k~='vbank' and " + "k~='sync' and k~='TIC' and k~='SCN' and k~='OVR' and k~='BOOT' and " + "k~='MENU' and k~='BDR' then " + "local ks=ser(k,v,d+1) local vs=ser(val,v,d+1) " + "if ks and vs and ks~='nil' and vs~='nil' then " + "s=s..'['..ks..']='..vs..',' end end end " + "v[o]=nil return s..'}' end return 'nil' end " + "return ser(_G,{})"; if (luaL_dostring(lua, script) == LUA_OK) { if (lua_isstring(lua, -1)) { From 9077c1e87ba3b3c7385366241cf683f62bc90b76 Mon Sep 17 00:00:00 2001 From: imsys <911254+imsys@users.noreply.github.com> Date: Wed, 4 Feb 2026 22:20:42 -0300 Subject: [PATCH 05/18] fixes some games savestates Fixes: 9BUTTERF and Bouncelot --- src/system/libretro/tic80_libretro.c | 157 +++++++++++++++++++++++---- 1 file changed, 133 insertions(+), 24 deletions(-) diff --git a/src/system/libretro/tic80_libretro.c b/src/system/libretro/tic80_libretro.c index 49d782e17..c290ef873 100644 --- a/src/system/libretro/tic80_libretro.c +++ b/src/system/libretro/tic80_libretro.c @@ -1221,8 +1221,9 @@ static void serialize_lua(tic_core* core) { lua_State* lua = core->currentVM; if (!lua) return; - // Lua script to serialize the _G table, excluding built-in globals and standard callbacks - // Uses high precision for numbers and handles nested tables (without circular reference handling) + // Lua script to serialize the _G table + // Properly handles standard library references by serializing them as special markers + // Skips unserializable functions so they can be preserved during merge const char* script = "local function getName(o,t) " "if t~='function' then return nil end " @@ -1234,27 +1235,29 @@ static void serialize_lua(tic_core* core) { "local function ser(o,v,d) " "d=d or 0 if d>20 then return 'nil' end " "local t=type(o) " - "if t=='number' then return string.format('%.17g',o) end " + "if t=='number' then return string.format('%.17g',o) end " // Test on game "Buried Deep" "if t=='boolean' then return tostring(o) end " "if t=='string' then return string.format('%q',o) end " - "if t=='function' then local n=getName(o,t) if n then return '_G[\"'..n..'\"]' end return 'nil' end " + // If function is unnamed, return nil to skip it (instead of 'nil' string) + "if t=='function' then local n=getName(o,t) if n then return '_G[\"'..n..'\"]' end return nil end " "if t=='table' then " + // Check if this is a standard library reference + "if o==math then return '\"__STDLIB_MATH__\"' end " + "if o==table then return '\"__STDLIB_TABLE__\"' end " + "if o==string then return '\"__STDLIB_STRING__\"' end " + "if o==coroutine then return '\"__STDLIB_COROUTINE__\"' end " + "if o==package then return '\"__STDLIB_PACKAGE__\"' end " + "if o==io then return '\"__STDLIB_IO__\"' end " + "if o==os then return '\"__STDLIB_OS__\"' end " + "if o==utf8 then return '\"__STDLIB_UTF8__\"' end " + "if o==debug then return '\"__STDLIB_DEBUG__\"' end " "if v[o] then return 'nil' end " "v[o]=true local s='{' " "for k,val in pairs(o) do " "local kt=type(k) " "if (kt=='string' or kt=='number') and k~='_G' and k~='package' and " "k~='coroutine' and k~='table' and k~='io' and k~='os' and k~='string' and " - "k~='math' and k~='utf8' and k~='debug' and k~='print' and k~='trace' and " - "k~='cls' and k~='spr' and k~='map' and k~='mget' and k~='mset' and " - "k~='fget' and k~='fset' and k~='sfx' and k~='music' and k~='peek' and " - "k~='poke' and k~='peek4' and k~='poke4' and k~='memcpy' and k~='memset' and " - "k~='pmem' and k~='time' and k~='tstamp' and k~='exit' and k~='font' and " - "k~='mouse' and k~='circ' and k~='circb' and k~='rect' and k~='rectb' and " - "k~='line' and k~='pix' and k~='btn' and k~='btnp' and k~='key' and " - "k~='keyp' and k~='textri' and k~='ttri' and k~='clip' and k~='vbank' and " - "k~='sync' and k~='TIC' and k~='SCN' and k~='OVR' and k~='BOOT' and " - "k~='MENU' and k~='BDR' then " + "k~='math' and k~='utf8' and k~='debug' then " "local ks=ser(k,v,d+1) local vs=ser(val,v,d+1) " "if ks and vs and ks~='nil' and vs~='nil' then " "s=s..'['..ks..']='..vs..',' end end end " @@ -1413,15 +1416,121 @@ RETRO_API bool retro_unserialize(const void *data, size_t size) if (lua_pcall(lua, 0, 1, 0) == LUA_OK) { // Check if the result is a table if (lua_istable(lua, -1)) { - // Iterate through the returned table and merge its contents into _G - lua_pushnil(lua); // Push nil to start iteration - while (lua_next(lua, -2) != 0) { // table is at -2, key at -2, value at -1 - lua_getglobal(lua, "_G"); // Push _G table - lua_pushvalue(lua, -3); // Copy key from stack (-3 relative to _G) - lua_pushvalue(lua, -3); // Copy value from stack (-3 relative to _G) - lua_settable(lua, -3); // _G[key] = value - lua_pop(lua, 1); // Pop _G table - lua_pop(lua, 1); // Pop value, leave key for next iteration + // First pass: restore standard library references + // This converts marker strings like "__STDLIB_MATH__" to actual library references + // We iterate through the table and ONLY replace entries that have stdlib markers + lua_pushnil(lua); + while (lua_next(lua, -2) != 0) { + // key at -2, value at -1 + int replaced = 0; + if (lua_isstring(lua, -1)) { + const char* str = lua_tostring(lua, -1); + if (strcmp(str, "__STDLIB_MATH__") == 0) { + lua_pop(lua, 1); // pop the string marker + lua_pushvalue(lua, -1); // copy key (at -1 now after pop) + lua_getglobal(lua, "math"); + lua_settable(lua, -4); // table is at -4: table, key, key_copy, math + replaced = 1; + } else if (strcmp(str, "__STDLIB_TABLE__") == 0) { + lua_pop(lua, 1); + lua_pushvalue(lua, -1); + lua_getglobal(lua, "table"); + lua_settable(lua, -4); + replaced = 1; + } else if (strcmp(str, "__STDLIB_STRING__") == 0) { + lua_pop(lua, 1); + lua_pushvalue(lua, -1); + lua_getglobal(lua, "string"); + lua_settable(lua, -4); + replaced = 1; + } else if (strcmp(str, "__STDLIB_COROUTINE__") == 0) { + lua_pop(lua, 1); + lua_pushvalue(lua, -1); + lua_getglobal(lua, "coroutine"); + lua_settable(lua, -4); + replaced = 1; + } else if (strcmp(str, "__STDLIB_PACKAGE__") == 0) { + lua_pop(lua, 1); + lua_pushvalue(lua, -1); + lua_getglobal(lua, "package"); + lua_settable(lua, -4); + replaced = 1; + } else if (strcmp(str, "__STDLIB_IO__") == 0) { + lua_pop(lua, 1); + lua_pushvalue(lua, -1); + lua_getglobal(lua, "io"); + lua_settable(lua, -4); + replaced = 1; + } else if (strcmp(str, "__STDLIB_OS__") == 0) { + lua_pop(lua, 1); + lua_pushvalue(lua, -1); + lua_getglobal(lua, "os"); + lua_settable(lua, -4); + replaced = 1; + } else if (strcmp(str, "__STDLIB_UTF8__") == 0) { + lua_pop(lua, 1); + lua_pushvalue(lua, -1); + lua_getglobal(lua, "utf8"); + lua_settable(lua, -4); + replaced = 1; + } else if (strcmp(str, "__STDLIB_DEBUG__") == 0) { + lua_pop(lua, 1); + lua_pushvalue(lua, -1); + lua_getglobal(lua, "debug"); + lua_settable(lua, -4); + replaced = 1; + } + } + // If we didn't replace a stdlib marker, just pop the value + // to leave the key for lua_next to continue iteration + if (!replaced) { + lua_pop(lua, 1); // pop value, leave key for next iteration + } + // If we did replace, the settable already consumed key_copy and value, + // but original key is still at -1 for lua_next + } + + // Second pass: merge the restored table into _G using a smart merge strategy + // This preserves existing functions (code) that couldn't be serialized + const char* merge_script = + "local S = ...\n" + "local seen = {}\n" + "local function update(d, s)\n" + " if seen[d] then return end\n" + " seen[d] = true\n" + " -- Update/Add keys from S to D\n" + " for k,v in pairs(s) do\n" + " if type(d[k]) == 'table' and type(v) == 'table' then\n" + " update(d[k], v)\n" + " else\n" + " d[k] = v\n" + " end\n" + " end\n" + " -- Remove keys in D not in S (ghost objects), unless they are functions (code)\n" + " for k,v in pairs(d) do\n" + " if s[k] == nil then\n" + " if type(v) ~= 'function' then\n" + " -- Protect standard libraries and special globals if they were missing in S\n" + " if k ~= '_G' and k ~= 'package' and k ~= 'coroutine' and\n" + " k ~= 'table' and k ~= 'io' and k ~= 'os' and k ~= 'string' and\n" + " k ~= 'math' and k ~= 'utf8' and k ~= 'debug' then\n" + " d[k] = nil\n" + " end\n" + " end\n" + " end\n" + " end\n" + "end\n" + "update(_G, S)\n"; + + if (luaL_loadstring(lua, merge_script) == LUA_OK) { + lua_pushvalue(lua, -2); // Push the table S (currently at -2) + if (lua_pcall(lua, 1, 0, 0) != LUA_OK) { + log_cb(RETRO_LOG_ERROR, "[TIC-80] Lua merge script error: %s\n", lua_tostring(lua, -1)); + lua_pop(lua, 1); // pop error + } + } else { + log_cb(RETRO_LOG_ERROR, "[TIC-80] Lua merge compile error: %s\n", lua_tostring(lua, -1)); + lua_pop(lua, 1); // pop error } } } else { @@ -1430,7 +1539,7 @@ RETRO_API bool retro_unserialize(const void *data, size_t size) } else { log_cb(RETRO_LOG_ERROR, "[TIC-80] Lua state loadstring error: %s\n", lua_tostring(lua, -1)); } - lua_pop(lua, 1); // Pop the result of the chunk (table or error message) + lua_pop(lua, 1); // Pop the result of the chunk (table S or error message) lua_pop(lua, 1); // Pop the concatenated "return { ... }" string src += luaSize; // Advance source pointer past Lua data From 9a47c508393ea154198ad8f13fab282cf5b44960 Mon Sep 17 00:00:00 2001 From: imsys <911254+imsys@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:18:28 -0300 Subject: [PATCH 06/18] Partial fix on Boomerang 2 --- src/system/libretro/tic80_libretro.c | 116 ++++++++++++++++----------- 1 file changed, 70 insertions(+), 46 deletions(-) diff --git a/src/system/libretro/tic80_libretro.c b/src/system/libretro/tic80_libretro.c index c290ef873..e5e0a6182 100644 --- a/src/system/libretro/tic80_libretro.c +++ b/src/system/libretro/tic80_libretro.c @@ -1225,43 +1225,61 @@ static void serialize_lua(tic_core* core) { // Properly handles standard library references by serializing them as special markers // Skips unserializable functions so they can be preserved during merge const char* script = - "local function getName(o,t) " - "if t~='function' then return nil end " + "local names = {} " "for k,v in pairs(_G) do " - "if v==o and type(k)=='string' and k~='_G' and k~='package' and k~='coroutine' and " - "k~='table' and k~='io' and k~='os' and k~='string' and k~='math' and " - "k~='utf8' and k~='debug' then return k end " - "end return nil end " + " if (type(v)=='table' or type(v)=='function') and k~='_G' then names[v] = k end " + "end " + "local libs = {'math','table','string','coroutine','package','io','os','utf8','debug'} " + "for _,ln in ipairs(libs) do " + " local l = _G[ln] if type(l)=='table' then " + " for k,v in pairs(l) do " + " if type(v)=='table' or type(v)=='function' then names[v] = ln..'.'..k end " + " end " + " end " + "end " + "local function getExpr(o) " + " local n = names[o] if not n then return nil end " + " local res = '_G' " + " for part in n:gmatch('[^.]+') do res = res .. '[' .. string.format('%q', part) .. ']' end " + " return res " + "end " "local function ser(o,v,d) " - "d=d or 0 if d>20 then return 'nil' end " - "local t=type(o) " - "if t=='number' then return string.format('%.17g',o) end " // Test on game "Buried Deep" - "if t=='boolean' then return tostring(o) end " - "if t=='string' then return string.format('%q',o) end " - // If function is unnamed, return nil to skip it (instead of 'nil' string) - "if t=='function' then local n=getName(o,t) if n then return '_G[\"'..n..'\"]' end return nil end " - "if t=='table' then " - // Check if this is a standard library reference - "if o==math then return '\"__STDLIB_MATH__\"' end " - "if o==table then return '\"__STDLIB_TABLE__\"' end " - "if o==string then return '\"__STDLIB_STRING__\"' end " - "if o==coroutine then return '\"__STDLIB_COROUTINE__\"' end " - "if o==package then return '\"__STDLIB_PACKAGE__\"' end " - "if o==io then return '\"__STDLIB_IO__\"' end " - "if o==os then return '\"__STDLIB_OS__\"' end " - "if o==utf8 then return '\"__STDLIB_UTF8__\"' end " - "if o==debug then return '\"__STDLIB_DEBUG__\"' end " - "if v[o] then return 'nil' end " - "v[o]=true local s='{' " - "for k,val in pairs(o) do " - "local kt=type(k) " - "if (kt=='string' or kt=='number') and k~='_G' and k~='package' and " - "k~='coroutine' and k~='table' and k~='io' and k~='os' and k~='string' and " - "k~='math' and k~='utf8' and k~='debug' then " - "local ks=ser(k,v,d+1) local vs=ser(val,v,d+1) " - "if ks and vs and ks~='nil' and vs~='nil' then " - "s=s..'['..ks..']='..vs..',' end end end " - "v[o]=nil return s..'}' end return 'nil' end " + " d=d or 0 if d>30 then return nil end " + " local t=type(o) " + " if t=='number' then return string.format('%.17g',o) end " // Test on game "Buried Deep" to validate + " if t=='boolean' then return tostring(o) end " + " if t=='string' then return string.format('%q',o) end " + " if t=='function' then return getExpr(o) end " + " if t=='table' then " + " if o==math then return '\"__STDLIB_MATH__\"' end " + " if o==table then return '\"__STDLIB_TABLE__\"' end " + " if o==string then return '\"__STDLIB_STRING__\"' end " + " if o==coroutine then return '\"__STDLIB_COROUTINE__\"' end " + " if o==package then return '\"__STDLIB_PACKAGE__\"' end " + " if o==io then return '\"__STDLIB_IO__\"' end " + " if o==os then return '\"__STDLIB_OS__\"' end " + " if o==utf8 then return '\"__STDLIB_UTF8__\"' end " + " if o==debug then return '\"__STDLIB_DEBUG__\"' end " + " if v[o] then return nil end " + " v[o]=true " + " local mt = getmetatable(o) " + " local mte = mt and getExpr(mt) " + " local s = mte and 'setmetatable(' or '' " + " s = s .. '{' " + " for k,val in pairs(o) do " + " if (type(k)=='string' or type(k)=='number') and k~='_G' and k~='package' and " + " k~='coroutine' and k~='table' and k~='io' and k~='os' and k~='string' and " + " k~='math' and k~='utf8' and k~='debug' then " + " local ks=ser(k,v,d+1) local vs=ser(val,v,d+1) " + " if ks and vs then s=s..'['..ks..']='..vs..',' end " + " end " + " end " + " s = s .. '}' " + " if mte then s = s .. ',' .. mte .. ')' end " + " v[o]=nil return s " + " end " + " return nil " + "end " "return ser(_G,{})"; if (luaL_dostring(lua, script) == LUA_OK) { @@ -1495,27 +1513,33 @@ RETRO_API bool retro_unserialize(const void *data, size_t size) const char* merge_script = "local S = ...\n" "local seen = {}\n" + "local STDLIB = {\n" + " __STDLIB_MATH__ = math, __STDLIB_TABLE__ = table, __STDLIB_STRING__ = string,\n" + " __STDLIB_COROUTINE__ = coroutine, __STDLIB_PACKAGE__ = package, __STDLIB_IO__ = io,\n" + " __STDLIB_OS__ = os, __STDLIB_UTF8__ = utf8, __STDLIB_DEBUG__ = debug\n" + "}\n" "local function update(d, s)\n" " if seen[d] then return end\n" " seen[d] = true\n" - " -- Update/Add keys from S to D\n" " for k,v in pairs(s) do\n" - " if type(d[k]) == 'table' and type(v) == 'table' then\n" + " if type(v) == 'string' and STDLIB[v] then\n" + " d[k] = STDLIB[v]\n" + " elseif type(d[k]) == 'table' and type(v) == 'table' then\n" + " local mt_s = getmetatable(v)\n" + " if mt_s then setmetatable(d[k], mt_s) end\n" " update(d[k], v)\n" " else\n" " d[k] = v\n" " end\n" " end\n" - " -- Remove keys in D not in S (ghost objects), unless they are functions (code)\n" " for k,v in pairs(d) do\n" - " if s[k] == nil then\n" - " if type(v) ~= 'function' then\n" - " -- Protect standard libraries and special globals if they were missing in S\n" - " if k ~= '_G' and k ~= 'package' and k ~= 'coroutine' and\n" - " k ~= 'table' and k ~= 'io' and k ~= 'os' and k ~= 'string' and\n" - " k ~= 'math' and k ~= 'utf8' and k ~= 'debug' then\n" - " d[k] = nil\n" - " end\n" + " if s[k] == nil and type(v) ~= 'function' and type(v) ~= 'table' then\n" + " -- Only remove primitive values. Tables/Functions might be skipped by serializer.\n" + " -- This preserves metatables/objects and system globals.\n" + " if d ~= _G or (k ~= '_G' and k ~= 'package' and k ~= 'coroutine' and\n" + " k ~= 'table' and k ~= 'io' and k ~= 'os' and k ~= 'string' and\n" + " k ~= 'math' and k ~= 'utf8' and k ~= 'debug') then\n" + " d[k] = nil\n" " end\n" " end\n" " end\n" From a4a1b30356c32f45113d7d1378171710b559c129 Mon Sep 17 00:00:00 2001 From: imsys <911254+imsys@users.noreply.github.com> Date: Thu, 5 Feb 2026 18:56:00 -0300 Subject: [PATCH 07/18] Fixes Beyond Underground, Bouncelot and a bit of Boomerang2 --- src/system/libretro/tic80_libretro.c | 34 +++++++++++++++++++++------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/src/system/libretro/tic80_libretro.c b/src/system/libretro/tic80_libretro.c index e5e0a6182..85e51fbf8 100644 --- a/src/system/libretro/tic80_libretro.c +++ b/src/system/libretro/tic80_libretro.c @@ -1226,17 +1226,31 @@ static void serialize_lua(tic_core* core) { // Skips unserializable functions so they can be preserved during merge const char* script = "local names = {} " - "for k,v in pairs(_G) do " - " if (type(v)=='table' or type(v)=='function') and k~='_G' then names[v] = k end " - "end " "local libs = {'math','table','string','coroutine','package','io','os','utf8','debug'} " - "for _,ln in ipairs(libs) do " - " local l = _G[ln] if type(l)=='table' then " - " for k,v in pairs(l) do " - " if type(v)=='table' or type(v)=='function' then names[v] = ln..'.'..k end " + "for _,n in ipairs(libs) do if _G[n] then names[_G[n]] = n end end " + "names[_G] = '_G' " + "local visited = {} " + "local function scan(t, path, d) " + " if d>4 or visited[t] then return end " + " visited[t] = true " + " for k,v in pairs(t) do " + " if type(k)=='string' and k:match('^[%a_][%w_]*$') then " + " local sub = (path=='_G') and k or (path..'.'..k) " + " if type(v)=='function' then " + " names[v]=sub " + " elseif type(v)=='table' then " + " if k~='_G' and k~='package' then " + " local is_class = false " // Important for Beyond The Underground (detects Vec, Mat, etc.) + " if type(v.new)=='function' then is_class=true end " + " if v.__index then is_class=true end " + " if is_class and not names[v] then names[v] = sub end " // Only reference classes, serialize instances by value (Color Critters) + " scan(v, sub, d+1) " + " end " + " end " " end " " end " "end " + "scan(_G, '_G', 0) " "local function getExpr(o) " " local n = names[o] if not n then return nil end " " local res = '_G' " @@ -1246,7 +1260,11 @@ static void serialize_lua(tic_core* core) { "local function ser(o,v,d) " " d=d or 0 if d>30 then return nil end " " local t=type(o) " - " if t=='number' then return string.format('%.17g',o) end " // Test on game "Buried Deep" to validate + " if t=='number' then " // Important for Bouncelot (preserves float vs int) + " local s=string.format('%.17g',o) " // Important for Buried Deep (preserves precision) + " if math.type(o)=='float' and not s:find('[^%-0-9]') then s=s..'.0' end " + " return s " + " end " " if t=='boolean' then return tostring(o) end " " if t=='string' then return string.format('%q',o) end " " if t=='function' then return getExpr(o) end " From 31a037833deb625fafcbd267508fc7f958fffcec Mon Sep 17 00:00:00 2001 From: imsys <911254+imsys@users.noreply.github.com> Date: Thu, 5 Feb 2026 20:37:55 -0300 Subject: [PATCH 08/18] Partial Balmung savestate fix Working nice: Beyond The Underground Bouncelot Buried Deep Color Critters Drill goes brrrrr Somewhat working: Balmung 9Butterf Einar --- src/system/libretro/tic80_libretro.c | 49 +++++++++++++++++++--------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/src/system/libretro/tic80_libretro.c b/src/system/libretro/tic80_libretro.c index 85e51fbf8..d2a1ace4d 100644 --- a/src/system/libretro/tic80_libretro.c +++ b/src/system/libretro/tic80_libretro.c @@ -1536,31 +1536,50 @@ RETRO_API bool retro_unserialize(const void *data, size_t size) " __STDLIB_COROUTINE__ = coroutine, __STDLIB_PACKAGE__ = package, __STDLIB_IO__ = io,\n" " __STDLIB_OS__ = os, __STDLIB_UTF8__ = utf8, __STDLIB_DEBUG__ = debug\n" "}\n" + "local function is_array(t)\n" + " local i = 0\n" + " for _ in pairs(t) do\n" + " i = i + 1\n" + " if t[i] == nil then return false end\n" + " end\n" + " return true\n" + "end\n" "local function update(d, s)\n" " if seen[d] then return end\n" " seen[d] = true\n" " for k,v in pairs(s) do\n" " if type(v) == 'string' and STDLIB[v] then\n" " d[k] = STDLIB[v]\n" - " elseif type(d[k]) == 'table' and type(v) == 'table' then\n" - " local mt_s = getmetatable(v)\n" - " if mt_s then setmetatable(d[k], mt_s) end\n" - " update(d[k], v)\n" + " elseif type(v) == 'table' then\n" + " if type(d[k]) ~= 'table' then\n" + " d[k] = {}\n" + " end\n" + " -- Important for Balmung: For arrays, preserve object metatables\n" + " if is_array(v) then\n" + " -- Update array length\n" + " for i = #v + 1, #d[k] do d[k][i] = nil end\n" + " for i = 1, #v do\n" + " if type(v[i]) == 'table' then\n" + " -- Important for Balmung: Only update existing tables to avoid creating objects without methods (closures)\n" + " if type(d[k][i]) == 'table' then\n" + " -- Preserve metatable of existing object\n" + " local mt = getmetatable(d[k][i])\n" + " update(d[k][i], v[i])\n" + " if mt then setmetatable(d[k][i], mt) end\n" + " end\n" + " else\n" + " d[k][i] = v[i]\n" + " end\n" + " end\n" + " else\n" + " local mt_s = getmetatable(v)\n" + " if mt_s then setmetatable(d[k], mt_s) end\n" + " update(d[k], v)\n" + " end\n" " else\n" " d[k] = v\n" " end\n" " end\n" - " for k,v in pairs(d) do\n" - " if s[k] == nil and type(v) ~= 'function' and type(v) ~= 'table' then\n" - " -- Only remove primitive values. Tables/Functions might be skipped by serializer.\n" - " -- This preserves metatables/objects and system globals.\n" - " if d ~= _G or (k ~= '_G' and k ~= 'package' and k ~= 'coroutine' and\n" - " k ~= 'table' and k ~= 'io' and k ~= 'os' and k ~= 'string' and\n" - " k ~= 'math' and k ~= 'utf8' and k ~= 'debug') then\n" - " d[k] = nil\n" - " end\n" - " end\n" - " end\n" "end\n" "update(_G, S)\n"; From 8deee9dad4cbcb22e399ae0346a2b330930bbed1 Mon Sep 17 00:00:00 2001 From: imsys <911254+imsys@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:15:37 -0300 Subject: [PATCH 09/18] Adds notification for non-Lua carts when trying to savestate --- src/system/libretro/tic80_libretro.c | 40 ++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/src/system/libretro/tic80_libretro.c b/src/system/libretro/tic80_libretro.c index d2a1ace4d..202ed7721 100644 --- a/src/system/libretro/tic80_libretro.c +++ b/src/system/libretro/tic80_libretro.c @@ -1326,10 +1326,11 @@ size_t retro_serialize_size(void) if (state && state->tic) { tic_core* core = (tic_core*)state->tic; - const tic_script* config = tic_get_script(&core->memory); + const tic_script* config = core->currentScript; + if (config == NULL) config = tic_get_script(&core->memory); - // Only support Lua for now (ID 10) - if (config && config->id == 10 && core->currentVM) { + // Only support Lua for now + if (config && strcmp(config->name, "lua") == 0 && core->currentVM) { serialize_lua(core); // Populate SerializedLuaData and SerializedLuaSize size += sizeof(u32) + SerializedLuaSize; // Size header + data } @@ -1348,6 +1349,33 @@ RETRO_API bool retro_serialize(void *data, size_t size) } tic_core* core = (tic_core*)state->tic; + + const tic_script* config = core->currentScript; + if (config == NULL) config = tic_get_script(&core->memory); + + if (config == NULL || strcmp(config->name, "lua") != 0) { + if (environ_cb) { + const char *txt = "Savestate currently not supported in non-Lua TIC-80 cartridges"; + + /* Attempt to use the modern 'Widget' notification style */ + struct retro_message_ext msg_ext = { + .msg = txt, + .duration = 3000, /* 3 seconds (milliseconds) */ + .priority = 3, /* High priority triggers the widget */ + .level = RETRO_LOG_INFO, /* Also sends to logs */ + .target = RETRO_MESSAGE_TARGET_ALL, /* Show on OSD and logs */ + .type = RETRO_MESSAGE_TYPE_NOTIFICATION + }; + + /* If the frontend doesn't support _EXT (returns false), use the old fallback */ + if (!environ_cb(RETRO_ENVIRONMENT_SET_MESSAGE_EXT, &msg_ext)) { + struct retro_message msg = { txt, 180 }; /* 180 frames (~3s at 60fps) */ + environ_cb(RETRO_ENVIRONMENT_SET_MESSAGE, &msg); + } + } + return false; + } + u8* dst = (u8*)data; // Check if the provided buffer is large enough for the base state @@ -1421,14 +1449,16 @@ RETRO_API bool retro_unserialize(const void *data, size_t size) core->state.music.commands[i].delay.row = NULL; } - const tic_script* config = tic_get_script(&core->memory); + const tic_script* config = core->currentScript; + if (config == NULL) config = tic_get_script(&core->memory); + if (config) { core->state.tick = config->tick; core->state.callback = config->callback; // Restore Lua state if present and correct VM - if (config->id == 10 && core->currentVM) { + if (strcmp(config->name, "lua") == 0 && core->currentVM) { u32 luaSize = 0; // Check if there's enough space to read the Lua size header if ((src - (const u8*)data) + sizeof(u32) <= size) { From b74aa60c5cbe43fc5510b9a8603fe94252203802 Mon Sep 17 00:00:00 2001 From: imsys <911254+imsys@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:00:59 -0300 Subject: [PATCH 10/18] Fixes savestate for Katzu --- src/system/libretro/tic80_libretro.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/system/libretro/tic80_libretro.c b/src/system/libretro/tic80_libretro.c index 202ed7721..59401ec11 100644 --- a/src/system/libretro/tic80_libretro.c +++ b/src/system/libretro/tic80_libretro.c @@ -1261,8 +1261,11 @@ static void serialize_lua(tic_core* core) { " d=d or 0 if d>30 then return nil end " " local t=type(o) " " if t=='number' then " // Important for Bouncelot (preserves float vs int) + " if o~=o then return '(0/0)' end " // Important for Katzu: NaN check + " if o==math.huge then return '(1/0)' end " // Positive infinity + " if o==-math.huge then return '(-1/0)' end " // Negative infinity " local s=string.format('%.17g',o) " // Important for Buried Deep (preserves precision) - " if math.type(o)=='float' and not s:find('[^%-0-9]') then s=s..'.0' end " + " if math.type and math.type(o)=='float' and not s:find('[^%-0-9]') then s=s..'.0' end " // Safety check for math.type " return s " " end " " if t=='boolean' then return tostring(o) end " From 8b624d0dc6c5256ede87902ec634761cfb3a6229 Mon Sep 17 00:00:00 2001 From: imsys <911254+imsys@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:45:43 -0300 Subject: [PATCH 11/18] Some savestate fixes for MadPhysicist --- src/system/libretro/tic80_libretro.c | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/system/libretro/tic80_libretro.c b/src/system/libretro/tic80_libretro.c index 59401ec11..95234b05a 100644 --- a/src/system/libretro/tic80_libretro.c +++ b/src/system/libretro/tic80_libretro.c @@ -1577,6 +1577,14 @@ RETRO_API bool retro_unserialize(const void *data, size_t size) " end\n" " return true\n" "end\n" + "local function has_methods(t)\n" + // Important for MadPhysicist: Check if table has function fields (methods) + " if type(t) ~= 'table' then return false end\n" + " for k,v in pairs(t) do\n" + " if type(v) == 'function' then return true end\n" + " end\n" + " return false\n" + "end\n" "local function update(d, s)\n" " if seen[d] then return end\n" " seen[d] = true\n" @@ -1587,21 +1595,24 @@ RETRO_API bool retro_unserialize(const void *data, size_t size) " if type(d[k]) ~= 'table' then\n" " d[k] = {}\n" " end\n" - " -- Important for Balmung: For arrays, preserve object metatables\n" + // Important for Balmung: For arrays, preserve object metatables " if is_array(v) then\n" - " -- Update array length\n" + // Update array length " for i = #v + 1, #d[k] do d[k][i] = nil end\n" " for i = 1, #v do\n" " if type(v[i]) == 'table' then\n" - " -- Important for Balmung: Only update existing tables to avoid creating objects without methods (closures)\n" + // Important for Balmung: Only update existing tables to avoid creating objects without methods (closures) " if type(d[k][i]) == 'table' then\n" - " -- Preserve metatable of existing object\n" + // Preserve metatable of existing object " local mt = getmetatable(d[k][i])\n" " update(d[k][i], v[i])\n" " if mt then setmetatable(d[k][i], mt) end\n" " end\n" " else\n" - " d[k][i] = v[i]\n" + // Important for MadPhysicist: Don't overwrite objects that have methods with nil/non-table values + " if not has_methods(d[k][i]) then\n" + " d[k][i] = v[i]\n" + " end\n" " end\n" " end\n" " else\n" From cf0904e9af3d244c492c9fe7bde8585bff64f7ab Mon Sep 17 00:00:00 2001 From: imsys <911254+imsys@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:03:49 -0300 Subject: [PATCH 12/18] Fix savestates for games overriding global lua type() function The game Ghost (and potentially others) overrides the global type() function with a custom drawing routine taking different parameters. This caused savestate serialization to fail with "attempt to perform arithmetic on a nil value" when the serialization script tried to use type() for value inspection. Fix this by: Adding internal_lua_type() C helper that exposes native Lua type checking via the Lua C API Injecting __builtin_type into the VM before running serialization scripts and the deserialization merge script Capturing the builtin type function at the start of serialization scripts to ensure type() works correctly Cleaning up __builtin_type from _G after use to avoid polluting the global namespace This ensures savestate serialization/deserialization uses standard Lua type() semantics regardless of game script modifications to the global namespace. --- src/system/libretro/tic80_libretro.c | 49 ++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/src/system/libretro/tic80_libretro.c b/src/system/libretro/tic80_libretro.c index 95234b05a..adc091b05 100644 --- a/src/system/libretro/tic80_libretro.c +++ b/src/system/libretro/tic80_libretro.c @@ -1214,24 +1214,37 @@ static void free_serialized_lua() { SerializedLuaSize = 0; } +static int internal_lua_type(lua_State* L) { + lua_pushstring(L, lua_typename(L, lua_type(L, 1))); + return 1; +} + // Helper to serialize Lua _G table into a string static void serialize_lua(tic_core* core) { free_serialized_lua(); lua_State* lua = core->currentVM; - if (!lua) return; + if (!lua) { + log_cb(RETRO_LOG_INFO, "Error, currentVM is NULL\n"); + return; + } + + // Important for Ghost game (1777). (overwritten type function) + lua_pushcfunction(lua, internal_lua_type); + lua_setglobal(lua, "__builtin_type"); // Lua script to serialize the _G table // Properly handles standard library references by serializing them as special markers // Skips unserializable functions so they can be preserved during merge const char* script = + "local type = __builtin_type " // Important for Ghost (overwritten type function) "local names = {} " "local libs = {'math','table','string','coroutine','package','io','os','utf8','debug'} " "for _,n in ipairs(libs) do if _G[n] then names[_G[n]] = n end end " "names[_G] = '_G' " "local visited = {} " "local function scan(t, path, d) " - " if d>4 or visited[t] then return end " + " if d>8 or visited[t] then return end " " visited[t] = true " " for k,v in pairs(t) do " " if type(k)=='string' and k:match('^[%a_][%w_]*$') then " @@ -1297,7 +1310,8 @@ static void serialize_lua(tic_core* core) { " end " " s = s .. '}' " " if mte then s = s .. ',' .. mte .. ')' end " - " v[o]=nil return s " + " v[o]=nil " + " return s " " end " " return nil " "end " @@ -1314,10 +1328,26 @@ static void serialize_lua(tic_core* core) { SerializedLuaData[len] = '\0'; SerializedLuaSize = len; } + } else { + log_cb(RETRO_LOG_ERROR, "[TIC-80] Lua serialization script returned nil\n"); + } + } else { + const char* error_msg = lua_tostring(lua, -1); + if(!error_msg) { + log_cb(RETRO_LOG_ERROR, "[TIC-80] Lua serialization script probably returned nil\n"); + } else { + log_cb(RETRO_LOG_ERROR, "[TIC-80] Lua serialization error: %s\n", error_msg); } } lua_pop(lua, 1); + } else { + const char* error_msg = lua_tostring(lua, -1); + log_cb(RETRO_LOG_ERROR, "[TIC-80] Lua serialization script compile error: %s\n", error_msg ? error_msg : "unknown error"); + lua_pop(lua, 1); // Remove error message from stack } + + lua_pushnil(lua); + lua_setglobal(lua, "__builtin_type"); } /** @@ -1472,6 +1502,11 @@ RETRO_API bool retro_unserialize(const void *data, size_t size) if (luaSize > 0 && (src - (const u8*)data) + luaSize <= size) { lua_State* lua = core->currentVM; + // Important for Ghost game (1777). (overwritten type function) + // Inject __builtin_type helper for the merge script + lua_pushcfunction(lua, internal_lua_type); + lua_setglobal(lua, "__builtin_type"); + // Push "return " string lua_pushstring(lua, "return "); // Push the serialized Lua table string @@ -1562,9 +1597,11 @@ RETRO_API bool retro_unserialize(const void *data, size_t size) // Second pass: merge the restored table into _G using a smart merge strategy // This preserves existing functions (code) that couldn't be serialized const char* merge_script = - "local S = ...\n" - "local seen = {}\n" - "local STDLIB = {\n" + "local type = __builtin_type\n" // Important for Ghost (overwritten type function) + "_G.__builtin_type = nil\n" + "local S = ...\n" + "local seen = {}\n" + "local STDLIB = {\n" " __STDLIB_MATH__ = math, __STDLIB_TABLE__ = table, __STDLIB_STRING__ = string,\n" " __STDLIB_COROUTINE__ = coroutine, __STDLIB_PACKAGE__ = package, __STDLIB_IO__ = io,\n" " __STDLIB_OS__ = os, __STDLIB_UTF8__ = utf8, __STDLIB_DEBUG__ = debug\n" From 46e265bc11c497096865c30d9feb2bbdb3cec1be Mon Sep 17 00:00:00 2001 From: imsys <911254+imsys@users.noreply.github.com> Date: Fri, 6 Feb 2026 19:29:11 -0300 Subject: [PATCH 13/18] libretro: Cache Lua serialization to prevent redundant calls Optimize retro_serialize_size() by caching the serialized Lua state and reusing it across multiple calls within the same frame. This prevents the expensive serialization script from running 3+ times per save state operation (as retro_serialize_size is typically called multiple times before retro_serialize). * Add lastSerializedTime timestamp to track frame state * Skip serialization if cached data matches current frameTime * Invalidate cache on retro_unserialize to ensure fresh data * Fixes performance issues with save states in Lua-based cartridges. --- src/system/libretro/tic80_libretro.c | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/system/libretro/tic80_libretro.c b/src/system/libretro/tic80_libretro.c index adc091b05..4d77127c0 100644 --- a/src/system/libretro/tic80_libretro.c +++ b/src/system/libretro/tic80_libretro.c @@ -83,6 +83,7 @@ struct tic80_state int mouseHideTimerStart; tic80* tic; retro_usec_t frameTime; + retro_usec_t lastSerializedTick; }; static struct tic80_state* state = NULL; @@ -1221,6 +1222,13 @@ static int internal_lua_type(lua_State* L) { // Helper to serialize Lua _G table into a string static void serialize_lua(tic_core* core) { + // Optimization: If we already serialized for this tick, reuse the cached data. + // This prevents triple-execution of the serialization script when the frontend calls + // retro_serialize_size multiple times before retro_serialize. + if (SerializedLuaData && state && state->lastSerializedTick == state->frameTime) { + return; + } + free_serialized_lua(); lua_State* lua = core->currentVM; @@ -1327,6 +1335,7 @@ static void serialize_lua(tic_core* core) { memcpy(SerializedLuaData, str, len); SerializedLuaData[len] = '\0'; SerializedLuaSize = len; + if (state) state->lastSerializedTick = state->frameTime; } } else { log_cb(RETRO_LOG_ERROR, "[TIC-80] Lua serialization script returned nil\n"); @@ -1461,6 +1470,9 @@ RETRO_API bool retro_serialize(void *data, size_t size) */ RETRO_API bool retro_unserialize(const void *data, size_t size) { + // Invalidate the cache when loading a state + free_serialized_lua(); + if (state == NULL || state->tic == NULL || size < sizeof(tic_ram) + sizeof(tic_core_state_data) || data == NULL) { return false; } From e49b1ce9b3c4c349aca57c69b0eaff475b8a557a Mon Sep 17 00:00:00 2001 From: imsys <911254+imsys@users.noreply.github.com> Date: Fri, 6 Feb 2026 22:21:26 -0300 Subject: [PATCH 14/18] fix(libretro): Support file-local state (upvalues) in Lua savestates Previously, savestates only captured global variables (_G), causing games like Bone Knight to fail to restore progress because they store critical state (e.g., player position, items) in file-local variables (upvalues). Additionally, Bone Knight overwrites the global `debug` table, which previously crashed the serializer. Changes: - **Access debug library safely**: Use `package.loaded.debug` to bypass game overrides of the global `debug` table when scanning upvalues. - **Serialize upvalues**: Traverse global functions to capture their upvalues using `debug.getupvalue`. Track shared upvalues via `debug.upvalueid` to preserve connections between functions (restored via `debug.upvaluejoin`). - **Update unserialization**: Restore upvalues (`setupvalue`) and re-link shared ones (`upvaluejoin`) before updating `_G` to ensure local state is correctly synchronized. - **Remove obsolete code**: Deleted the C-based `__STDLIB__` marker replacement loop in `retro_unserialize`; this is now handled within the Lua merge script. --- src/system/libretro/tic80_libretro.c | 363 +++++++++++++-------------- 1 file changed, 181 insertions(+), 182 deletions(-) diff --git a/src/system/libretro/tic80_libretro.c b/src/system/libretro/tic80_libretro.c index 4d77127c0..cea14a34b 100644 --- a/src/system/libretro/tic80_libretro.c +++ b/src/system/libretro/tic80_libretro.c @@ -1245,7 +1245,8 @@ static void serialize_lua(tic_core* core) { // Properly handles standard library references by serializing them as special markers // Skips unserializable functions so they can be preserved during merge const char* script = - "local type = __builtin_type " // Important for Ghost (overwritten type function) + "local type = __builtin_type " + "local dbg = package.loaded.debug " // Important: use real debug lib "local names = {} " "local libs = {'math','table','string','coroutine','package','io','os','utf8','debug'} " "for _,n in ipairs(libs) do if _G[n] then names[_G[n]] = n end end " @@ -1278,7 +1279,7 @@ static void serialize_lua(tic_core* core) { " for part in n:gmatch('[^.]+') do res = res .. '[' .. string.format('%q', part) .. ']' end " " return res " "end " - "local function ser(o,v,d) " + "local function ser(o,v,d,ref_tables) " // Added ref_tables param " d=d or 0 if d>30 then return nil end " " local t=type(o) " " if t=='number' then " // Important for Bouncelot (preserves float vs int) @@ -1293,6 +1294,7 @@ static void serialize_lua(tic_core* core) { " if t=='string' then return string.format('%q',o) end " " if t=='function' then return getExpr(o) end " " if t=='table' then " + " if ref_tables and names[o] then return getExpr(o) end " // Use references for Upvalues " if o==math then return '\"__STDLIB_MATH__\"' end " " if o==table then return '\"__STDLIB_TABLE__\"' end " " if o==string then return '\"__STDLIB_STRING__\"' end " @@ -1312,7 +1314,7 @@ static void serialize_lua(tic_core* core) { " if (type(k)=='string' or type(k)=='number') and k~='_G' and k~='package' and " " k~='coroutine' and k~='table' and k~='io' and k~='os' and k~='string' and " " k~='math' and k~='utf8' and k~='debug' then " - " local ks=ser(k,v,d+1) local vs=ser(val,v,d+1) " + " local ks=ser(k,v,d+1,ref_tables) local vs=ser(val,v,d+1,ref_tables) " " if ks and vs then s=s..'['..ks..']='..vs..',' end " " end " " end " @@ -1323,7 +1325,47 @@ static void serialize_lua(tic_core* core) { " end " " return nil " "end " - "return ser(_G,{})"; + + // Important for Bone Knight: Scan upvalues to capture file-local state variables + // We use debug.upvalueid to detect shared locals (like 'player' used in multiple functions) + "local uvs={} " + "local joins={} " + "local u_ids={} " + "local q={} " + "local u_seen={} " + "if dbg then " + " for k,v in pairs(_G) do if type(v)=='function' then table.insert(q,{v,{k}}); u_seen[v]=true end end " + " local ptr=1 " + " while ptr<=#q do " + " local item=q[ptr]; ptr=ptr+1 " + " local f=item[1]; local p=item[2] " + " local i=1 " + " while true do " + " local n,v=dbg.getupvalue(f,i) " + " if not n then break end " + " if n~='_ENV' then " + " local np={table.unpack(p)}; table.insert(np,i) " + " local id=dbg.upvalueid(f,i) " + " if u_ids[id] then " + " table.insert(joins,{src=u_ids[id],dest=np}) " + " else " + " u_ids[id]=np " + " if type(v)=='function' then " + " if not u_seen[v] then u_seen[v]=true; table.insert(q,{v,np}) end " + " elseif type(v)=='table' or type(v)=='number' or type(v)=='string' or type(v)=='boolean' then " + " table.insert(uvs,{p=np,v=v}) " + " end " + " end " + " end " + " i=i+1 " + " end " + " end " + "end " + + "local g_str=ser(_G,{},0,false) " // No refs for G (serialize by value) + "local u_str=ser(uvs,{},0,true) " // Refs for U (point to G tables if possible) + "local j_str=ser(joins,{},0,true) " + "return '{g='..(g_str or '{}')..',u='..(u_str or 'nil')..',j='..(j_str or 'nil')..'}' "; if (luaL_dostring(lua, script) == LUA_OK) { if (lua_isstring(lua, -1)) { @@ -1519,184 +1561,141 @@ RETRO_API bool retro_unserialize(const void *data, size_t size) lua_pushcfunction(lua, internal_lua_type); lua_setglobal(lua, "__builtin_type"); - // Push "return " string - lua_pushstring(lua, "return "); - // Push the serialized Lua table string - lua_pushlstring(lua, (const char*)src, luaSize); - // Concatenate them: "return { ... }" - lua_concat(lua, 2); - - // Load the concatenated string as a Lua chunk - if (luaL_loadstring(lua, lua_tostring(lua, -1)) == LUA_OK) { - // Execute the chunk, which should return the serialized table - if (lua_pcall(lua, 0, 1, 0) == LUA_OK) { - // Check if the result is a table - if (lua_istable(lua, -1)) { - // First pass: restore standard library references - // This converts marker strings like "__STDLIB_MATH__" to actual library references - // We iterate through the table and ONLY replace entries that have stdlib markers - lua_pushnil(lua); - while (lua_next(lua, -2) != 0) { - // key at -2, value at -1 - int replaced = 0; - if (lua_isstring(lua, -1)) { - const char* str = lua_tostring(lua, -1); - if (strcmp(str, "__STDLIB_MATH__") == 0) { - lua_pop(lua, 1); // pop the string marker - lua_pushvalue(lua, -1); // copy key (at -1 now after pop) - lua_getglobal(lua, "math"); - lua_settable(lua, -4); // table is at -4: table, key, key_copy, math - replaced = 1; - } else if (strcmp(str, "__STDLIB_TABLE__") == 0) { - lua_pop(lua, 1); - lua_pushvalue(lua, -1); - lua_getglobal(lua, "table"); - lua_settable(lua, -4); - replaced = 1; - } else if (strcmp(str, "__STDLIB_STRING__") == 0) { - lua_pop(lua, 1); - lua_pushvalue(lua, -1); - lua_getglobal(lua, "string"); - lua_settable(lua, -4); - replaced = 1; - } else if (strcmp(str, "__STDLIB_COROUTINE__") == 0) { - lua_pop(lua, 1); - lua_pushvalue(lua, -1); - lua_getglobal(lua, "coroutine"); - lua_settable(lua, -4); - replaced = 1; - } else if (strcmp(str, "__STDLIB_PACKAGE__") == 0) { - lua_pop(lua, 1); - lua_pushvalue(lua, -1); - lua_getglobal(lua, "package"); - lua_settable(lua, -4); - replaced = 1; - } else if (strcmp(str, "__STDLIB_IO__") == 0) { - lua_pop(lua, 1); - lua_pushvalue(lua, -1); - lua_getglobal(lua, "io"); - lua_settable(lua, -4); - replaced = 1; - } else if (strcmp(str, "__STDLIB_OS__") == 0) { - lua_pop(lua, 1); - lua_pushvalue(lua, -1); - lua_getglobal(lua, "os"); - lua_settable(lua, -4); - replaced = 1; - } else if (strcmp(str, "__STDLIB_UTF8__") == 0) { - lua_pop(lua, 1); - lua_pushvalue(lua, -1); - lua_getglobal(lua, "utf8"); - lua_settable(lua, -4); - replaced = 1; - } else if (strcmp(str, "__STDLIB_DEBUG__") == 0) { - lua_pop(lua, 1); - lua_pushvalue(lua, -1); - lua_getglobal(lua, "debug"); - lua_settable(lua, -4); - replaced = 1; - } - } - // If we didn't replace a stdlib marker, just pop the value - // to leave the key for lua_next to continue iteration - if (!replaced) { - lua_pop(lua, 1); // pop value, leave key for next iteration - } - // If we did replace, the settable already consumed key_copy and value, - // but original key is still at -1 for lua_next - } - - // Second pass: merge the restored table into _G using a smart merge strategy - // This preserves existing functions (code) that couldn't be serialized - const char* merge_script = - "local type = __builtin_type\n" // Important for Ghost (overwritten type function) - "_G.__builtin_type = nil\n" - "local S = ...\n" - "local seen = {}\n" - "local STDLIB = {\n" - " __STDLIB_MATH__ = math, __STDLIB_TABLE__ = table, __STDLIB_STRING__ = string,\n" - " __STDLIB_COROUTINE__ = coroutine, __STDLIB_PACKAGE__ = package, __STDLIB_IO__ = io,\n" - " __STDLIB_OS__ = os, __STDLIB_UTF8__ = utf8, __STDLIB_DEBUG__ = debug\n" - "}\n" - "local function is_array(t)\n" - " local i = 0\n" - " for _ in pairs(t) do\n" - " i = i + 1\n" - " if t[i] == nil then return false end\n" - " end\n" - " return true\n" - "end\n" - "local function has_methods(t)\n" - // Important for MadPhysicist: Check if table has function fields (methods) - " if type(t) ~= 'table' then return false end\n" - " for k,v in pairs(t) do\n" - " if type(v) == 'function' then return true end\n" - " end\n" - " return false\n" - "end\n" - "local function update(d, s)\n" - " if seen[d] then return end\n" - " seen[d] = true\n" - " for k,v in pairs(s) do\n" - " if type(v) == 'string' and STDLIB[v] then\n" - " d[k] = STDLIB[v]\n" - " elseif type(v) == 'table' then\n" - " if type(d[k]) ~= 'table' then\n" - " d[k] = {}\n" - " end\n" - // Important for Balmung: For arrays, preserve object metatables - " if is_array(v) then\n" - // Update array length - " for i = #v + 1, #d[k] do d[k][i] = nil end\n" - " for i = 1, #v do\n" - " if type(v[i]) == 'table' then\n" - // Important for Balmung: Only update existing tables to avoid creating objects without methods (closures) - " if type(d[k][i]) == 'table' then\n" - // Preserve metatable of existing object - " local mt = getmetatable(d[k][i])\n" - " update(d[k][i], v[i])\n" - " if mt then setmetatable(d[k][i], mt) end\n" - " end\n" - " else\n" - // Important for MadPhysicist: Don't overwrite objects that have methods with nil/non-table values - " if not has_methods(d[k][i]) then\n" - " d[k][i] = v[i]\n" - " end\n" - " end\n" - " end\n" - " else\n" - " local mt_s = getmetatable(v)\n" - " if mt_s then setmetatable(d[k], mt_s) end\n" - " update(d[k], v)\n" - " end\n" - " else\n" - " d[k] = v\n" - " end\n" - " end\n" - "end\n" - "update(_G, S)\n"; - - if (luaL_loadstring(lua, merge_script) == LUA_OK) { - lua_pushvalue(lua, -2); // Push the table S (currently at -2) - if (lua_pcall(lua, 1, 0, 0) != LUA_OK) { - log_cb(RETRO_LOG_ERROR, "[TIC-80] Lua merge script error: %s\n", lua_tostring(lua, -1)); - lua_pop(lua, 1); // pop error - } - } else { - log_cb(RETRO_LOG_ERROR, "[TIC-80] Lua merge compile error: %s\n", lua_tostring(lua, -1)); - lua_pop(lua, 1); // pop error - } - } - } else { - log_cb(RETRO_LOG_ERROR, "[TIC-80] Lua state unserialize error: %s\n", lua_tostring(lua, -1)); - } - } else { - log_cb(RETRO_LOG_ERROR, "[TIC-80] Lua state loadstring error: %s\n", lua_tostring(lua, -1)); - } - lua_pop(lua, 1); // Pop the result of the chunk (table S or error message) - lua_pop(lua, 1); // Pop the concatenated "return { ... }" string - - src += luaSize; // Advance source pointer past Lua data + // Push "return " string + lua_pushstring(lua, "return "); + // Push the serialized Lua table string + lua_pushlstring(lua, (const char*)src, luaSize); + // Concatenate them: "return { ... }" + lua_concat(lua, 2); + + // Load the concatenated string as a Lua chunk + if (luaL_loadstring(lua, lua_tostring(lua, -1)) == LUA_OK) { + // Execute the chunk, which should return the serialized table + if (lua_pcall(lua, 0, 1, 0) == LUA_OK) { + // S is at -1 + + const char* merge_script = + "local type = __builtin_type\n" // Important for Ghost (overwritten type function) + "_G.__builtin_type = nil\n" + "local dbg = package.loaded.debug\n" // Important for Bone Knight (access hidden debug lib) + "local S = ...\n" + "local g = S\n" + "local u = nil\n" + "local j = nil\n" + "if type(S) == 'table' and S.g then\n" + " g = S.g\n" + " u = S.u\n" + " j = S.j\n" + "end\n" + // Helper to resolve upvalue path + "local function resolve(path)\n" + " local f = _G[path[1]]\n" + " if not f then return nil end\n" + " for i = 2, #path - 1 do\n" + " local n, v = dbg.getupvalue(f, path[i])\n" + " if type(v) == 'function' then f = v else return nil end\n" + " end\n" + " return f, path[#path]\n" + "end\n" + // Important for Bone Knight: Restore upvalues (local variables) + "if u and dbg then\n" + " for _,x in ipairs(u) do\n" + " local f, i = resolve(x.p)\n" + " if f then dbg.setupvalue(f, i, x.v) end\n" + " end\n" + "end\n" + // Restore shared upvalues links + "if j and dbg then\n" + " for _,x in ipairs(j) do\n" + " local f1, i1 = resolve(x.dest)\n" + " local f2, i2 = resolve(x.src)\n" + " if f1 and f2 then dbg.upvaluejoin(f1, i1, f2, i2) end\n" + " end\n" + "end\n" + "local seen = {}\n" + "local STDLIB = {\n" + " __STDLIB_MATH__ = math, __STDLIB_TABLE__ = table, __STDLIB_STRING__ = string,\n" + " __STDLIB_COROUTINE__ = coroutine, __STDLIB_PACKAGE__ = package, __STDLIB_IO__ = io,\n" + " __STDLIB_OS__ = os, __STDLIB_UTF8__ = utf8, __STDLIB_DEBUG__ = debug\n" + "}\n" + "local function is_array(t)\n" + " local i = 0\n" + " for _ in pairs(t) do\n" + " i = i + 1\n" + " if t[i] == nil then return false end\n" + " end\n" + " return true\n" + "end\n" + "local function has_methods(t)\n" + // Important for MadPhysicist: Check if table has function fields (methods) + " if type(t) ~= 'table' then return false end\n" + " for k,v in pairs(t) do\n" + " if type(v) == 'function' then return true end\n" + " end\n" + " return false\n" + "end\n" + "local function update(d, s)\n" + " if seen[d] then return end\n" + " seen[d] = true\n" + " for k,v in pairs(s) do\n" + " if type(v) == 'string' and STDLIB[v] then\n" + " d[k] = STDLIB[v]\n" + " elseif type(v) == 'table' then\n" + " if type(d[k]) ~= 'table' then\n" + " d[k] = {}\n" + " end\n" + // Important for Balmung: For arrays, preserve object metatables + " if is_array(v) then\n" + // Update array length + " for i = #v + 1, #d[k] do d[k][i] = nil end\n" + " for i = 1, #v do\n" + " if type(v[i]) == 'table' then\n" + // Important for Balmung: Only update existing tables to avoid creating objects without methods (closures) + " if type(d[k][i]) == 'table' then\n" + // Preserve metatable of existing object + " local mt = getmetatable(d[k][i])\n" + " update(d[k][i], v[i])\n" + " if mt then setmetatable(d[k][i], mt) end\n" + " end\n" + " else\n" + // Important for MadPhysicist: Don't overwrite objects that have methods with nil/non-table values + " if not has_methods(d[k][i]) then\n" + " d[k][i] = v[i]\n" + " end\n" + " end\n" + " end\n" + " else\n" + " local mt_s = getmetatable(v)\n" + " if mt_s then setmetatable(d[k], mt_s) end\n" + " update(d[k], v)\n" + " end\n" + " else\n" + " d[k] = v\n" + " end\n" + " end\n" + "end\n" + "update(_G, g)\n"; + + if (luaL_loadstring(lua, merge_script) == LUA_OK) { + lua_pushvalue(lua, -2); // Push the table S (currently at -2) + if (lua_pcall(lua, 1, 0, 0) != LUA_OK) { + log_cb(RETRO_LOG_ERROR, "[TIC-80] Lua merge script error: %s\n", lua_tostring(lua, -1)); + lua_pop(lua, 1); // pop error + } + } else { + log_cb(RETRO_LOG_ERROR, "[TIC-80] Lua merge compile error: %s\n", lua_tostring(lua, -1)); + lua_pop(lua, 1); // pop error + } + } else { + log_cb(RETRO_LOG_ERROR, "[TIC-80] Lua state unserialize error: %s\n", lua_tostring(lua, -1)); + } + } else { + log_cb(RETRO_LOG_ERROR, "[TIC-80] Lua state loadstring error: %s\n", lua_tostring(lua, -1)); + } + lua_pop(lua, 1); // Pop the result of the chunk (table S or error message) + lua_pop(lua, 1); // Pop the concatenated "return { ... }" string + + src += luaSize; // Advance source pointer past Lua data } } } From e33e97d4cfa2b0f07025cbb5ad3246452b181c27 Mon Sep 17 00:00:00 2001 From: imsys <911254+imsys@users.noreply.github.com> Date: Sun, 8 Feb 2026 09:52:37 -0300 Subject: [PATCH 15/18] libretro: fix Lua savestates when games overwrite 'pairs' Some TIC-80 games (e.g., Oddsocks) overwrite the global 'pairs' function as a variable (e.g., 'pairs=0'). This caused the Lua serialization and deserialization scripts to fail silently, resulting in state desynchronization where Lua variables were not restored correctly. Fix by capturing 'next' and 'pairs' locally at the start of both the serialize (serialize_lua) and merge (retro_unserialize) scripts, with a fallback reconstruction using 'next' if the global 'pairs' has been corrupted (is not a function). --- src/system/libretro/tic80_libretro.c | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/system/libretro/tic80_libretro.c b/src/system/libretro/tic80_libretro.c index cea14a34b..eaa167c7d 100644 --- a/src/system/libretro/tic80_libretro.c +++ b/src/system/libretro/tic80_libretro.c @@ -1246,6 +1246,9 @@ static void serialize_lua(tic_core* core) { // Skips unserializable functions so they can be preserved during merge const char* script = "local type = __builtin_type " + "local next = next " + "local pairs = pairs " + "if type(pairs)~='function' then pairs = function(x) return next,x,nil end end " // Important for OddSocks (pairs is overwritten) "local dbg = package.loaded.debug " // Important: use real debug lib "local names = {} " "local libs = {'math','table','string','coroutine','package','io','os','utf8','debug'} " @@ -1577,6 +1580,9 @@ RETRO_API bool retro_unserialize(const void *data, size_t size) const char* merge_script = "local type = __builtin_type\n" // Important for Ghost (overwritten type function) "_G.__builtin_type = nil\n" + "local next = next\n" + "local pairs = pairs\n" + "if type(pairs) ~= 'function' then pairs = function(t) return next, t, nil end end\n" // Important for OddSocks (pairs is overwritten) "local dbg = package.loaded.debug\n" // Important for Bone Knight (access hidden debug lib) "local S = ...\n" "local g = S\n" From c0503d3a1dd727799e5d159d3eeb357a0fd2e8a7 Mon Sep 17 00:00:00 2001 From: imsys <911254+imsys@users.noreply.github.com> Date: Sun, 8 Feb 2026 10:23:51 -0300 Subject: [PATCH 16/18] libretro: preserve methods on local tables during savestate load Fixes "attempt to call a nil value (method 'draw')" crash in "Searching for Pixel" and potentially other games using local tables as objects with attached methods. When loading a savestate, the upvalue restoration logic was replacing entire local tables with their serialized versions. Since methods defined with `:function()` syntax are not serializable, this caused local table objects like `bos:`, `p:`, and `gun:` to lose their methods after load. Now, when restoring upvalues that are tables, we use the existing `update()` function to merge the saved data into the live table object instead of replacing it. This preserves methods and metatables attached to the object while still updating all data fields from the save state. Fix applies to the Lua merge script in retro_unserialize. --- src/system/libretro/tic80_libretro.c | 38 +++++++++++++++++----------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/src/system/libretro/tic80_libretro.c b/src/system/libretro/tic80_libretro.c index eaa167c7d..75cdfe699 100644 --- a/src/system/libretro/tic80_libretro.c +++ b/src/system/libretro/tic80_libretro.c @@ -1603,21 +1603,6 @@ RETRO_API bool retro_unserialize(const void *data, size_t size) " end\n" " return f, path[#path]\n" "end\n" - // Important for Bone Knight: Restore upvalues (local variables) - "if u and dbg then\n" - " for _,x in ipairs(u) do\n" - " local f, i = resolve(x.p)\n" - " if f then dbg.setupvalue(f, i, x.v) end\n" - " end\n" - "end\n" - // Restore shared upvalues links - "if j and dbg then\n" - " for _,x in ipairs(j) do\n" - " local f1, i1 = resolve(x.dest)\n" - " local f2, i2 = resolve(x.src)\n" - " if f1 and f2 then dbg.upvaluejoin(f1, i1, f2, i2) end\n" - " end\n" - "end\n" "local seen = {}\n" "local STDLIB = {\n" " __STDLIB_MATH__ = math, __STDLIB_TABLE__ = table, __STDLIB_STRING__ = string,\n" @@ -1680,6 +1665,29 @@ RETRO_API bool retro_unserialize(const void *data, size_t size) " end\n" " end\n" "end\n" + // Important for Bone Knight: Restore upvalues (local variables) + "if u and dbg then\n" + " for _,x in ipairs(u) do\n" + " local f, i = resolve(x.p)\n" + " if f then\n" + // Important for Searching for Pixel: Preserve methods on local table objects by updating instead of replacing + " local n, curr = dbg.getupvalue(f, i)\n" + " if type(curr) == 'table' and type(x.v) == 'table' then\n" + " update(curr, x.v)\n" + " else\n" + " dbg.setupvalue(f, i, x.v)\n" + " end\n" + " end\n" + " end\n" + "end\n" + // Restore shared upvalues links + "if j and dbg then\n" + " for _,x in ipairs(j) do\n" + " local f1, i1 = resolve(x.dest)\n" + " local f2, i2 = resolve(x.src)\n" + " if f1 and f2 then dbg.upvaluejoin(f1, i1, f2, i2) end\n" + " end\n" + "end\n" "update(_G, g)\n"; if (luaL_loadstring(lua, merge_script) == LUA_OK) { From 66e11a119229a2f6d2ece169104bb75999c8707b Mon Sep 17 00:00:00 2001 From: imsys <911254+imsys@users.noreply.github.com> Date: Sun, 8 Feb 2026 21:55:46 -0300 Subject: [PATCH 17/18] libretro: Fix Lua savestate regression for OOP games and shared locals Fixes a critical regression where loading savestates would either corrupt object methods (by replacing live tables) or leave stale entities in memory (by not cleaning up removed keys). Implement a robust two-pass merge strategy in retro_unserialize(): 1. Merge Phase: Recursively apply saved values to live tables while preserving method references and metatables (critical for games using OOP patterns like Searching for Pixel). 2. Cleanup Phase: Remove keys present in live state but absent from savestate, while explicitly protecting functions and standard libraries (math, table, string, etc.) to prevent VM corruption. Key fixes: - Preserve metatables when reconstructing tables during merge - Use debug.upvaluejoin to restore shared local variable links (required for Bone Knight's cross-closure state sharing) - Protect standard library globals from deletion during cleanup - Skip serializing stdlib references as markers, resolving them during deserialization Tested with: - Bone Knight (shared locals/upvalue handling) - Searching for Pixel (OOP object method preservation) - Robot Pilfer (dead entity/flag cleanup) --- src/system/libretro/tic80_libretro.c | 86 ++++++++++++++++------------ 1 file changed, 49 insertions(+), 37 deletions(-) diff --git a/src/system/libretro/tic80_libretro.c b/src/system/libretro/tic80_libretro.c index 75cdfe699..918578567 100644 --- a/src/system/libretro/tic80_libretro.c +++ b/src/system/libretro/tic80_libretro.c @@ -1609,14 +1609,6 @@ RETRO_API bool retro_unserialize(const void *data, size_t size) " __STDLIB_COROUTINE__ = coroutine, __STDLIB_PACKAGE__ = package, __STDLIB_IO__ = io,\n" " __STDLIB_OS__ = os, __STDLIB_UTF8__ = utf8, __STDLIB_DEBUG__ = debug\n" "}\n" - "local function is_array(t)\n" - " local i = 0\n" - " for _ in pairs(t) do\n" - " i = i + 1\n" - " if t[i] == nil then return false end\n" - " end\n" - " return true\n" - "end\n" "local function has_methods(t)\n" // Important for MadPhysicist: Check if table has function fields (methods) " if type(t) ~= 'table' then return false end\n" @@ -1625,53 +1617,74 @@ RETRO_API bool retro_unserialize(const void *data, size_t size) " end\n" " return false\n" "end\n" + "local function is_array(t)\n" + " if type(t) ~= 'table' then return false end\n" + " local i = 0 for _ in pairs(t) do i = i + 1 end\n" + " return #t == i\n" + "end\n" "local function update(d, s)\n" " if seen[d] then return end\n" " seen[d] = true\n" + // Pass 1: Copy/Merge from Save(s) to Dest(d) " for k,v in pairs(s) do\n" " if type(v) == 'string' and STDLIB[v] then\n" " d[k] = STDLIB[v]\n" + " elseif is_array(v) then\n" + " if type(d[k]) ~= 'table' then d[k] = {} end\n" + " for i = #v + 1, #d[k] do d[k][i] = nil end\n" + " for i = 1, #v do\n" + " if type(v[i]) == 'table' then\n" + // Important for Balmung: Only update existing tables to avoid creating objects without methods (closures) + " if type(d[k][i]) == 'table' then\n" + " local mt = getmetatable(d[k][i])\n" + " update(d[k][i], v[i])\n" + " if mt then setmetatable(d[k][i], mt) end\n" + " end\n" + " else\n" + // Important for MadPhysicist: Don't overwrite objects that have methods with nil/non-table values + " if not has_methods(d[k][i]) then\n" + " d[k][i] = v[i]\n" + " end\n" + " end\n" + " end\n" " elseif type(v) == 'table' then\n" " if type(d[k]) ~= 'table' then\n" " d[k] = {}\n" + // Restore metatable from save if present + " local mt = getmetatable(v)\n" + " if mt then setmetatable(d[k], mt) end\n" " end\n" - // Important for Balmung: For arrays, preserve object metatables - " if is_array(v) then\n" - // Update array length - " for i = #v + 1, #d[k] do d[k][i] = nil end\n" - " for i = 1, #v do\n" - " if type(v[i]) == 'table' then\n" - // Important for Balmung: Only update existing tables to avoid creating objects without methods (closures) - " if type(d[k][i]) == 'table' then\n" - // Preserve metatable of existing object - " local mt = getmetatable(d[k][i])\n" - " update(d[k][i], v[i])\n" - " if mt then setmetatable(d[k][i], mt) end\n" - " end\n" - " else\n" - // Important for MadPhysicist: Don't overwrite objects that have methods with nil/non-table values - " if not has_methods(d[k][i]) then\n" - " d[k][i] = v[i]\n" - " end\n" - " end\n" - " end\n" - " else\n" - " local mt_s = getmetatable(v)\n" - " if mt_s then setmetatable(d[k], mt_s) end\n" - " update(d[k], v)\n" - " end\n" + // Recurse to preserve methods in d[k] + " update(d[k], v)\n" " else\n" - " d[k] = v\n" + // Primitive value: overwrite unless d[k] is an object with methods + " if not has_methods(d[k]) then\n" + " d[k] = v\n" + " end\n" + " end\n" + " end\n" + // Pass 2: Cleanup Dest(d) - Remove keys not in Save(s) + // Important for Robot Pilfer: Removes dead entities/flags + " for k,v in pairs(d) do\n" + " if s[k] == nil then\n" + // Important for Searching for Pixel: Keep methods and system globals + // Protect standard libraries (table, math, etc.) to prevent 'attempt to index nil' on next save + " if type(v) ~= 'function' and k ~= '_G' and k ~= 'package' and\n" + " k ~= 'table' and k ~= 'math' and k ~= 'string' and k ~= 'coroutine' and\n" + " k ~= 'io' and k ~= 'os' and k ~= 'utf8' and k ~= 'debug' then\n" + " d[k] = nil\n" + " end\n" " end\n" " end\n" "end\n" + "update(_G, g)\n" // Important for Bone Knight: Restore upvalues (local variables) "if u and dbg then\n" " for _,x in ipairs(u) do\n" " local f, i = resolve(x.p)\n" " if f then\n" - // Important for Searching for Pixel: Preserve methods on local table objects by updating instead of replacing " local n, curr = dbg.getupvalue(f, i)\n" + // Use update() for tables to preserve methods on locals (Searching for Pixel) " if type(curr) == 'table' and type(x.v) == 'table' then\n" " update(curr, x.v)\n" " else\n" @@ -1687,8 +1700,7 @@ RETRO_API bool retro_unserialize(const void *data, size_t size) " local f2, i2 = resolve(x.src)\n" " if f1 and f2 then dbg.upvaluejoin(f1, i1, f2, i2) end\n" " end\n" - "end\n" - "update(_G, g)\n"; + "end\n"; if (luaL_loadstring(lua, merge_script) == LUA_OK) { lua_pushvalue(lua, -2); // Push the table S (currently at -2) From 26f28d42b5ebb63749800caca88fdd11787062e0 Mon Sep 17 00:00:00 2001 From: imsys <911254+imsys@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:17:10 -0300 Subject: [PATCH 18/18] libretro - savestates - improve 8-Bit Panda compatibility Current state of savestate: 8bit panda - ok Bouncelot - ok Buried Deep - ok Color Critters - ok Drill goes brrrrr - ok Katzu - ok Searching for pixel - ok Robot Pilfer - ok Gorgoion - ok Last in Space - ok Bone Knight - ok Antvania - ok Somewhat working: Einar - partial 9Butterf - partial Fetch Quest - partial (bug when loading from titlescreen) Beyond The Underground - buggy Balmung - very buggy MadPhysicist - buggy / crash Tanuki Star - crash --- src/system/libretro/tic80_libretro.c | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/system/libretro/tic80_libretro.c b/src/system/libretro/tic80_libretro.c index 918578567..f9e4bfd1f 100644 --- a/src/system/libretro/tic80_libretro.c +++ b/src/system/libretro/tic80_libretro.c @@ -1634,11 +1634,12 @@ RETRO_API bool retro_unserialize(const void *data, size_t size) " for i = #v + 1, #d[k] do d[k][i] = nil end\n" " for i = 1, #v do\n" " if type(v[i]) == 'table' then\n" - // Important for Balmung: Only update existing tables to avoid creating objects without methods (closures) - " if type(d[k][i]) == 'table' then\n" - " local mt = getmetatable(d[k][i])\n" + // Important for 8bitpanda: Replace table if entity ID (eid) differs or target is not a table + // This prevents merging incompatible entity data and fixes the "nil comparison" crash + " if type(d[k][i]) == 'table' and d[k][i].eid == v[i].eid then\n" " update(d[k][i], v[i])\n" - " if mt then setmetatable(d[k][i], mt) end\n" + " else\n" + " d[k][i] = v[i]\n" " end\n" " else\n" // Important for MadPhysicist: Don't overwrite objects that have methods with nil/non-table values @@ -1648,14 +1649,12 @@ RETRO_API bool retro_unserialize(const void *data, size_t size) " end\n" " end\n" " elseif type(v) == 'table' then\n" - " if type(d[k]) ~= 'table' then\n" - " d[k] = {}\n" - // Restore metatable from save if present - " local mt = getmetatable(v)\n" - " if mt then setmetatable(d[k], mt) end\n" + // Important for 8bitpanda: Use identity check for global tables too + " if type(d[k]) == 'table' and d[k].eid == v.eid then\n" + " update(d[k], v)\n" + " else\n" + " d[k] = v\n" " end\n" - // Recurse to preserve methods in d[k] - " update(d[k], v)\n" " else\n" // Primitive value: overwrite unless d[k] is an object with methods " if not has_methods(d[k]) then\n"