Skip to content

Conversation

@imsys
Copy link
Contributor

@imsys imsys commented Feb 6, 2026

This is for Lua cartridges only.

When trying to save on non-Lua, the user gets a message:
"Savestate currently not supported in non-Lua TIC-80 cartridges"


So, I basically was playing Multiple Maze, the game is a great puzzle, but there is no save implemented. I was getting quite far, then I got soft-locked and the only option was starting over.

So then I thought, let's try to get an AI to implement a savestate, and it did it successfully.

So I then started to check game by game to improve the savestate functionality.

It is working quite nice on most simple games, but there are some still with some inconsistent behavior during loading saves, I will keep working on this on the next few days. I'm already sharing this in case someone wants to give some input/opinion.

Although I do not think it will be possible to save the state of EMUUROM, as that has basically all the memory space for the Lua code already filled. I'm leaving EMUUROM to test later in the hope that fixing smaller games could make more complex games works. But If I still can't get that to work, I could just add an If EMUUROM or maybe If code memory almost full give some error message saying unsupported, because it would be better than the current long freeze that I get when trying to save it.

imsys added 11 commits February 3, 2026 23:39
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.
Fixes: 9BUTTERF and Bouncelot
Working nice:
Beyond The Underground
Bouncelot
Buried Deep
Color Critters
Drill goes brrrrr

Somewhat working:
Balmung
9Butterf
Einar
@imsys
Copy link
Contributor Author

imsys commented Feb 6, 2026

@RobLoach

imsys added 5 commits February 6, 2026 16:03
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.
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.
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.
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).
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.
@RobLoach
Copy link
Contributor

RobLoach commented Feb 8, 2026

Thanks for cleaning this up. For reasons beyond my knowing, I seem to be getting a SEGFAULT whenever running the libretro core either from master, or savestate.

Do you know if using the CMAKE to build the libretro core is still the right approach? Thanks.

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)
@imsys
Copy link
Contributor Author

imsys commented Feb 9, 2026

@RobLoach
I'm using this:
https://gist.github.com/imsys/dce37ff9fe34ade03addc106ba1e0e33
I think I initially copied from you and made some modifications.

I mainly compile for the 3 devices listed there. I'm on Linux.

By the way, I think you know that retro handhelds are getting quite popular, so I made a collection manager (with free games from tic80com and itch). I want to make TIC-80 more easily playable on those devices, but first we will have to fix the upstream libretro/TIC-80 that seems to be what all devices use as reference.

If I'm not mistaken, previously when you were trying to compile, it was failing in another system, I don't remember if it was TIC-80 core on Gamecube, DS, or some other less common system. Let's see if that happens again. Maybe we could get an AI to help, or I could request an account in libretro's gitlab to test getting the build working.

And this savestate branch is still quite experimental, we would need to publish master first.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants