@patleeman/pi-boy 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patleeman/pi-boy",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "pi-boy: embedded Game Boy emulator inside pi",
6
6
  "license": "MIT",
package/src/constants.ts CHANGED
@@ -9,6 +9,9 @@ export const GB_HEIGHT = 144;
9
9
 
10
10
  export const EMULATOR_FRAME_MS = 1000 / 60;
11
11
  export const MAX_CATCHUP_STEPS = 64;
12
+ export const MAX_TIMER_DELTA_MS = 250;
13
+ // Treat long timer gaps like a temporary pause (e.g. app backgrounded / terminal stalled).
14
+ export const BACKGROUND_STALL_MS = 200;
12
15
 
13
16
  export const IMAGE_RENDER_FPS = 30;
14
17
  export const ANSI_RENDER_FPS = 30;
package/src/extension.ts CHANGED
@@ -6,7 +6,7 @@ import { type PiBoyConfig, loadConfig, pickRomPath, resolveInputPath, resolveRom
6
6
  import { runSelfTest } from "./gameboy.js";
7
7
  import { notify } from "./notify.js";
8
8
  import { PiBoyComponent, type PiBoyExitResult } from "./pi-boy-component.js";
9
- import { type RuntimeOptions, loadRuntimeOptions } from "./runtime.js";
9
+ import { type RuntimeOptions, getLaunchRuntimeOptions, loadRuntimeOptions } from "./runtime.js";
10
10
  import { getRomPathCompletions, isRomFilePath, listRomFiles } from "./roms.js";
11
11
  import {
12
12
  getSaveStatePath,
@@ -530,20 +530,23 @@ export default function (pi: ExtensionAPI) {
530
530
 
531
531
  const config = loadConfig();
532
532
  const runtimeOptions = getEffectiveRuntimeOptions(config);
533
+ const launchOptions = getLaunchRuntimeOptions(runtimeOptions, ctx.isIdle());
533
534
  const resumeState = loadSaveState(romResult.romPath);
534
535
  if (resumeState) {
535
536
  notify(ctx, "pi-boy: found suspended state, attempting resume.", "info");
536
537
  }
537
538
 
538
- if (!runtimeOptions.forceOverlay) {
539
+ if (!runtimeOptions.forceOverlay && launchOptions.forceOverlay) {
540
+ notify(ctx, "pi-boy: agent is busy, using overlay mode for stable rendering.", "info");
541
+ } else if (!launchOptions.forceOverlay) {
539
542
  notify(ctx, "pi-boy: inline mode enabled.", "info");
540
543
  }
541
544
 
542
545
  try {
543
546
  const exitResult = await ctx.ui.custom<PiBoyExitResult | undefined>(
544
547
  (tui, _theme, _keybindings, done) =>
545
- new PiBoyComponent(tui, romResult.rom, (result) => done(result), runtimeOptions, resumeState ?? undefined),
546
- runtimeOptions.forceOverlay
548
+ new PiBoyComponent(tui, romResult.rom, (result) => done(result), launchOptions, resumeState ?? undefined),
549
+ launchOptions.forceOverlay
547
550
  ? {
548
551
  overlay: true,
549
552
  overlayOptions: {
@@ -0,0 +1,71 @@
1
+ import {
2
+ ANSI_RENDER_FPS,
3
+ BACKGROUND_STALL_MS,
4
+ EMULATOR_FRAME_MS,
5
+ IMAGE_RENDER_FPS,
6
+ MAX_CATCHUP_STEPS,
7
+ MAX_TIMER_DELTA_MS,
8
+ } from "./constants.js";
9
+ import type { RenderBackend } from "./runtime.js";
10
+
11
+ export interface GameLoopState {
12
+ frameAccumulatorMs: number;
13
+ renderAccumulatorMs: number;
14
+ pausedForStall: boolean;
15
+ }
16
+
17
+ export interface GameLoopAdvanceResult {
18
+ frameAccumulatorMs: number;
19
+ renderAccumulatorMs: number;
20
+ emulatorSteps: number;
21
+ shouldRender: boolean;
22
+ pausedForStall: boolean;
23
+ enteredStall: boolean;
24
+ resumedFromStall: boolean;
25
+ }
26
+
27
+ export function advanceGameLoop(state: GameLoopState, rawDeltaMs: number, renderBackend: RenderBackend): GameLoopAdvanceResult {
28
+ const deltaMs = Number.isFinite(rawDeltaMs) && rawDeltaMs > 0 ? rawDeltaMs : 0;
29
+ if (deltaMs >= BACKGROUND_STALL_MS) {
30
+ // Once timers stretch this far, smooth catch-up usually looks worse than
31
+ // pausing the loop and forcing a clean redraw when normal cadence returns.
32
+ return {
33
+ frameAccumulatorMs: 0,
34
+ renderAccumulatorMs: 0,
35
+ emulatorSteps: 0,
36
+ shouldRender: false,
37
+ pausedForStall: true,
38
+ enteredStall: !state.pausedForStall,
39
+ resumedFromStall: false,
40
+ };
41
+ }
42
+
43
+ let frameAccumulatorMs = state.frameAccumulatorMs + Math.min(MAX_TIMER_DELTA_MS, deltaMs);
44
+ let renderAccumulatorMs = state.renderAccumulatorMs + Math.min(MAX_TIMER_DELTA_MS, deltaMs);
45
+ let emulatorSteps = 0;
46
+
47
+ while (frameAccumulatorMs >= EMULATOR_FRAME_MS && emulatorSteps < MAX_CATCHUP_STEPS) {
48
+ frameAccumulatorMs -= EMULATOR_FRAME_MS;
49
+ emulatorSteps++;
50
+ }
51
+
52
+ if (emulatorSteps === MAX_CATCHUP_STEPS) {
53
+ frameAccumulatorMs = 0;
54
+ }
55
+
56
+ const renderFrameMs = 1000 / (renderBackend === "ansi" ? ANSI_RENDER_FPS : IMAGE_RENDER_FPS);
57
+ const shouldRender = renderAccumulatorMs >= renderFrameMs;
58
+ if (shouldRender) {
59
+ renderAccumulatorMs %= renderFrameMs;
60
+ }
61
+
62
+ return {
63
+ frameAccumulatorMs,
64
+ renderAccumulatorMs,
65
+ emulatorSteps,
66
+ shouldRender,
67
+ pausedForStall: false,
68
+ enteredStall: false,
69
+ resumedFromStall: state.pausedForStall,
70
+ };
71
+ }
@@ -2,18 +2,15 @@ import { isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth, type TUI
2
2
  import {
3
3
  ANSI_MAX_COLUMNS,
4
4
  ANSI_MAX_ROWS,
5
- ANSI_RENDER_FPS,
6
- EMULATOR_FRAME_MS,
7
5
  GB_HEIGHT,
8
6
  GB_WIDTH,
9
- IMAGE_RENDER_FPS,
10
- MAX_CATCHUP_STEPS,
11
7
  TAP_FRAMES_ACTION,
12
8
  TAP_FRAMES_DIRECTION,
13
9
  VIEWPORT_SAFETY_ROWS,
14
10
  } from "./constants.js";
15
11
  import { AudioOutput } from "./audio-output.js";
16
12
  import { KEYMAP, type GameboyInstance, type GameboyKey, createGameboy } from "./gameboy.js";
13
+ import { advanceGameLoop } from "./game-loop.js";
17
14
  import { renderAnsiFrame } from "./render/ansi.js";
18
15
  import { KittyRenderer } from "./render/kitty.js";
19
16
  import { getRenderBackend, supportsHeldKeys } from "./terminal.js";
@@ -47,6 +44,7 @@ export class PiBoyComponent {
47
44
  private version = 0;
48
45
  private frameAccumulatorMs = 0;
49
46
  private renderAccumulatorMs = 0;
47
+ private pausedForStall = false;
50
48
  private lastTickMs = performance.now();
51
49
  private previousClearOnShrink: boolean | null = null;
52
50
  private cachedWidth = 0;
@@ -86,39 +84,50 @@ export class PiBoyComponent {
86
84
  if (this.coreError) return;
87
85
 
88
86
  const now = performance.now();
89
- const deltaMs = Math.min(250, now - this.lastTickMs);
87
+ const renderBackend = getRenderBackend(this.options);
88
+ const loopState = advanceGameLoop(
89
+ {
90
+ frameAccumulatorMs: this.frameAccumulatorMs,
91
+ renderAccumulatorMs: this.renderAccumulatorMs,
92
+ pausedForStall: this.pausedForStall,
93
+ },
94
+ now - this.lastTickMs,
95
+ renderBackend,
96
+ );
90
97
  this.lastTickMs = now;
91
- this.frameAccumulatorMs += deltaMs;
92
- this.renderAccumulatorMs += deltaMs;
98
+ this.frameAccumulatorMs = loopState.frameAccumulatorMs;
99
+ this.renderAccumulatorMs = loopState.renderAccumulatorMs;
100
+ this.pausedForStall = loopState.pausedForStall;
101
+
102
+ if (loopState.enteredStall) {
103
+ this.kittyRenderer.reset();
104
+ this.invalidate();
105
+ return;
106
+ }
93
107
 
94
- let stepsRun = 0;
95
- while (this.frameAccumulatorMs >= EMULATOR_FRAME_MS && stepsRun < MAX_CATCHUP_STEPS) {
108
+ if (loopState.resumedFromStall) {
109
+ this.invalidate();
110
+ this.requestRender(true);
111
+ }
112
+
113
+ for (let step = 0; step < loopState.emulatorSteps; step++) {
96
114
  try {
97
115
  this.runEmulatorStep();
98
116
  } catch (error) {
99
117
  this.failCore(error);
100
118
  return;
101
119
  }
102
- this.frameAccumulatorMs -= EMULATOR_FRAME_MS;
103
- stepsRun++;
104
120
  }
105
121
 
106
- if (stepsRun === MAX_CATCHUP_STEPS) {
107
- this.frameAccumulatorMs = 0;
108
- }
109
-
110
- const renderBackend = getRenderBackend(this.options);
111
- const renderFrameMs = 1000 / (renderBackend === "ansi" ? ANSI_RENDER_FPS : IMAGE_RENDER_FPS);
112
- if (this.renderAccumulatorMs >= renderFrameMs) {
113
- this.renderAccumulatorMs %= renderFrameMs;
122
+ if (loopState.shouldRender && !loopState.resumedFromStall) {
114
123
  this.requestRender();
115
124
  }
116
125
  }, 8);
117
126
  }
118
127
 
119
- private requestRender(): void {
128
+ private requestRender(force = false): void {
120
129
  this.version++;
121
- this.tui.requestRender();
130
+ this.tui.requestRender(force);
122
131
  }
123
132
 
124
133
  private failCore(error: unknown): void {
@@ -24,6 +24,14 @@ export class KittyRenderer {
24
24
  this.cachedVersion = -1;
25
25
  }
26
26
 
27
+ reset(): void {
28
+ this.imageId = undefined;
29
+ this.cachedVersion = -1;
30
+ this.cachedScale = -1;
31
+ this.cachedBase64 = null;
32
+ this.previousRows = 0;
33
+ }
34
+
27
35
  private computeIntegerScale(targetCols: number, targetRows: number): number {
28
36
  const cell = getCellDimensions();
29
37
  const cellWidthPx = Math.max(1, cell.widthPx);
@@ -147,8 +155,7 @@ export class KittyRenderer {
147
155
  } catch {
148
156
  // ignore
149
157
  }
150
- this.imageId = undefined;
151
158
  }
152
- this.previousRows = 0;
159
+ this.reset();
153
160
  }
154
161
  }
package/src/runtime.ts CHANGED
@@ -34,3 +34,13 @@ export function loadRuntimeOptions(env: NodeJS.ProcessEnv = process.env): Runtim
34
34
  romPathFromEnv: env.PI_BOY_ROM_PATH,
35
35
  };
36
36
  }
37
+
38
+ export function getLaunchRuntimeOptions(options: RuntimeOptions, agentIsIdle: boolean): RuntimeOptions {
39
+ if (agentIsIdle || options.forceOverlay) {
40
+ return options;
41
+ }
42
+ return {
43
+ ...options,
44
+ forceOverlay: true,
45
+ };
46
+ }
package/types/shims.d.ts CHANGED
@@ -9,6 +9,7 @@ declare module "@mariozechner/pi-coding-agent" {
9
9
  export interface CommandContext {
10
10
  hasUI: boolean;
11
11
  cwd: string;
12
+ isIdle: () => boolean;
12
13
  ui: {
13
14
  notify: (message: string, level: NotifyLevel) => void;
14
15
  select: (title: string, options: string[], opts?: DialogOptions) => Promise<string | undefined>;