@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 +1 -1
- package/src/constants.ts +3 -0
- package/src/extension.ts +7 -4
- package/src/game-loop.ts +71 -0
- package/src/pi-boy-component.ts +30 -21
- package/src/render/kitty.ts +9 -2
- package/src/runtime.ts +10 -0
- package/types/shims.d.ts +1 -0
package/package.json
CHANGED
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),
|
|
546
|
-
|
|
548
|
+
new PiBoyComponent(tui, romResult.rom, (result) => done(result), launchOptions, resumeState ?? undefined),
|
|
549
|
+
launchOptions.forceOverlay
|
|
547
550
|
? {
|
|
548
551
|
overlay: true,
|
|
549
552
|
overlayOptions: {
|
package/src/game-loop.ts
ADDED
|
@@ -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
|
+
}
|
package/src/pi-boy-component.ts
CHANGED
|
@@ -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
|
|
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
|
|
92
|
-
this.renderAccumulatorMs
|
|
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
|
-
|
|
95
|
-
|
|
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 (
|
|
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 {
|
package/src/render/kitty.ts
CHANGED
|
@@ -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.
|
|
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>;
|