aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorgbc dev <gbc@localhost>2026-07-04 23:07:50 +0200
committergbc dev <gbc@localhost>2026-07-04 23:07:50 +0200
commitf4ba890eb4b98e1a65a3d9fc8ba8f62dda66895b (patch)
treecb1cccb2dfd0f1b59e59e8e86454849179c88cb2
parentinput: external control channel via FIFO (diff)
downloadsl0pboy-f4ba890eb4b98e1a65a3d9fc8ba8f62dda66895b.tar.gz
sl0pboy-f4ba890eb4b98e1a65a3d9fc8ba8f62dda66895b.tar.xz
sl0pboy-f4ba890eb4b98e1a65a3d9fc8ba8f62dda66895b.zip
speed control: frame skipping to decouple terminal draw from emulation
Emulation always runs at native rate (correct game speed); --frameskip N draws only 1 of every N+1 frames so a slow terminal no longer bottlenecks the loop. Adds --fps cap, --uncapped/--turbo, live [ ] and f keys, HUD readout, and an exit summary of emulated-vs-drawn fps. Replaces the confusing --render-fps.
-rw-r--r--README.md26
-rw-r--r--src/main.c72
-rw-r--r--src/render.c29
-rw-r--r--src/render.h6
4 files changed, 120 insertions, 13 deletions
diff --git a/README.md b/README.md
index 620f4c9..0412f4c 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,30 @@ make
Requires a truecolor-capable terminal (`COLORTERM=truecolor`) at least
160 columns wide.
+### Speed & frame skipping
+
+The emulator always advances every Game Boy frame at the correct rate, so
+games run at their intended speed. Because writing a frame to the terminal is
+by far the most expensive step, drawing can be *decoupled* from emulation via
+frame skipping — the machine keeps running in real time while the terminal is
+refreshed less often:
+
+```sh
+./build/gbc rom.gb --frameskip 2 # emulate 60 fps, draw every 3rd frame (20 fps)
+./build/gbc rom.gb --fps 120 # run the game at 2x speed
+./build/gbc rom.gb --uncapped # run as fast as possible (turbo)
+```
+
+| Flag | Meaning |
+|-------------------|-----------------------------------------------------|
+| `--frameskip N` | draw 1 of every `N+1` emulated frames (default 0) |
+| `--fps N` | emulation speed cap in fps (default 59.73 native) |
+| `--uncapped`/`--turbo` | run emulation as fast as possible |
+
+Frame skip and turbo are also adjustable live with `[` / `]` and `f`; in turbo
+mode terminal redraws are additionally clamped to ~60 Hz. On exit the emulator
+prints how many frames it emulated vs. actually drew.
+
## Controls
| Key | Button |
@@ -34,6 +58,8 @@ Requires a truecolor-capable terminal (`COLORTERM=truecolor`) at least
| `x` / `k` | B |
| enter | Start |
| space | Select |
+| `f` | toggle fast-forward (turbo) |
+| `[` / `]` | decrease / increase frame skip |
| `q` / Ctrl-C | Quit |
Since terminals don't report key-release, buttons are held for a few frames
diff --git a/src/main.c b/src/main.c
index 173ab5b..5ce4fb5 100644
--- a/src/main.c
+++ b/src/main.c
@@ -35,6 +35,8 @@ int main(int argc, char **argv) {
int shot_frames = -1;
const char *shot_path = "shot.ppm";
const char *fifo_path = NULL;
+ double target_fps = 59.73; // emulation speed cap; 0 = uncapped
+ int frameskip = 0; // draw 1 of every (frameskip+1) frames
for (int i = 2; i < argc; i++) {
if (!strcmp(argv[i], "--test")) {
test = true;
@@ -45,8 +47,15 @@ int main(int argc, char **argv) {
} else if (!strcmp(argv[i], "--fifo")) {
fifo_path = (i + 1 < argc && argv[i+1][0] != '-')
? argv[++i] : "/tmp/gbc.fifo";
+ } else if (!strcmp(argv[i], "--fps")) {
+ if (i + 1 < argc) target_fps = atof(argv[++i]);
+ } else if (!strcmp(argv[i], "--frameskip")) {
+ if (i + 1 < argc) frameskip = atoi(argv[++i]);
+ } else if (!strcmp(argv[i], "--uncapped") || !strcmp(argv[i], "--turbo")) {
+ target_fps = 0;
}
}
+ if (frameskip < 0) frameskip = 0;
GB *gb = calloc(1, sizeof(GB));
if (cart_load(&gb->cart, rom) != 0) { free(gb); return 1; }
@@ -83,26 +92,67 @@ int main(int argc, char **argv) {
}
term_init();
- const double frame_time = 1.0 / 59.73;
- double next = now_sec();
+ // The emulator always advances every GB frame at the paced native rate so
+ // games run at correct speed. Frame *skipping* only affects how often we
+ // actually draw to the terminal: rendering is the expensive part, so on a
+ // slow terminal we render 1 of every (frameskip+1) frames while emulation
+ // keeps real-time. In turbo we run uncapped and additionally clamp redraws
+ // to ~60 Hz so the terminal isn't flooded.
+ bool turbo = (target_fps <= 0);
+ double last_render = 0;
+ double start_time = now_sec();
+ double next_emu = start_time;
+ u64 frames = 0, drawn = 0;
+ int since_render = 0;
while (running) {
+ frames++;
if (!input_poll(gb)) break;
- run_frame(gb);
- render_frame(gb);
+ if (input_take_turbo()) turbo = !turbo;
+ frameskip += input_take_frameskip_delta();
+ if (frameskip < 0) frameskip = 0;
+ if (frameskip > 30) frameskip = 30;
+ render_set_hud(frameskip, turbo);
- next += frame_time;
+ run_frame(gb); // always emulate a full frame
+
+ // decide whether to draw this frame
+ bool do_render = (since_render >= frameskip);
double t = now_sec();
- double sleep = next - t;
- if (sleep > 0) {
- struct timespec ts = { (time_t)sleep, (long)((sleep - (time_t)sleep) * 1e9) };
- nanosleep(&ts, NULL);
- } else if (sleep < -0.1) {
- next = t; // fell behind; resync
+ if (do_render && turbo && (t - last_render) < 1.0 / 60.0)
+ do_render = false; // clamp turbo redraws to ~60 Hz
+ if (do_render) {
+ render_frame(gb);
+ last_render = t;
+ since_render = 0;
+ drawn++;
+ } else {
+ since_render++;
+ }
+
+ // pace emulation to native speed (skip sleeping in turbo)
+ double emu_dt = turbo ? 0.0 : 1.0 / target_fps;
+ if (emu_dt > 0.0) {
+ next_emu += emu_dt;
+ double sleep = next_emu - now_sec();
+ if (sleep > 0) {
+ struct timespec ts = { (time_t)sleep,
+ (long)((sleep - (time_t)sleep) * 1e9) };
+ nanosleep(&ts, NULL);
+ } else if (sleep < -0.1) {
+ next_emu = now_sec(); // fell behind; resync
+ }
+ } else {
+ next_emu = now_sec(); // uncapped: keep baseline fresh
}
}
term_restore();
input_close_fifo();
+ double elapsed = now_sec() - start_time;
+ if (elapsed > 0)
+ fprintf(stderr, "emulated %llu frames (%.1f fps), drew %llu (%.1f fps) "
+ "in %.2fs\n", (unsigned long long)frames, frames / elapsed,
+ (unsigned long long)drawn, drawn / elapsed, elapsed);
cart_free(&gb->cart);
free(gb);
return 0;
diff --git a/src/render.c b/src/render.c
index f659da3..df39c99 100644
--- a/src/render.c
+++ b/src/render.c
@@ -51,8 +51,19 @@ static char outbuf[SCREEN_W * (SCREEN_H/2) * 40 + 4096];
static u8 prev_fb[SCREEN_H][SCREEN_W][3];
static bool need_full = true; // force a complete repaint
static volatile sig_atomic_t resized = 0; // set by SIGWINCH
+static int hud_frameskip = 0;
+static bool hud_turbo = false;
void render_force_full(void) { need_full = true; }
+
+// Update the on-screen HUD values; forces a status-line repaint on change.
+void render_set_hud(int frameskip, bool turbo) {
+ if (frameskip != hud_frameskip || turbo != hud_turbo) {
+ hud_frameskip = frameskip;
+ hud_turbo = turbo;
+ need_full = true;
+ }
+}
static void on_winch(int s) { (void)s; resized = 1; }
void render_frame(GB *gb) {
@@ -99,8 +110,9 @@ void render_frame(GB *gb) {
// status line: static text, only (re)draw on a full repaint
if (need_full) {
o += sprintf(o, "\x1b[0m\x1b[%d;1H\x1b[38;2;150;150;150m %.*s "
- "[wasd/arrows] z:A x:B space:sel enter:start q:quit",
- SCREEN_H/2 + 1, 15, gb->cart.title);
+ "skip:%d%s [f]ast [ ][ ] q:quit\x1b[K",
+ SCREEN_H/2 + 1, 12, gb->cart.title,
+ hud_frameskip, hud_turbo ? " TURBO" : "");
}
memcpy(prev_fb, p->fb, sizeof(prev_fb));
@@ -119,8 +131,18 @@ void render_frame(GB *gb) {
// decay[] : momentary presses (keyboard, or FIFO taps), count down per frame
static int decay[8]; // frames remaining for each BTN bit position
static u8 held; // explicitly-held buttons (FIFO)
+static int turbo_toggles; // pending fast-forward toggles (consumed by main)
+static int frameskip_delta;// pending frameskip adjustment (consumed by main)
#define HOLD_FRAMES 4
+bool input_take_turbo(void) {
+ if (turbo_toggles > 0) { turbo_toggles--; return true; }
+ return false;
+}
+int input_take_frameskip_delta(void) {
+ int d = frameskip_delta; frameskip_delta = 0; return d;
+}
+
static int mask_index(int mask) {
for (int i = 0; i < 8; i++) if ((1 << i) == mask) return i;
return -1;
@@ -226,6 +248,9 @@ bool input_poll(GB *gb) {
case 'x': case 'k': tap(BTN_B, HOLD_FRAMES); break;
case '\r': case '\n': tap(BTN_START, HOLD_FRAMES); break;
case ' ': tap(BTN_SELECT, HOLD_FRAMES); break;
+ case 'f': turbo_toggles++; break; // toggle fast-forward
+ case '[': frameskip_delta--; break; // fewer skipped frames
+ case ']': frameskip_delta++; break; // more skipped frames
case 'q': case 3: quit = true; break;
}
}
diff --git a/src/render.h b/src/render.h
index 27da080..3b6c7e4 100644
--- a/src/render.h
+++ b/src/render.h
@@ -7,6 +7,7 @@ void term_init(void);
void term_restore(void);
void render_frame(GB *gb);
void render_force_full(void); // force a complete repaint next frame
+void render_set_hud(int frameskip, bool turbo); // update HUD readout
// Poll keyboard + control FIFO, update gb->buttons. False if quit requested.
bool input_poll(GB *gb);
@@ -15,4 +16,9 @@ bool input_poll(GB *gb);
void input_open_fifo(const char *path);
void input_close_fifo(void);
+// Returns true once for each pending fast-forward (turbo) toggle keypress.
+bool input_take_turbo(void);
+// Net frameskip adjustment requested via '[' / ']' since last call.
+int input_take_frameskip_delta(void);
+
#endif