diff --git a/makefile.toolchain b/makefile.toolchain index f2d5a2ba1..eb4f83142 100644 --- a/makefile.toolchain +++ b/makefile.toolchain @@ -13,6 +13,7 @@ GIT_IF_NECESSARY=toolchains/$(PLATFORM)-toolchain INIT_IF_NECESSARY=toolchains/$(PLATFORM)-toolchain/.build IMAGE_NAME=ghcr.io/loveretro/$(PLATFORM)-toolchain:modernize + all: $(INIT_IF_NECESSARY) docker run -it --rm -v $(HOST_WORKSPACE):$(GUEST_WORKSPACE) $(IMAGE_NAME) /bin/bash @@ -21,7 +22,7 @@ $(INIT_IF_NECESSARY): $(GIT_IF_NECESSARY) $(GIT_IF_NECESSARY): mkdir -p toolchains - git clone -b modernize https://github.com/LoveRetro/$(PLATFORM)-toolchain/ toolchains/$(PLATFORM)-toolchain + git clone https://github.com/LoveRetro/$(PLATFORM)-toolchain/ toolchains/$(PLATFORM)-toolchain docker pull $(IMAGE_NAME) && touch toolchains/$(PLATFORM)-toolchain/.build clean: diff --git a/skeleton/SYSTEM/tg5040/paks/Emus/PS.pak/default-brick.cfg b/skeleton/SYSTEM/tg5040/paks/Emus/PS.pak/default-brick.cfg index aecdba77e..bacd9fcd3 100755 --- a/skeleton/SYSTEM/tg5040/paks/Emus/PS.pak/default-brick.cfg +++ b/skeleton/SYSTEM/tg5040/paks/Emus/PS.pak/default-brick.cfg @@ -2,6 +2,10 @@ -pcsx_rearmed_display_internal_fps = disabled -pcsx_rearmed_show_input_settings = disabled pcsx_rearmed_dithering = enabled +minarch_rewind_buffer_mb = 128 +minarch_rewind_granularity = 33 +minarch_rewind_compression_speed = 12 +minarch_rewind_skip_compression = Off minarch_gamepad_type = 1 bind Up = UP diff --git a/skeleton/SYSTEM/tg5040/paks/Emus/PS.pak/default.cfg b/skeleton/SYSTEM/tg5040/paks/Emus/PS.pak/default.cfg index 67f23b5b7..b940102c1 100755 --- a/skeleton/SYSTEM/tg5040/paks/Emus/PS.pak/default.cfg +++ b/skeleton/SYSTEM/tg5040/paks/Emus/PS.pak/default.cfg @@ -1,6 +1,10 @@ -minarch_prevent_tearing = Strict -pcsx_rearmed_display_internal_fps = disabled -pcsx_rearmed_show_input_settings = disabled +minarch_rewind_buffer_mb = 128 +minarch_rewind_granularity = 33 +minarch_rewind_compression_speed = 12 +minarch_rewind_skip_compression = Off pcsx_rearmed_dithering = enabled @@ -18,4 +22,4 @@ bind Square = Y bind L1 Button = L1 bind R1 Button = R1 bind L2 Button = L2 -bind R2 Button = R2 \ No newline at end of file +bind R2 Button = R2 diff --git a/workspace/all/minarch/makefile b/workspace/all/minarch/makefile index 2fc9b2fb2..8342565bc 100644 --- a/workspace/all/minarch/makefile +++ b/workspace/all/minarch/makefile @@ -28,15 +28,16 @@ CC = $(CROSS_COMPILE)gcc CFLAGS += $(OPT) -fomit-frame-pointer CFLAGS += $(INCDIR) -DPLATFORM=\"$(PLATFORM)\" -std=gnu99 LDFLAGS += -lmsettings -lsamplerate +LDFLAGS += -llz4 ifeq ($(PLATFORM), desktop) ifeq ($(UNAME_S),Linux) CFLAGS += `pkg-config --cflags libzip` -LDFLAGS += `pkg-config --libs libzip` +LDFLAGS += `pkg-config --libs libzip` -lz else -LDFLAGS += -lzip +LDFLAGS += -lzip -lz endif else -LDFLAGS += -Llibretro-common -lsrm -lzip +LDFLAGS += -Llibretro-common -lsrm -lzip -lz CFLAGS += -DHAS_SRM endif ifeq ($(PLATFORM), tg5040) @@ -68,6 +69,7 @@ all: clean libretro-common libsrm.a $(PREFIX_LOCAL)/include/msettings.h cp $(PREFIX)/lib/libbz2.so.1.0 build/$(PLATFORM) cp $(PREFIX)/lib/liblzma.so.5 build/$(PLATFORM) cp $(PREFIX)/lib/libzstd.so.1 build/$(PLATFORM) + cp $(PREFIX)/lib/liblz4.so.1 build/$(PLATFORM) $(CC) $(SOURCE) -o $(PRODUCT) $(CFLAGS) $(LDFLAGS) endif diff --git a/workspace/all/minarch/minarch.c b/workspace/all/minarch/minarch.c index 3daee1b6c..7e9db17bd 100644 --- a/workspace/all/minarch/minarch.c +++ b/workspace/all/minarch/minarch.c @@ -13,6 +13,7 @@ #include #include #include +#include // libretro-common #include "libretro.h" @@ -54,6 +55,22 @@ enum { SCALE_COUNT, }; +// defaults for rewind UI options (frontend-only) +#define MINARCH_DEFAULT_REWIND_ENABLE 0 +#define MINARCH_DEFAULT_REWIND_BUFFER_MB 64 +#define MINARCH_DEFAULT_REWIND_GRANULARITY 16 +#define MINARCH_DEFAULT_REWIND_AUDIO 0 +#define MINARCH_DEFAULT_REWIND_LZ4_ACCELERATION 2 + +// rewind implementation constants +#define REWIND_ENTRY_SIZE_HINT 4096 // assumed avg entry size for capacity calc +#define REWIND_MIN_ENTRIES 8 // minimum entry table size +#define REWIND_POOL_SIZE_SMALL 3 // capture pool size for small states +#define REWIND_POOL_SIZE_LARGE 4 // capture pool size for large states +#define REWIND_LARGE_STATE_THRESHOLD (2*1024*1024) // 2MB threshold for pool sizing +#define REWIND_MAX_BUFFER_MB 256 // max rewind buffer size +#define REWIND_MAX_LZ4_ACCELERATION 64 // max LZ4 acceleration value + // default frontend options static int screen_scaling = SCALE_ASPECT; static int resampling_quality = 2; @@ -70,6 +87,18 @@ static int show_debug = 0; static int max_ff_speed = 3; // 4x static int ff_audio = 0; static int fast_forward = 0; +static int rewind_pressed = 0; +static int rewind_toggle = 0; +static int ff_toggled = 0; +static int ff_hold_active = 0; +static int ff_paused_by_rewind_hold = 0; +static int rewinding = 0; +static int rewind_cfg_enable = MINARCH_DEFAULT_REWIND_ENABLE; +static int rewind_cfg_buffer_mb = MINARCH_DEFAULT_REWIND_BUFFER_MB; +static int rewind_cfg_granularity = MINARCH_DEFAULT_REWIND_GRANULARITY; +static int rewind_cfg_audio = MINARCH_DEFAULT_REWIND_AUDIO; +static int rewind_cfg_skip_compress = 0; +static int rewind_cfg_lz4_acceleration = MINARCH_DEFAULT_REWIND_LZ4_ACCELERATION; static int overclock = 3; // auto static int has_custom_controllers = 0; static int gamepad_type = 0; // index in gamepad_labels/gamepad_values @@ -1082,6 +1111,7 @@ static void State_autosave(void) { State_write(); state_slot = last_state_slot; } +static void Rewind_on_state_change(void); static void State_resume(void) { if (!exists(RESUME_SLOT_PATH)) return; @@ -1090,6 +1120,796 @@ static void State_resume(void) { unlink(RESUME_SLOT_PATH); State_read(); state_slot = last_state_slot; + Rewind_on_state_change(); +} + +/////////////////////////////// +// Rewind buffer (in-memory, compressed) + +typedef struct { + size_t offset; + size_t size; + uint8_t is_keyframe; // 1 if this entry is a full state, 0 if delta-encoded +} RewindEntry; + +typedef struct { + uint8_t *buffer; + size_t capacity; + size_t head; + size_t tail; + + RewindEntry *entries; + int entry_capacity; + int entry_head; + int entry_tail; + int entry_count; + + uint8_t *state_buf; + size_t state_size; + uint8_t *scratch; + size_t scratch_size; + + // Delta compression: store XOR of current vs previous state + uint8_t *prev_state_enc; // previous state for delta encoding (compression) + uint8_t *prev_state_dec; // previous state for delta decoding (decompression) + uint8_t *delta_buf; // scratch buffer for XOR result + int has_prev_enc; // 1 if prev_state_enc is valid + int has_prev_dec; // 1 if prev_state_dec is valid + + int granularity_frames; + int interval_ms; + uint32_t last_push_ms; + uint32_t last_step_ms; + int playback_interval_ms; + int use_time_cadence; + int frame_counter; + unsigned int generation; + int enabled; + int audio; + int compress; + int lz4_acceleration; + int logged_first; + + // async capture/compression + pthread_t worker; + pthread_mutex_t lock; + pthread_mutex_t queue_mx; + pthread_cond_t queue_cv; + int worker_stop; + int worker_running; + int drop_warned; + int locks_ready; + + uint8_t **capture_pool; + unsigned int *capture_gen; + uint8_t *capture_busy; + int pool_size; + int free_count; + int *free_stack; + + int queue_capacity; + int queue_head; + int queue_tail; + int queue_count; + int *queue; +} RewindContext; + +static RewindContext rewind_ctx = {0}; +static int rewind_warn_empty = 0; +static int last_rewind_pressed = 0; + +static void* Rewind_worker_thread(void *arg); +static int Rewind_write_entry_locked(const uint8_t *compressed, size_t dest_len, int is_keyframe); +static int Rewind_compress_state(const uint8_t *src, size_t *dest_len, int *is_keyframe_out); +static void Rewind_wait_for_worker_idle(void); + +static void Rewind_free(void) { + if (rewind_ctx.worker_running) { + pthread_mutex_lock(&rewind_ctx.queue_mx); + rewind_ctx.worker_stop = 1; + pthread_cond_signal(&rewind_ctx.queue_cv); + pthread_mutex_unlock(&rewind_ctx.queue_mx); + pthread_join(rewind_ctx.worker, NULL); + rewind_ctx.worker_running = 0; + } + + if (rewind_ctx.capture_pool) { + for (int i = 0; i < rewind_ctx.pool_size; i++) { + if (rewind_ctx.capture_pool[i]) free(rewind_ctx.capture_pool[i]); + } + free(rewind_ctx.capture_pool); + } + if (rewind_ctx.capture_gen) free(rewind_ctx.capture_gen); + if (rewind_ctx.capture_busy) free(rewind_ctx.capture_busy); + if (rewind_ctx.free_stack) free(rewind_ctx.free_stack); + if (rewind_ctx.queue) free(rewind_ctx.queue); + if (rewind_ctx.buffer) free(rewind_ctx.buffer); + if (rewind_ctx.entries) free(rewind_ctx.entries); + if (rewind_ctx.state_buf) free(rewind_ctx.state_buf); + if (rewind_ctx.scratch) free(rewind_ctx.scratch); + if (rewind_ctx.prev_state_enc) free(rewind_ctx.prev_state_enc); + if (rewind_ctx.prev_state_dec) free(rewind_ctx.prev_state_dec); + if (rewind_ctx.delta_buf) free(rewind_ctx.delta_buf); + if (rewind_ctx.locks_ready) { + pthread_mutex_destroy(&rewind_ctx.lock); + pthread_mutex_destroy(&rewind_ctx.queue_mx); + pthread_cond_destroy(&rewind_ctx.queue_cv); + } + memset(&rewind_ctx, 0, sizeof(rewind_ctx)); + rewinding = 0; +} + +static void Rewind_reset(void) { + if (!rewind_ctx.enabled) return; + Rewind_wait_for_worker_idle(); + pthread_mutex_lock(&rewind_ctx.lock); + rewind_ctx.head = rewind_ctx.tail = 0; + rewind_ctx.entry_head = rewind_ctx.entry_tail = rewind_ctx.entry_count = 0; + rewind_ctx.has_prev_enc = 0; + rewind_ctx.has_prev_dec = 0; + pthread_mutex_unlock(&rewind_ctx.lock); + rewind_ctx.frame_counter = 0; + rewind_ctx.last_push_ms = 0; + rewind_ctx.last_step_ms = 0; + rewind_ctx.generation += 1; + rewind_ctx.drop_warned = 0; + rewind_ctx.worker_stop = 0; + if (!rewind_ctx.generation) rewind_ctx.generation = 1; // avoid zero if it wrapped + // clear pending async work so new snapshots don't mix with stale ones + if (rewind_ctx.pool_size) { + pthread_mutex_lock(&rewind_ctx.queue_mx); + while (rewind_ctx.queue_count > 0) { + int slot = rewind_ctx.queue[rewind_ctx.queue_head]; + rewind_ctx.queue_head = (rewind_ctx.queue_head + 1) % rewind_ctx.queue_capacity; + rewind_ctx.queue_count -= 1; + rewind_ctx.capture_busy[slot] = 0; + } + rewind_ctx.queue_head = rewind_ctx.queue_tail = 0; + rewind_ctx.free_count = 0; + for (int i = 0; i < rewind_ctx.pool_size; i++) { + if (!rewind_ctx.capture_busy[i] && rewind_ctx.free_count < rewind_ctx.pool_size) { + rewind_ctx.free_stack[rewind_ctx.free_count++] = i; + } + } + pthread_mutex_unlock(&rewind_ctx.queue_mx); + } + rewinding = 0; + rewind_warn_empty = 0; +} + +static size_t Rewind_free_space_locked(void) { + if (rewind_ctx.entry_count > 0 && rewind_ctx.head == rewind_ctx.tail) return 0; + if (rewind_ctx.head >= rewind_ctx.tail) + return rewind_ctx.capacity - (rewind_ctx.head - rewind_ctx.tail); + else + return rewind_ctx.tail - rewind_ctx.head; +} +static void Rewind_drop_oldest_locked(void) { + if (!rewind_ctx.entry_count) return; + RewindEntry *e = &rewind_ctx.entries[rewind_ctx.entry_tail]; + rewind_ctx.tail = (e->offset + e->size) % rewind_ctx.capacity; + rewind_ctx.entry_tail = (rewind_ctx.entry_tail + 1) % rewind_ctx.entry_capacity; + rewind_ctx.entry_count -= 1; + if (rewind_ctx.entry_count == 0) { + rewind_ctx.head = rewind_ctx.tail = 0; + } +} + +// Block until the worker has drained its queue and is not holding any slots +static void Rewind_wait_for_worker_idle(void) { + if (!rewind_ctx.worker_running || !rewind_ctx.pool_size) return; + pthread_mutex_lock(&rewind_ctx.queue_mx); + while (rewind_ctx.queue_count > 0 || rewind_ctx.free_count < rewind_ctx.pool_size) { + pthread_mutex_unlock(&rewind_ctx.queue_mx); + struct timespec ts = {0, 1000000}; // 1ms + nanosleep(&ts, NULL); + pthread_mutex_lock(&rewind_ctx.queue_mx); + } + pthread_mutex_unlock(&rewind_ctx.queue_mx); +} + +// Check if an entry overlaps with range [range_start, range_end) in a non-wrapping buffer region +static int Rewind_entry_overlaps_range(int entry_idx, size_t range_start, size_t range_end) { + RewindEntry *e = &rewind_ctx.entries[entry_idx]; + size_t e_start = e->offset; + size_t e_end = e->offset + e->size; + // Check for overlap: ranges overlap if start < other_end AND other_start < end + return (e_start < range_end) && (range_start < e_end); +} + +static int Rewind_write_entry_locked(const uint8_t *compressed, size_t dest_len, int is_keyframe) { + if (dest_len >= rewind_ctx.capacity) { + LOG_error("Rewind: state does not fit in buffer\n"); + return 0; + } + + // If the entry table is full, drop the oldest entry *before* writing so we don't + // overwrite its metadata (entry_head == entry_tail when full). + if (rewind_ctx.entry_count == rewind_ctx.entry_capacity) { + Rewind_drop_oldest_locked(); + } + + size_t write_offset = rewind_ctx.head; + + // If this write would go past the end of the buffer, wrap to 0 + if (write_offset + dest_len > rewind_ctx.capacity) { + write_offset = 0; + rewind_ctx.head = 0; + if (rewind_ctx.entry_count == 0) { + rewind_ctx.tail = 0; + } + } + + // Drop any entries that overlap with the region we're about to write: [write_offset, write_offset + dest_len) + // We need to check all entries from tail to head and drop any that overlap. + // Since entries are stored oldest-to-newest, we drop from oldest while they overlap. + while (rewind_ctx.entry_count > 0) { + int oldest_idx = rewind_ctx.entry_tail; + if (Rewind_entry_overlaps_range(oldest_idx, write_offset, write_offset + dest_len)) { + Rewind_drop_oldest_locked(); + } else { + break; + } + } + + // Still need to make room based on free space calculation + while (rewind_ctx.entry_count > 0 && Rewind_free_space_locked() <= dest_len) { + Rewind_drop_oldest_locked(); + } + + // Safety check: if we still can't fit, there's a logic error + if (Rewind_free_space_locked() <= dest_len && rewind_ctx.entry_count > 0) { + LOG_error("Rewind: unable to make room for entry (need %zu, have %zu)\n", dest_len, Rewind_free_space_locked()); + return 0; + } + + memcpy(rewind_ctx.buffer + write_offset, compressed, dest_len); + + RewindEntry *e = &rewind_ctx.entries[rewind_ctx.entry_head]; + e->offset = write_offset; + e->size = dest_len; + e->is_keyframe = is_keyframe ? 1 : 0; + + rewind_ctx.head = write_offset + dest_len; + if (rewind_ctx.head >= rewind_ctx.capacity) rewind_ctx.head = 0; + + rewind_ctx.entry_head = (rewind_ctx.entry_head + 1) % rewind_ctx.entry_capacity; + if (rewind_ctx.entry_count < rewind_ctx.entry_capacity) { + rewind_ctx.entry_count += 1; + } else { + Rewind_drop_oldest_locked(); + } + rewind_warn_empty = 0; + return 1; +} + +static int Rewind_compress_state(const uint8_t *src, size_t *dest_len, int *is_keyframe_out) { + if (!rewind_ctx.scratch || !dest_len) return -1; + if (is_keyframe_out) *is_keyframe_out = 1; // default to keyframe + if (!rewind_ctx.compress) { + *dest_len = rewind_ctx.state_size; + memcpy(rewind_ctx.scratch, src, rewind_ctx.state_size); + if (is_keyframe_out) *is_keyframe_out = 1; // raw snapshots are always keyframes + if (!rewind_ctx.logged_first) { + rewind_ctx.logged_first = 1; + LOG_info("Rewind: compression disabled, storing %zu bytes per snapshot\n", rewind_ctx.state_size); + } + return 0; + } + + // Delta compression: XOR current state with previous state + // The result is mostly zeros for similar consecutive states, which compresses much faster + const uint8_t *compress_src = src; + int used_delta = 0; + if (rewind_ctx.has_prev_enc && rewind_ctx.prev_state_enc && rewind_ctx.delta_buf) { + size_t state_size = rewind_ctx.state_size; + uint8_t *delta = rewind_ctx.delta_buf; + const uint8_t *prev = rewind_ctx.prev_state_enc; + // Byte-by-byte XOR to avoid unaligned memory access issues + for (size_t i = 0; i < state_size; i++) { + delta[i] = src[i] ^ prev[i]; + } + compress_src = delta; + used_delta = 1; + } + + int max_dst = (int)rewind_ctx.scratch_size; + // acceleration: 1=default speed, higher=faster but slightly lower ratio + int accel = rewind_ctx.lz4_acceleration > 0 ? rewind_ctx.lz4_acceleration : MINARCH_DEFAULT_REWIND_LZ4_ACCELERATION; + int res = LZ4_compress_fast((const char*)compress_src, (char*)rewind_ctx.scratch, (int)rewind_ctx.state_size, max_dst, accel); + if (res <= 0) return -1; + *dest_len = (size_t)res; + + // Report whether this was a keyframe (full state) or delta + if (is_keyframe_out) *is_keyframe_out = used_delta ? 0 : 1; + + // Update prev_state_enc with the current state for next delta + if (rewind_ctx.prev_state_enc) { + memcpy(rewind_ctx.prev_state_enc, src, rewind_ctx.state_size); + rewind_ctx.has_prev_enc = 1; + } + + return 0; +} + +static int Rewind_init(size_t state_size) { + Rewind_free(); + // pull current option values directly + int enable = rewind_cfg_enable; + int buf_mb = rewind_cfg_buffer_mb; + int gran = rewind_cfg_granularity; + int audio = rewind_cfg_audio; + int skip_compress = rewind_cfg_skip_compress; + if (!enable) { + LOG_info("Rewind: disabled via config\n"); + return 0; + } + if (!state_size) { + LOG_info("Rewind: core reported zero serialize size, disabling\n"); + return 0; + } + + // Bounds check before size_t conversion to avoid negative int issues + if (buf_mb < 1) buf_mb = 1; + if (buf_mb > REWIND_MAX_BUFFER_MB) buf_mb = REWIND_MAX_BUFFER_MB; + size_t buffer_mb = (size_t)buf_mb; + + rewind_ctx.capacity = buffer_mb * 1024 * 1024; + rewind_ctx.compress = skip_compress ? 0 : 1; + if (!rewind_ctx.compress && rewind_ctx.capacity <= state_size) { + LOG_warn("Rewind: raw snapshots (%zu bytes) do not fit in %zu-byte buffer; falling back to compression\n", + state_size, rewind_ctx.capacity); + rewind_ctx.compress = 1; + } + int accel = rewind_cfg_lz4_acceleration; + if (accel < 1) accel = 1; + if (accel > REWIND_MAX_LZ4_ACCELERATION) accel = REWIND_MAX_LZ4_ACCELERATION; + rewind_ctx.lz4_acceleration = accel; + rewind_ctx.logged_first = 0; + if (rewind_ctx.compress) { + LOG_info("Rewind: config enable=%i bufferMB=%i interval=%ims audio=%i compression=lz4 (accel=%i)\n", + enable, buf_mb, gran, audio, rewind_ctx.lz4_acceleration); + } else { + LOG_info("Rewind: config enable=%i bufferMB=%i interval=%ims audio=%i compression=raw\n", + enable, buf_mb, gran, audio); + } + rewind_ctx.buffer = calloc(1, rewind_ctx.capacity); + if (!rewind_ctx.buffer) { + LOG_error("Rewind: failed to allocate buffer\n"); + return 0; + } + + rewind_ctx.state_size = state_size; + rewind_ctx.state_buf = calloc(1, state_size); + if (!rewind_ctx.state_buf) { + LOG_error("Rewind: failed to allocate state buffer\n"); + Rewind_free(); + return 0; + } + + rewind_ctx.scratch_size = LZ4_compressBound((int)state_size); + if (!rewind_ctx.compress) rewind_ctx.scratch_size = state_size; + rewind_ctx.scratch = calloc(1, rewind_ctx.scratch_size); + if (!rewind_ctx.scratch) { + LOG_error("Rewind: failed to allocate scratch buffer\n"); + Rewind_free(); + return 0; + } + + // Allocate delta compression buffers (separate for encode/decode to avoid race conditions) + rewind_ctx.prev_state_enc = calloc(1, state_size); + rewind_ctx.prev_state_dec = calloc(1, state_size); + rewind_ctx.delta_buf = calloc(1, state_size); + if (!rewind_ctx.prev_state_enc || !rewind_ctx.prev_state_dec || !rewind_ctx.delta_buf) { + LOG_error("Rewind: failed to allocate delta buffers\n"); + Rewind_free(); + return 0; + } + rewind_ctx.has_prev_enc = 0; + rewind_ctx.has_prev_dec = 0; + + int entry_cap = rewind_ctx.capacity / REWIND_ENTRY_SIZE_HINT; + if (entry_cap < REWIND_MIN_ENTRIES) entry_cap = REWIND_MIN_ENTRIES; + rewind_ctx.entry_capacity = entry_cap; + rewind_ctx.entries = calloc(entry_cap, sizeof(RewindEntry)); + if (!rewind_ctx.entries) { + LOG_error("Rewind: failed to allocate entry table\n"); + Rewind_free(); + return 0; + } + + rewind_ctx.granularity_frames = 1; + rewind_ctx.interval_ms = gran < 1 ? 1 : gran; // treat granularity as milliseconds always + rewind_ctx.use_time_cadence = 1; + double fps = core.fps > 1.0 ? core.fps : 60.0; + int frame_ms = (int)(1000.0 / fps); + if (frame_ms < 1) frame_ms = 1; + // Capture interval in milliseconds (time-based only) + int capture_ms = rewind_ctx.interval_ms; + if (capture_ms < frame_ms) capture_ms = frame_ms; + // Play back at the capture cadence (match recorded speed) but never faster than native frame time + int playback_ms = capture_ms; + if (playback_ms < frame_ms) playback_ms = frame_ms; + rewind_ctx.playback_interval_ms = playback_ms; + LOG_info("Rewind: capture_ms=%d, playback_ms=%d (state size=%zu bytes, buffer=%zu bytes, entries=%d)\n", + capture_ms, playback_ms, state_size, rewind_ctx.capacity, rewind_ctx.entry_capacity); + rewind_ctx.audio = audio; + rewind_ctx.enabled = 1; + rewind_ctx.generation = 1; + rewind_ctx.worker_stop = 0; + rewind_ctx.queue_head = rewind_ctx.queue_tail = rewind_ctx.queue_count = 0; + rewind_ctx.drop_warned = 0; + + pthread_mutex_init(&rewind_ctx.lock, NULL); + pthread_mutex_init(&rewind_ctx.queue_mx, NULL); + pthread_cond_init(&rewind_ctx.queue_cv, NULL); + rewind_ctx.locks_ready = 1; + + // set up async capture buffers + // Larger states need a deeper pool to avoid drops; cap to a modest size to limit RAM + rewind_ctx.pool_size = (state_size > REWIND_LARGE_STATE_THRESHOLD) ? REWIND_POOL_SIZE_LARGE : REWIND_POOL_SIZE_SMALL; + if (rewind_ctx.pool_size < 1) rewind_ctx.pool_size = 1; + rewind_ctx.capture_pool = calloc(rewind_ctx.pool_size, sizeof(uint8_t*)); + rewind_ctx.capture_gen = calloc(rewind_ctx.pool_size, sizeof(unsigned int)); + rewind_ctx.capture_busy = calloc(rewind_ctx.pool_size, sizeof(uint8_t)); + rewind_ctx.free_stack = calloc(rewind_ctx.pool_size, sizeof(int)); + rewind_ctx.queue = calloc(rewind_ctx.pool_size, sizeof(int)); + if (!rewind_ctx.capture_pool || !rewind_ctx.capture_gen || !rewind_ctx.capture_busy || !rewind_ctx.free_stack || !rewind_ctx.queue) { + LOG_error("Rewind: failed to allocate async capture buffers\n"); + Rewind_free(); + return 0; + } + for (int i = 0; i < rewind_ctx.pool_size; i++) { + rewind_ctx.capture_pool[i] = calloc(1, state_size); + if (!rewind_ctx.capture_pool[i]) { + LOG_error("Rewind: failed to allocate capture slot %i\n", i); + Rewind_free(); + return 0; + } + rewind_ctx.free_stack[i] = i; + } + rewind_ctx.queue_capacity = rewind_ctx.pool_size; + rewind_ctx.free_count = rewind_ctx.pool_size; + + if (pthread_create(&rewind_ctx.worker, NULL, Rewind_worker_thread, NULL) != 0) { + // fallback to synchronous path + LOG_error("Rewind: failed to start worker thread, falling back to synchronous capture\n"); + rewind_ctx.pool_size = 0; + rewind_ctx.queue_capacity = 0; + rewind_ctx.free_count = 0; + } else { + rewind_ctx.worker_running = 1; + } + + LOG_info("Rewind: enabled (%zu bytes buffer, cadence %i %s)\n", rewind_ctx.capacity, + rewind_ctx.use_time_cadence ? rewind_ctx.interval_ms : rewind_ctx.granularity_frames, + rewind_ctx.use_time_cadence ? "ms" : "frames"); + return 1; +} + +static void* Rewind_worker_thread(void *arg) { + (void)arg; + + while (1) { + pthread_mutex_lock(&rewind_ctx.queue_mx); + while (!rewind_ctx.worker_stop && rewind_ctx.queue_count == 0) { + pthread_cond_wait(&rewind_ctx.queue_cv, &rewind_ctx.queue_mx); + } + if (rewind_ctx.worker_stop && rewind_ctx.queue_count == 0) { + pthread_mutex_unlock(&rewind_ctx.queue_mx); + break; + } + + int slot = rewind_ctx.queue[rewind_ctx.queue_head]; + rewind_ctx.queue_head = (rewind_ctx.queue_head + 1) % rewind_ctx.queue_capacity; + rewind_ctx.queue_count -= 1; + unsigned int gen = rewind_ctx.capture_gen[slot]; + pthread_mutex_unlock(&rewind_ctx.queue_mx); + + if (gen != rewind_ctx.generation) { + // stale snapshot, drop quietly + pthread_mutex_lock(&rewind_ctx.queue_mx); + rewind_ctx.capture_busy[slot] = 0; + rewind_ctx.free_stack[rewind_ctx.free_count++] = slot; + pthread_mutex_unlock(&rewind_ctx.queue_mx); + continue; + } + + size_t dest_len = rewind_ctx.scratch_size; + int is_keyframe = 1; + pthread_mutex_lock(&rewind_ctx.lock); + if (gen == rewind_ctx.generation) { + int res = Rewind_compress_state(rewind_ctx.capture_pool[slot], &dest_len, &is_keyframe); + if (res == 0) { + Rewind_write_entry_locked(rewind_ctx.scratch, dest_len, is_keyframe); + } else { + LOG_error("Rewind: compression failed (%i)\n", res); + } + } + // If generation changed mid-flight, drop silently after releasing the slot + pthread_mutex_unlock(&rewind_ctx.lock); + + pthread_mutex_lock(&rewind_ctx.queue_mx); + rewind_ctx.capture_busy[slot] = 0; + rewind_ctx.free_stack[rewind_ctx.free_count++] = slot; + pthread_mutex_unlock(&rewind_ctx.queue_mx); + } + + return NULL; +} + +static void Rewind_push(int force) { + if (!rewind_ctx.enabled) return; + if (!rewind_ctx.buffer || !rewind_ctx.state_buf) return; + + uint32_t now_ms = SDL_GetTicks(); + if (!force) { + if (rewind_ctx.use_time_cadence) { + if (rewind_ctx.last_push_ms && (int)(now_ms - rewind_ctx.last_push_ms) < rewind_ctx.interval_ms) return; + rewind_ctx.last_push_ms = now_ms; + } else { + rewind_ctx.frame_counter += 1; + if (rewind_ctx.frame_counter < rewind_ctx.granularity_frames) return; + rewind_ctx.frame_counter = 0; + } + } else { + rewind_ctx.frame_counter = 0; + rewind_ctx.last_push_ms = now_ms; + } + + if (!core.serialize || !core.serialize_size) return; + + if (rewind_ctx.worker_running && rewind_ctx.pool_size) { + int slot = -1; + while (1) { + pthread_mutex_lock(&rewind_ctx.queue_mx); + if (rewind_ctx.free_count && rewind_ctx.queue_count < rewind_ctx.queue_capacity) { + slot = rewind_ctx.free_stack[--rewind_ctx.free_count]; + rewind_ctx.capture_busy[slot] = 1; + pthread_mutex_unlock(&rewind_ctx.queue_mx); + break; + } + // No free slot: synchronously process the oldest queued capture to preserve ordering + if (rewind_ctx.queue_count > 0) { + int queued_slot = rewind_ctx.queue[rewind_ctx.queue_head]; + unsigned int gen = rewind_ctx.capture_gen[queued_slot]; + rewind_ctx.queue_head = (rewind_ctx.queue_head + 1) % rewind_ctx.queue_capacity; + rewind_ctx.queue_count -= 1; + pthread_mutex_unlock(&rewind_ctx.queue_mx); + + size_t dest_len = rewind_ctx.scratch_size; + int is_keyframe = 1; + pthread_mutex_lock(&rewind_ctx.lock); + if (gen == rewind_ctx.generation) { + int res = Rewind_compress_state(rewind_ctx.capture_pool[queued_slot], &dest_len, &is_keyframe); + if (res == 0) { + Rewind_write_entry_locked(rewind_ctx.scratch, dest_len, is_keyframe); + } else { + LOG_error("Rewind: compression failed (%i)\n", res); + } + } + pthread_mutex_unlock(&rewind_ctx.lock); + + pthread_mutex_lock(&rewind_ctx.queue_mx); + rewind_ctx.capture_busy[queued_slot] = 0; + rewind_ctx.free_stack[rewind_ctx.free_count++] = queued_slot; + pthread_mutex_unlock(&rewind_ctx.queue_mx); + // loop again to try to grab a free slot for the current frame + continue; + } + pthread_mutex_unlock(&rewind_ctx.queue_mx); + break; + } + + if (slot < 0) { + // worker is busy; fall back to synchronous capture so we don't miss cadence + if (!core.serialize(rewind_ctx.state_buf, rewind_ctx.state_size)) { + LOG_error("Rewind: serialize failed (sync fallback)\n"); + return; + } + + size_t dest_len = rewind_ctx.scratch_size; + int is_keyframe = 1; + pthread_mutex_lock(&rewind_ctx.lock); + int res = Rewind_compress_state(rewind_ctx.state_buf, &dest_len, &is_keyframe); + if (res != 0) { + pthread_mutex_unlock(&rewind_ctx.lock); + LOG_error("Rewind: compression failed (sync fallback) (%i)\n", res); + return; + } + + Rewind_write_entry_locked(rewind_ctx.scratch, dest_len, is_keyframe); + pthread_mutex_unlock(&rewind_ctx.lock); + return; + } + + uint8_t *buf = rewind_ctx.capture_pool[slot]; + if (!core.serialize(buf, rewind_ctx.state_size)) { + LOG_error("Rewind: serialize failed\n"); + pthread_mutex_lock(&rewind_ctx.queue_mx); + rewind_ctx.capture_busy[slot] = 0; + rewind_ctx.free_stack[rewind_ctx.free_count++] = slot; + pthread_mutex_unlock(&rewind_ctx.queue_mx); + return; + } + + rewind_ctx.capture_gen[slot] = rewind_ctx.generation; + pthread_mutex_lock(&rewind_ctx.queue_mx); + rewind_ctx.queue[rewind_ctx.queue_tail] = slot; + rewind_ctx.queue_tail = (rewind_ctx.queue_tail + 1) % rewind_ctx.queue_capacity; + rewind_ctx.queue_count += 1; + pthread_cond_signal(&rewind_ctx.queue_cv); + pthread_mutex_unlock(&rewind_ctx.queue_mx); + rewind_ctx.drop_warned = 0; + return; + } + + // synchronous fallback (thread not available) + if (!core.serialize(rewind_ctx.state_buf, rewind_ctx.state_size)) { + LOG_error("Rewind: serialize failed\n"); + return; + } + + size_t dest_len = rewind_ctx.scratch_size; + int is_keyframe = 1; + pthread_mutex_lock(&rewind_ctx.lock); + int res = Rewind_compress_state(rewind_ctx.state_buf, &dest_len, &is_keyframe); + if (res != 0) { + pthread_mutex_unlock(&rewind_ctx.lock); + LOG_error("Rewind: compression failed (%i)\n", res); + return; + } + + Rewind_write_entry_locked(rewind_ctx.scratch, dest_len, is_keyframe); + pthread_mutex_unlock(&rewind_ctx.lock); + rewind_ctx.drop_warned = 0; +} + +// Returns: 0 = buffer empty/disabled, 1 = stepped back successfully, 2 = waiting for cadence (don't run core) +static int Rewind_step_back(void) { + if (!rewind_ctx.enabled) return 0; + uint32_t now_ms = SDL_GetTicks(); + if (rewind_ctx.playback_interval_ms > 0 && rewind_ctx.last_step_ms && + (int)(now_ms - rewind_ctx.last_step_ms) < rewind_ctx.playback_interval_ms) { + // still rewinding, just waiting for cadence; don't run core, just re-render + return 2; + } + + // On first rewind step, we need to: + // 1. Wait for any pending compression to finish (so entry indices are stable) + // 2. Copy the last compressed state as our delta reference + if (!rewinding && rewind_ctx.compress && rewind_ctx.prev_state_dec) { + // Wait for worker to finish all pending compressions + Rewind_wait_for_worker_idle(); + pthread_mutex_lock(&rewind_ctx.lock); + if (rewind_ctx.has_prev_enc && rewind_ctx.prev_state_enc) { + memcpy(rewind_ctx.prev_state_dec, rewind_ctx.prev_state_enc, rewind_ctx.state_size); + rewind_ctx.has_prev_dec = 1; + } else { + rewind_ctx.has_prev_dec = 0; + } + pthread_mutex_unlock(&rewind_ctx.lock); + } + + pthread_mutex_lock(&rewind_ctx.lock); + if (!rewind_ctx.entry_count) { + pthread_mutex_unlock(&rewind_ctx.lock); + if (!rewind_warn_empty) { + LOG_info("Rewind: no buffered states yet\n"); + rewind_warn_empty = 1; + } + return 0; + } + + int idx = rewind_ctx.entry_head - 1; + if (idx < 0) idx += rewind_ctx.entry_capacity; + RewindEntry *e = &rewind_ctx.entries[idx]; + + int decode_ok = 1; + if (rewind_ctx.compress) { + // Decompress into delta_buf first (it may contain XOR delta or full state) + int res = LZ4_decompress_safe((const char*)rewind_ctx.buffer + e->offset, + (char*)rewind_ctx.delta_buf, (int)e->size, (int)rewind_ctx.state_size); + if (res < (int)rewind_ctx.state_size) { + LOG_error("Rewind: decompress failed (res=%i, want=%zu, compressed=%zu, offset=%zu, idx=%d head=%d tail=%d count=%d buf_head=%zu buf_tail=%zu)\n", + res, rewind_ctx.state_size, e->size, e->offset, idx, rewind_ctx.entry_head, rewind_ctx.entry_tail, rewind_ctx.entry_count, rewind_ctx.head, rewind_ctx.tail); + decode_ok = 0; + } else if (e->is_keyframe) { + // This is a keyframe (full state), just copy it directly + memcpy(rewind_ctx.state_buf, rewind_ctx.delta_buf, rewind_ctx.state_size); + if (rewind_ctx.prev_state_dec) { + memcpy(rewind_ctx.prev_state_dec, rewind_ctx.state_buf, rewind_ctx.state_size); + rewind_ctx.has_prev_dec = 1; + } + } else if (rewind_ctx.has_prev_dec && rewind_ctx.prev_state_dec) { + // Delta decompression: XOR the delta with prev_state_dec to recover the actual state + // prev_state_dec holds the current state (state N), delta = state_N XOR state_(N-1) + // So: state_(N-1) = delta XOR state_N = delta XOR prev_state_dec + size_t state_size = rewind_ctx.state_size; + uint8_t *result = rewind_ctx.state_buf; + const uint8_t *delta = rewind_ctx.delta_buf; + const uint8_t *prev = rewind_ctx.prev_state_dec; + // Byte-by-byte XOR to avoid unaligned memory access issues + for (size_t i = 0; i < state_size; i++) { + result[i] = delta[i] ^ prev[i]; + } + // Update prev_state_dec to the state we just recovered (for next rewind step) + memcpy(rewind_ctx.prev_state_dec, result, state_size); + } else { + // Delta frame but no previous state - this shouldn't happen with proper keyframe tracking + // Fall back to treating it as a full state (may produce incorrect results) + LOG_warn("Rewind: delta frame without previous state, results may be incorrect\n"); + memcpy(rewind_ctx.state_buf, rewind_ctx.delta_buf, rewind_ctx.state_size); + if (rewind_ctx.prev_state_dec) { + memcpy(rewind_ctx.prev_state_dec, rewind_ctx.state_buf, rewind_ctx.state_size); + rewind_ctx.has_prev_dec = 1; + } + } + } else { + if (e->size != rewind_ctx.state_size) { + LOG_error("Rewind: raw snapshot size mismatch (got=%zu, want=%zu, offset=%zu)\n", + e->size, rewind_ctx.state_size, e->offset); + decode_ok = 0; + } else { + memcpy(rewind_ctx.state_buf, rewind_ctx.buffer + e->offset, rewind_ctx.state_size); + } + } + if (!decode_ok) { + // On decode failure, drop the corrupted newest entry instead of oldest + rewind_ctx.entry_head = idx; + rewind_ctx.entry_count -= 1; + if (rewind_ctx.entry_count == 0) { + rewind_ctx.head = rewind_ctx.tail = 0; + } + pthread_mutex_unlock(&rewind_ctx.lock); + return 0; + } + + if (!core.unserialize(rewind_ctx.state_buf, rewind_ctx.state_size)) { + LOG_error("Rewind: unserialize failed\n"); + Rewind_drop_oldest_locked(); + pthread_mutex_unlock(&rewind_ctx.lock); + return 0; + } + + // pop newest + rewind_ctx.entry_head = idx; + rewind_ctx.entry_count -= 1; + if (rewind_ctx.entry_count == 0) { + rewind_ctx.head = rewind_ctx.tail = 0; + } + pthread_mutex_unlock(&rewind_ctx.lock); + + rewinding = 1; + rewind_ctx.last_step_ms = now_ms; + return 1; +} + +// Call this when rewind ends to sync the encode buffer with the last decoded state +// Also clears old entries that were compressed with a different delta chain +static void Rewind_sync_encode_state(void) { + if (!rewind_ctx.enabled || !rewind_ctx.compress) return; + if (!rewinding) return; // Only sync if we were actually rewinding + + pthread_mutex_lock(&rewind_ctx.lock); + + // The decoder's prev_state_dec contains the state we rewound to. + // Use it as the new reference for future compressions so the existing + // rewind history remains valid and we can continue rewinding further back. + if (rewind_ctx.has_prev_dec && rewind_ctx.prev_state_dec && rewind_ctx.prev_state_enc) { + memcpy(rewind_ctx.prev_state_enc, rewind_ctx.prev_state_dec, rewind_ctx.state_size); + rewind_ctx.has_prev_enc = 1; + } else { + rewind_ctx.has_prev_enc = 0; + } + + pthread_mutex_unlock(&rewind_ctx.lock); +} + +static void Rewind_on_state_change(void) { + Rewind_reset(); + Rewind_push(1); + LOG_info("Rewind: state changed, buffer re-seeded\n"); } /////////////////////////////// @@ -1146,6 +1966,66 @@ static char* resample_labels[] = { "Max", NULL }; +static char* rewind_enable_labels[] = { + "Off", + "On", + NULL +}; +static char* rewind_buffer_labels[] = { + "8", + "16", + "32", + "64", + "128", + "256", + NULL +}; +static char* rewind_granularity_values[] = { + "16", + "22", + "25", + "33", + "50", + "66", + "100", + "150", + "200", + "300", + "450", + "600", + NULL +}; +static char* rewind_granularity_labels[] = { + "16 ms (~60 fps)", + "22 ms (~45 fps)", + "25 ms (~40 fps)", + "33 ms (~30 fps)", + "50 ms (~20 fps)", + "66 ms (~15 fps)", + "100 ms (~10 fps)", + "150 ms (~7 fps)", + "200 ms (~5 fps)", + "300 ms", + "450 ms", + "600 ms", + NULL +}; +static char* rewind_compression_accel_values[] = { + "1", + "2", + "4", + "8", + "12", + NULL +}; +static char* rewind_compression_accel_labels[] = { + "1 (best ratio)", + "2 (default)", + "4 (fast)", + "8 (faster)", + "12 (fastest)", + NULL +}; static char* ambient_labels[] = { "Off", "All", @@ -1379,6 +2259,12 @@ enum { FE_OPT_DEBUG, FE_OPT_MAXFF, FE_OPT_FF_AUDIO, + FE_OPT_REWIND_ENABLE, + FE_OPT_REWIND_BUFFER, + FE_OPT_REWIND_GRANULARITY, + FE_OPT_REWIND_SKIP_COMPRESSION, + FE_OPT_REWIND_COMPRESSION_ACCEL, + FE_OPT_REWIND_AUDIO, FE_OPT_COUNT, }; @@ -1391,6 +2277,8 @@ enum { SHORTCUT_CYCLE_EFFECT, SHORTCUT_TOGGLE_FF, SHORTCUT_HOLD_FF, + SHORTCUT_TOGGLE_REWIND, + SHORTCUT_HOLD_REWIND, SHORTCUT_GAMESWITCHER, SHORTCUT_SCREENSHOT, // Trimui only @@ -1741,6 +2629,66 @@ static struct Config { .values = onoff_labels, .labels = onoff_labels, }, + [FE_OPT_REWIND_ENABLE] = { + .key = "minarch_rewind_enable", + .name = "Rewind", + .desc = "Enable in-memory rewind buffer.\nMust set a shortcut to access rewind during gameplay.\nUses extra CPU and memory.", + .default_value = MINARCH_DEFAULT_REWIND_ENABLE ? 1 : 0, + .value = MINARCH_DEFAULT_REWIND_ENABLE ? 1 : 0, + .count = 2, + .values = rewind_enable_labels, + .labels = rewind_enable_labels, + }, + [FE_OPT_REWIND_BUFFER] = { + .key = "minarch_rewind_buffer_mb", + .name = "Rewind Buffer (MB)", + .desc = "Memory reserved for rewind snapshots.\nIncrease for longer rewind times.", + .default_value = 3, // 64MB + .value = 3, + .count = 6, + .values = rewind_buffer_labels, + .labels = rewind_buffer_labels, + }, + [FE_OPT_REWIND_GRANULARITY] = { + .key = "minarch_rewind_granularity", + .name = "Rewind Interval", + .desc = "Interval between rewind snapshots.\nShorter intervals improve smoothness during rewind,\nbut increase CPU and memory usage.", + .default_value = 0, // 16ms + .value = 0, + .count = 12, + .values = rewind_granularity_values, + .labels = rewind_granularity_labels, + }, + [FE_OPT_REWIND_SKIP_COMPRESSION] = { + .key = "minarch_rewind_skip_compression", + .name = "Skip Rewind Compression", + .desc = "Store raw rewind snapshots instead of compressing them.\nUses more memory but less CPU.", + .default_value = 0, + .value = 0, + .count = 2, + .values = onoff_labels, + .labels = onoff_labels, + }, + [FE_OPT_REWIND_COMPRESSION_ACCEL] = { + .key = "minarch_rewind_compression_speed", + .name = "Rewind Compression Speed", + .desc = "LZ4 acceleration used for rewind snapshots.\nLower values compress more but use more CPU.", + .default_value = 1, // value 2 + .value = 1, + .count = 5, + .values = rewind_compression_accel_values, + .labels = rewind_compression_accel_labels, + }, + [FE_OPT_REWIND_AUDIO] = { + .key = "minarch_rewind_audio", + .name = "Rewind audio", + .desc = "Play or mute audio when rewinding.", + .default_value = MINARCH_DEFAULT_REWIND_AUDIO ? 1 : 0, + .value = MINARCH_DEFAULT_REWIND_AUDIO ? 1 : 0, + .count = 2, + .values = onoff_labels, + .labels = onoff_labels, + }, [FE_OPT_COUNT] = {NULL} } }, @@ -1953,6 +2901,8 @@ static struct Config { [SHORTCUT_CYCLE_EFFECT] = {"Cycle Effect", -1, BTN_ID_NONE, 0}, [SHORTCUT_TOGGLE_FF] = {"Toggle FF", -1, BTN_ID_NONE, 0}, [SHORTCUT_HOLD_FF] = {"Hold FF", -1, BTN_ID_NONE, 0}, + [SHORTCUT_TOGGLE_REWIND] = {"Toggle Rewind", -1, BTN_ID_NONE, 0}, + [SHORTCUT_HOLD_REWIND] = {"Hold Rewind", -1, BTN_ID_NONE, 0}, [SHORTCUT_GAMESWITCHER] = {"Game Switcher", -1, BTN_ID_NONE, 0}, [SHORTCUT_SCREENSHOT] = {"Screenshot", -1, BTN_ID_NONE, 0}, // Trimui only @@ -2099,9 +3049,59 @@ static void Config_syncFrontend(char* key, int value) { ff_audio = value; i = FE_OPT_FF_AUDIO; } + else if (exactMatch(key,config.frontend.options[FE_OPT_REWIND_ENABLE].key)) { + i = FE_OPT_REWIND_ENABLE; + } + else if (exactMatch(key,config.frontend.options[FE_OPT_REWIND_BUFFER].key)) { + i = FE_OPT_REWIND_BUFFER; + } + else if (exactMatch(key,config.frontend.options[FE_OPT_REWIND_GRANULARITY].key)) { + i = FE_OPT_REWIND_GRANULARITY; + } + else if (exactMatch(key,config.frontend.options[FE_OPT_REWIND_AUDIO].key)) { + i = FE_OPT_REWIND_AUDIO; + } + else if (exactMatch(key,config.frontend.options[FE_OPT_REWIND_SKIP_COMPRESSION].key)) { + i = FE_OPT_REWIND_SKIP_COMPRESSION; + } + else if (exactMatch(key,config.frontend.options[FE_OPT_REWIND_COMPRESSION_ACCEL].key)) { + i = FE_OPT_REWIND_COMPRESSION_ACCEL; + } if (i==-1) return; Option* option = &config.frontend.options[i]; option->value = value; + if (i==FE_OPT_REWIND_ENABLE || i==FE_OPT_REWIND_BUFFER || i==FE_OPT_REWIND_GRANULARITY || i==FE_OPT_REWIND_AUDIO || i==FE_OPT_REWIND_SKIP_COMPRESSION || i==FE_OPT_REWIND_COMPRESSION_ACCEL) { + const char* sval = option->values && option->values[value] ? option->values[value] : "0"; + int parsed = 0; + if (i==FE_OPT_REWIND_ENABLE || i==FE_OPT_REWIND_AUDIO || i==FE_OPT_REWIND_SKIP_COMPRESSION) { + // use option index (Off/On) + parsed = value; + } + else { + parsed = strtol(sval, NULL, 10); + } + switch (i) { + case FE_OPT_REWIND_ENABLE: rewind_cfg_enable = parsed; break; + case FE_OPT_REWIND_BUFFER: rewind_cfg_buffer_mb = parsed; break; + case FE_OPT_REWIND_GRANULARITY: rewind_cfg_granularity = parsed; break; + case FE_OPT_REWIND_AUDIO: rewind_cfg_audio = parsed; break; + case FE_OPT_REWIND_SKIP_COMPRESSION: rewind_cfg_skip_compress = parsed; break; + case FE_OPT_REWIND_COMPRESSION_ACCEL: rewind_cfg_lz4_acceleration = parsed; break; + } + // Only call Rewind_init if core is initialized; early config reads happen before + // the core is ready and will be followed by an explicit Rewind_init later + if (core.initialized) { + Rewind_init(core.serialize_size ? core.serialize_size() : 0); + } + if (i==FE_OPT_REWIND_ENABLE) { + // ensure runtime toggles don't linger when enabling/disabling feature + rewind_toggle = 0; + rewind_pressed = 0; + Rewind_sync_encode_state(); + rewinding = 0; + ff_paused_by_rewind_hold = 0; + } + } } char** list_files_in_folder(const char* folderPath, int* fileCount, const char* extensionFilter) { @@ -3145,8 +4145,12 @@ static void Menu_saveState(void); static void Menu_loadState(void); static int setFastForward(int enable) { - fast_forward = enable; - return enable; + int val = enable ? 1 : 0; + if (fast_forward != val) { + LOG_info("FF state -> %i\n", val); + } + fast_forward = val; + return val; } static uint32_t buttons = 0; // RETRO_DEVICE_ID_JOYPAD_* buttons @@ -3173,7 +4177,7 @@ static void input_poll_callback(void) { GFX_clear(screen); } - + if (PAD_justPressed(BTN_POWER)) { } @@ -3182,6 +4186,7 @@ static void input_poll_callback(void) { } static int toggled_ff_on = 0; // this logic only works because TOGGLE_FF is before HOLD_FF in the menu... + rewind_pressed = 0; for (int i=0; ilocal; @@ -3190,6 +4195,15 @@ static void input_poll_callback(void) { if (i==SHORTCUT_TOGGLE_FF) { if (PAD_justPressed(btn)) { toggled_ff_on = setFastForward(!fast_forward); + ff_toggled = toggled_ff_on; + ff_hold_active = 0; + if (ff_toggled && rewind_toggle) { + // last toggle wins: disable rewind toggle when FF toggle is enabled + rewind_toggle = 0; + rewind_pressed = 0; + Rewind_sync_encode_state(); + rewinding = 0; + } if (mapping->mod) ignore_menu = 1; break; } @@ -3202,9 +4216,48 @@ static void input_poll_callback(void) { // don't allow turn off fast_forward with a release of the hold button // if it was initially turned on with the toggle button if (PAD_justPressed(btn) || (!toggled_ff_on && PAD_justReleased(btn))) { - fast_forward = setFastForward(PAD_isPressed(btn)); + int pressed = PAD_isPressed(btn); + fast_forward = setFastForward(pressed); + ff_hold_active = pressed ? 1 : 0; if (mapping->mod) ignore_menu = 1; // very unlikely but just in case } + if (PAD_justReleased(btn) && toggled_ff_on) { + ff_hold_active = 0; + } + } + else if (i==SHORTCUT_HOLD_REWIND) { + rewind_pressed = PAD_isPressed(btn) ? 1 : 0; + if (rewind_pressed != last_rewind_pressed) { + LOG_info("Rewind hotkey %s\n", rewind_pressed ? "pressed" : "released"); + last_rewind_pressed = rewind_pressed; + } + if (rewind_pressed && ff_toggled && !ff_paused_by_rewind_hold) { + ff_paused_by_rewind_hold = 1; + fast_forward = setFastForward(0); + } + else if (!rewind_pressed && ff_paused_by_rewind_hold) { + ff_paused_by_rewind_hold = 0; + if (ff_toggled) fast_forward = setFastForward(1); + } + if (mapping->mod && rewind_pressed) ignore_menu = 1; + } + else if (i==SHORTCUT_TOGGLE_REWIND) { + if (PAD_justPressed(btn)) { + rewind_toggle = !rewind_toggle; + LOG_info("Rewind toggle %s\n", rewind_toggle ? "on" : "off"); + if (rewind_toggle && ff_toggled) { + // disable fast forward toggle when rewinding is toggled on + ff_toggled = 0; + fast_forward = setFastForward(0); + ff_paused_by_rewind_hold = 0; + } + if (mapping->mod) ignore_menu = 1; + break; + } + else if (PAD_justReleased(btn)) { + if (mapping->mod) ignore_menu = 1; + break; + } } // Trimui only else if (PLAT_canTurbo() && i>=SHORTCUT_TOGGLE_TURBO_A && i<=SHORTCUT_TOGGLE_TURBO_R2) { @@ -4760,6 +5813,7 @@ static void video_refresh_callback(const void* data, unsigned width, unsigned he /////////////////////////////// static void audio_sample_callback(int16_t left, int16_t right) { + if (rewinding && !rewind_ctx.audio) return; if (!fast_forward || ff_audio) { if (use_core_fps || fast_forward) { SND_batchSamples_fixed_rate(&(const SND_Frame){left,right}, 1); @@ -4770,6 +5824,7 @@ static void audio_sample_callback(int16_t left, int16_t right) { } } static size_t audio_sample_batch_callback(const int16_t *data, size_t frames) { + if (rewinding && !rewind_ctx.audio) return frames; if (!fast_forward || ff_audio) { if (use_core_fps || fast_forward) { return SND_batchSamples_fixed_rate((const SND_Frame*)data, frames); @@ -4920,6 +5975,7 @@ void Core_load(void) { } void Core_reset(void) { core.reset(); + Rewind_on_state_change(); } void Core_unload(void) { // Disabling this is a dumb hack for bluetooth, we should really be using @@ -6533,25 +7589,26 @@ static void Menu_saveState(void) { } static void Menu_loadState(void) { Menu_updateState(); - + if (menu.save_exists) { if (menu.total_discs) { char slot_disc_name[256]; getFile(menu.txt_path, slot_disc_name, 256); - + char slot_disc_path[256]; if (slot_disc_name[0]=='/') strcpy(slot_disc_path, slot_disc_name); else sprintf(slot_disc_path, "%s%s", menu.base_path, slot_disc_name); - + char* disc_path = menu.disc_paths[menu.disc]; if (!exactMatch(slot_disc_path, disc_path)) { Game_changeDisc(slot_disc_path); } } - + state_slot = menu.slot; putInt(menu.slot_path, menu.slot); State_read(); + Rewind_on_state_change(); } } @@ -7151,17 +8208,64 @@ int main(int argc , char* argv[]) { initShaders(); Config_readOptions(); applyShaderSettings(); + Rewind_init(core.serialize_size ? core.serialize_size() : 0); + if (core.serialize_size) Rewind_on_state_change(); // release config when all is loaded Config_free(); LOG_info("total startup time %ims\n\n",SDL_GetTicks()); while (!quit) { GFX_startFrame(); - - core.run(); + + // if rewind is toggled, fast-forward toggle must stay off; fast-forward hold pauses rewind + int do_rewind = (rewind_pressed || rewind_toggle) && !(rewind_toggle && ff_hold_active); + if (do_rewind) { + // Rewind_step_back returns: 0=buffer empty, 1=stepped back, 2=waiting for cadence + int rewind_result = Rewind_step_back(); + rewinding = (rewind_result != 0); + if (rewind_result == 1) { + // Actually stepped back - run one frame to render the restored state + fast_forward = 0; + core.run(); + } + else if (rewind_result == 2) { + // Waiting for cadence - don't run core, just re-render current frame + fast_forward = 0; + // Skip core.run() entirely to avoid advancing the game + } + else { + // Buffer empty: auto untoggle rewind, resume FF if it was paused for a hold + if (rewind_toggle) rewind_toggle = 0; + if (ff_paused_by_rewind_hold && ff_toggled) { + ff_paused_by_rewind_hold = 0; + fast_forward = setFastForward(1); + } + core.run(); + Rewind_push(0); + } + } + else { + Rewind_sync_encode_state(); + rewinding = 0; + if (ff_paused_by_rewind_hold && !rewind_pressed) { + // resume fast forward after hold rewind ends + if (ff_toggled) fast_forward = setFastForward(1); + ff_paused_by_rewind_hold = 0; + } + + int ff_runs = 1; + if (fast_forward) { + // when "None" is selected, assume a modest 2x instead of unbounded spam + ff_runs = max_ff_speed ? max_ff_speed + 1 : 2; + } + + for (int ff_step = 0; ff_step < ff_runs; ff_step++) { + core.run(); + Rewind_push(0); + } + } limitFF(); trackFPS(); - if (has_pending_opt_change) { has_pending_opt_change = 0; @@ -7173,7 +8277,6 @@ int main(int argc , char* argv[]) { chooseSyncRef(); } - if (show_menu) { PWR_updateFrequency(PWR_UPDATE_FREQ,1); Menu_loop(); @@ -7211,10 +8314,11 @@ int main(int argc , char* argv[]) { Menu_quit(); QuitSettings(); - + finish: Game_close(); + Rewind_free(); Core_unload(); Core_quit(); Core_close(); diff --git a/workspace/tg5040/poweroff_next/makefile b/workspace/tg5040/poweroff_next/makefile index e57bf234c..52d3e63ac 100644 --- a/workspace/tg5040/poweroff_next/makefile +++ b/workspace/tg5040/poweroff_next/makefile @@ -24,7 +24,7 @@ SOURCE = $(TARGET).c ../../all/common/utils.c ../../all/common/config.c CC = $(CROSS_COMPILE)gcc # Override CFLAGS and LDFLAGS to not include SDL2 dependencies -CFLAGS = $(ARCH) -g -std=gnu11 -Wall -Wextra +CFLAGS = $(OPT) -g -std=gnu11 -Wall -Wextra CFLAGS += $(INCDIR) -DPLATFORM=\"$(PLATFORM)\" LDFLAGS = -lpthread -ldl