Skip to content

libretro: don't treat optional environment calls as fatal - #2980

Open
wescopeland wants to merge 1 commit into
nesbox:mainfrom
wescopeland:libretro-optional-env-calls
Open

libretro: don't treat optional environment calls as fatal#2980
wescopeland wants to merge 1 commit into
nesbox:mainfrom
wescopeland:libretro-optional-env-calls

Conversation

@wescopeland

@wescopeland wescopeland commented Aug 9, 2026

Copy link
Copy Markdown

I am a RetroAchievements administrator, and I am interested in seeing if achievement development for TIC-80 is viable.

Currently, the TIC-80 libretro core does not load in minimal frontends. This is a major blocking issue, as this is currently blocking the use of the RetroAchievements toolkit (RALibretro), which needs the core to load and to expose memory so that one can make achievements for TIC-80 carts.

RetroArch hides both problems below, because it implements the optional calls that the core assumes are always available.

What is wrong

1. An optional environment call is fatal.

retro_load_game() returns false when the frontend does not implement RETRO_ENVIRONMENT_SET_FRAME_TIME_CALLBACK. That call is optional. A frontend returns false to say it does not implement it, and the core must continue. The check is also above tic80_create(), so retro_get_memory_data() then gives no memory. This looks like a memory problem, but it is actually a load problem.

2. The core frees itself inside retro_run().

When a cart calls exit(), the core calls retro_deinit(). This frees the buffer that the frontend received earlier from retro_get_memory_data(). Frontends keep that pointer and keep reading through it. This is a use-after-free. The frontend now reads memory that has been returned to the allocator and may already hold something else. It does not crash cleanly, which is what makes it hard to notice.

RETRO_ENVIRONMENT_SHUTDOWN is only a request. A frontend that does not implement it keeps calling retro_run(), so this block runs again on every frame.

The fix

This PR makes two remediations:

  1. We record the result of the frame time call and continue. When the frontend does not drive the clock, the core advances it with a fixed timestep. Both paths use the same accumulator, so there is one clock.
  2. We ask the frontend to shut down one time, then hold the last frame. Teardown stays with retro_unload_game() and retro_deinit(), where the libretro API puts it.

Frontends that implement both calls see no change in behavior.

Testing

These changes were tested with a minimal libretro host that returns false for every environment call except SET_PIXEL_FORMAT, and with a build that accepts them.

I also tested this with ~20 carts and saw no regressions.

before after
retro_load_game(), env 21 unsupported false true
SYSTEM_RAM size / data 0 / NULL 0x18000 / valid
time() after 180 frames, env 21 unsupported 0 ms 2999 ms
time() after 180 frames, env 21 supported 2999 ms 2999 ms

2999ms is 180 frames x 16666µs, so the fallback clock is confirmed to be running at the correct rate.

Before
Screenshot 2026-08-09 at 2 38 51 PM

After
Screenshot 2026-08-09 at 2 39 39 PM

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.

1 participant