Skip to content

Commit ad9ae3d

Browse files
trekawekclaude
andcommitted
Add hold-to-rewind (Backspace) to the single-player emulator
RewindManager keeps a rolling buffer of Gameboy mementos (one every 6 frames, 300 entries - about 30 seconds). Holding Backspace posts Controller.RewindEvent(true); BasicController then restores one recorded state per rendered frame and emulates a single frame from it, so the game plays backwards at 6x speed with live video and audio. Releasing the key resumes forward play from wherever the rewind stopped. History is cleared on ROM load, reset and snapshot restore. Save-state slots already existed in the Game menu; together they complete the quality-of-life pair. Verified headless: 4 s of Tetris, 1 s of rewind restores ~32 recorded states and keeps rendering; controller and swing suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J1wLWscyUGS7CFwc3CjJR8
1 parent d5877ec commit ad9ae3d

4 files changed

Lines changed: 84 additions & 1 deletion

File tree

controller/src/main/java/eu/rekawek/coffeegb/controller/BasicController.kt

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ class BasicController(
3131

3232
private var isPaused = false
3333

34+
private var isRewinding = false
35+
36+
private val rewindManager = RewindManager()
37+
3438
private val patches = mutableListOf<Patch>()
3539

3640
private val thread = Thread {
@@ -44,6 +48,7 @@ class BasicController(
4448
eventQueue.register<Controller.LoadRomEvent> {
4549
stop()
4650
patches.clear()
51+
rewindManager.clear()
4752
val config = Controller.createGameboyConfig(properties, Rom(it.rom))
4853
session = createSession(config)
4954
if (it.memento != null) {
@@ -55,6 +60,7 @@ class BasicController(
5560
eventQueue.register<Controller.SaveSnapshotEvent> { e -> saveSnapshot(e.slot) }
5661
eventQueue.register<Controller.PauseEmulationEvent> { isPaused = true }
5762
eventQueue.register<Controller.ResumeEmulationEvent> { isPaused = false }
63+
eventQueue.register<Controller.RewindEvent> { isRewinding = it.active }
5864
eventQueue.register<Controller.ResetEmulationEvent> { reset() }
5965
eventQueue.register<Controller.StopEmulationEvent> { stop() }
6066
eventQueue.register<Controller.UpdatedSystemMappingEvent> {
@@ -74,12 +80,21 @@ class BasicController(
7480
private fun runFrame() {
7581
eventQueue.dispatch()
7682

83+
// rewinding restores one recorded state and then emulates a single frame from it,
84+
// so the display and audio play backwards at RewindManager.RECORD_INTERVAL speed
85+
val rewound = isRewinding && session?.gameboy?.let { rewindManager.rewindOneStep(it) } == true
86+
87+
var emulated = false
7788
repeat(TICKS_PER_FRAME) {
78-
if (!isPaused) {
89+
if (rewound || (!isPaused && !isRewinding)) {
7990
session?.gameboy?.tick()
91+
emulated = true
8092
}
8193
timingTicker.run()
8294
}
95+
if (emulated && !rewound) {
96+
session?.gameboy?.let { rewindManager.record(it) }
97+
}
8398
}
8499

85100
private fun createSession(config: Gameboy.GameboyConfiguration) =
@@ -109,6 +124,7 @@ class BasicController(
109124
val session = session ?: return
110125
val config = session.config
111126
stop()
127+
rewindManager.clear()
112128
this.session = createSession(config)
113129
start()
114130
}
@@ -119,6 +135,7 @@ class BasicController(
119135

120136
private fun loadSnapshot(slot: Int) {
121137
session?.gameboy?.let { snapshotManager?.loadSnapshot(slot, it) }
138+
rewindManager.clear()
122139
}
123140

124141
override fun snapshotAvailable(slot: Int): Boolean {

controller/src/main/java/eu/rekawek/coffeegb/controller/Controller.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ interface Controller : AutoCloseable {
4141

4242
data class GameboyTypeEvent(val gameboyType: GameboyType) : Event
4343

44+
/** Posted while the rewind key is held; the emulation plays backwards while active. */
45+
data class RewindEvent(val active: Boolean) : Event
46+
4447
data class ControllerState(val memento: Memento<Gameboy>, val rom: Rom)
4548

4649
companion object {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package eu.rekawek.coffeegb.controller
2+
3+
import eu.rekawek.coffeegb.core.Gameboy
4+
import eu.rekawek.coffeegb.core.memento.Memento
5+
6+
/**
7+
* Rolling history of emulation states for the rewind feature. A state is recorded every
8+
* [RECORD_INTERVAL] frames and the buffer holds [CAPACITY] entries, giving about
9+
* CAPACITY * RECORD_INTERVAL / 60 seconds of history. Rewinding restores one recorded
10+
* state per rendered frame, so it plays backwards at RECORD_INTERVAL times the game speed.
11+
*/
12+
class RewindManager {
13+
14+
private val states = ArrayDeque<Memento<Gameboy>>()
15+
16+
private var frameCounter = 0
17+
18+
fun record(gameboy: Gameboy) {
19+
if (frameCounter++ % RECORD_INTERVAL != 0) {
20+
return
21+
}
22+
if (states.size == CAPACITY) {
23+
states.removeFirst()
24+
}
25+
states.addLast(gameboy.saveToMemento())
26+
}
27+
28+
/** Restores the most recent recorded state; returns false when the history is empty. */
29+
fun rewindOneStep(gameboy: Gameboy): Boolean {
30+
val state = states.removeLastOrNull() ?: return false
31+
gameboy.restoreFromMemento(state)
32+
return true
33+
}
34+
35+
fun clear() {
36+
states.clear()
37+
frameCounter = 0
38+
}
39+
40+
companion object {
41+
const val RECORD_INTERVAL = 6
42+
43+
const val CAPACITY = 300
44+
}
45+
}

swing/src/main/java/eu/rekawek/coffeegb/swing/io/SwingJoypad.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package eu.rekawek.coffeegb.swing.io;
22

3+
import eu.rekawek.coffeegb.controller.Controller;
34
import eu.rekawek.coffeegb.core.joypad.Button;
45
import eu.rekawek.coffeegb.core.joypad.ButtonPressEvent;
56
import eu.rekawek.coffeegb.core.joypad.ButtonReleaseEvent;
@@ -11,10 +12,14 @@
1112

1213
public class SwingJoypad implements KeyListener {
1314

15+
private static final int REWIND_KEY = KeyEvent.VK_BACK_SPACE;
16+
1417
private final Map<Integer, Button> mapping;
1518

1619
private final EventBus eventBus;
1720

21+
private boolean rewindActive;
22+
1823
public SwingJoypad(Map<Integer, Button> mapping, EventBus eventBus) {
1924
this.mapping = mapping;
2025
this.eventBus = eventBus;
@@ -26,6 +31,14 @@ public void keyTyped(KeyEvent e) {
2631

2732
@Override
2833
public void keyPressed(KeyEvent e) {
34+
if (e.getKeyCode() == REWIND_KEY) {
35+
// holding the key auto-repeats; post only on the state change
36+
if (!rewindActive) {
37+
rewindActive = true;
38+
eventBus.post(new Controller.RewindEvent(true));
39+
}
40+
return;
41+
}
2942
Button b = getButton(e);
3043
if (b != null) {
3144
eventBus.post(new ButtonPressEvent(b));
@@ -34,6 +47,11 @@ public void keyPressed(KeyEvent e) {
3447

3548
@Override
3649
public void keyReleased(KeyEvent e) {
50+
if (e.getKeyCode() == REWIND_KEY) {
51+
rewindActive = false;
52+
eventBus.post(new Controller.RewindEvent(false));
53+
return;
54+
}
3755
Button b = getButton(e);
3856
if (b != null) {
3957
eventBus.post(new ButtonReleaseEvent(b));

0 commit comments

Comments
 (0)