emoemu 0.1.0

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.
Files changed (213) hide show
  1. package/.claude/settings.local.json +77 -0
  2. package/.node-version +1 -0
  3. package/CLAUDE.md +435 -0
  4. package/README.md +404 -0
  5. package/TODO.md +655 -0
  6. package/dist/index.cjs +25108 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +25085 -0
  9. package/docs/building-libretro-cores-arm-mac.md +237 -0
  10. package/docs/config-file-format.md +488 -0
  11. package/docs/cores-trd.md +425 -0
  12. package/docs/headless-hardware-rendering-trd.md +676 -0
  13. package/docs/libretro-cores-trd.md +997 -0
  14. package/docs/mupen64-software-rendering-trd.md +751 -0
  15. package/docs/n64-support-trd.md +306 -0
  16. package/docs/native-rendering-trd.md +540 -0
  17. package/docs/native-ui-rendering-trd.md +1195 -0
  18. package/docs/netplay-trd.md +665 -0
  19. package/docs/retroarch-netplay-docs.md +277 -0
  20. package/docs/save-state-format.md +666 -0
  21. package/eslint.config.js +111 -0
  22. package/icon/icon.png +0 -0
  23. package/icon/icon.pxd +0 -0
  24. package/package.json +63 -0
  25. package/pnpm-workspace.yaml +10 -0
  26. package/src/Emulator/consts.ts +14 -0
  27. package/src/Emulator/index.ts +2496 -0
  28. package/src/Emulator/saveState/index.ts +155 -0
  29. package/src/Emulator/screenshot/index.ts +160 -0
  30. package/src/Emulator/terminalDimensions/index.ts +79 -0
  31. package/src/Emulator/types.ts +83 -0
  32. package/src/cli/commands/consts.ts +10 -0
  33. package/src/cli/commands/index.ts +462 -0
  34. package/src/cli/parseArgs/consts.ts +17 -0
  35. package/src/cli/parseArgs/index.ts +457 -0
  36. package/src/cli/parseArgs/types.ts +61 -0
  37. package/src/cli/runEmulator/index.ts +406 -0
  38. package/src/cli/runEmulator/types.ts +7 -0
  39. package/src/consts.ts +19 -0
  40. package/src/core/button/consts.ts +35 -0
  41. package/src/core/button/index.ts +123 -0
  42. package/src/core/core.ts +300 -0
  43. package/src/core/index.ts +19 -0
  44. package/src/cores/libretro/api/index.ts +334 -0
  45. package/src/cores/libretro/api/types.ts +148 -0
  46. package/src/cores/libretro/callbacks/consts.ts +41 -0
  47. package/src/cores/libretro/callbacks/index.ts +456 -0
  48. package/src/cores/libretro/consts.ts +45 -0
  49. package/src/cores/libretro/coreOptions/consts.ts +36 -0
  50. package/src/cores/libretro/coreOptions/index.ts +222 -0
  51. package/src/cores/libretro/environment/consts.ts +118 -0
  52. package/src/cores/libretro/environment/index.ts +1095 -0
  53. package/src/cores/libretro/index.ts +937 -0
  54. package/src/cores/libretro/loader/index.ts +496 -0
  55. package/src/cores/libretro/pixelFormat/consts.ts +43 -0
  56. package/src/cores/libretro/pixelFormat/index.ts +397 -0
  57. package/src/cores/libretro/types.ts +339 -0
  58. package/src/frontend/AudioManager/index.ts +420 -0
  59. package/src/frontend/SettingsManager/index.ts +250 -0
  60. package/src/frontend/config/index.ts +608 -0
  61. package/src/frontend/config/tests.ts +354 -0
  62. package/src/frontend/config/types.ts +36 -0
  63. package/src/frontend/consts.ts +114 -0
  64. package/src/frontend/coreBuilder/index.ts +644 -0
  65. package/src/frontend/coreBuilder/types.ts +15 -0
  66. package/src/frontend/coreDownloader/index.ts +620 -0
  67. package/src/frontend/coreDownloader/types.ts +17 -0
  68. package/src/frontend/corePreferences/index.ts +69 -0
  69. package/src/frontend/coreRegistry/index.ts +276 -0
  70. package/src/frontend/directoryCache/index.ts +75 -0
  71. package/src/frontend/index.ts +79 -0
  72. package/src/frontend/notifications/consts.ts +14 -0
  73. package/src/frontend/notifications/index.ts +250 -0
  74. package/src/frontend/playlist/consts.ts +168 -0
  75. package/src/frontend/playlist/index.ts +899 -0
  76. package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
  77. package/src/frontend/playlist/labelFormatter/index.ts +155 -0
  78. package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
  79. package/src/frontend/playlist/reader/index.ts +559 -0
  80. package/src/frontend/playlist/sync/index.ts +511 -0
  81. package/src/frontend/playlist/systemLookup/index.ts +233 -0
  82. package/src/frontend/playlist/utils/index.ts +50 -0
  83. package/src/frontend/romScanner/consts.ts +348 -0
  84. package/src/frontend/romScanner/index.ts +1957 -0
  85. package/src/frontend/saveServices/consts.ts +2 -0
  86. package/src/frontend/saveServices/index.ts +313 -0
  87. package/src/frontend/serviceProvider/index.ts +108 -0
  88. package/src/frontend/serviceProvider/types.ts +13 -0
  89. package/src/index.ts +428 -0
  90. package/src/input/Controller/consts.ts +50 -0
  91. package/src/input/Controller/index.ts +81 -0
  92. package/src/input/GamepadManager/consts.ts +22 -0
  93. package/src/input/GamepadManager/index.ts +418 -0
  94. package/src/input/InputManager/consts.ts +86 -0
  95. package/src/input/InputManager/index.ts +593 -0
  96. package/src/input/InputMapper/consts.ts +33 -0
  97. package/src/input/InputMapper/index.ts +436 -0
  98. package/src/input/consts.ts +410 -0
  99. package/src/input/gamepadProfiles/index.ts +1100 -0
  100. package/src/input/index.ts +38 -0
  101. package/src/input/inputUtils/index.ts +31 -0
  102. package/src/netplay/FrameBuffer/consts.ts +2 -0
  103. package/src/netplay/FrameBuffer/index.ts +364 -0
  104. package/src/netplay/FrameBuffer/tests.ts +286 -0
  105. package/src/netplay/InputBuffer/consts.ts +7 -0
  106. package/src/netplay/InputBuffer/index.ts +347 -0
  107. package/src/netplay/InputBuffer/tests.ts +166 -0
  108. package/src/netplay/NetplayClient/index.ts +976 -0
  109. package/src/netplay/NetplayConnection/index.ts +536 -0
  110. package/src/netplay/NetplayDiscovery/consts.ts +41 -0
  111. package/src/netplay/NetplayDiscovery/index.ts +525 -0
  112. package/src/netplay/NetplayServer/index.ts +1407 -0
  113. package/src/netplay/SyncManager/index.ts +984 -0
  114. package/src/netplay/SyncManager/tests.ts +419 -0
  115. package/src/netplay/consts.ts +371 -0
  116. package/src/netplay/crc32/consts.ts +14 -0
  117. package/src/netplay/crc32/index.ts +97 -0
  118. package/src/netplay/crc32/tests.ts +40 -0
  119. package/src/netplay/index.ts +41 -0
  120. package/src/netplay/netplayLogger/consts.ts +30 -0
  121. package/src/netplay/netplayLogger/index.ts +345 -0
  122. package/src/netplay/protocol/consts.ts +86 -0
  123. package/src/netplay/protocol/index.ts +1280 -0
  124. package/src/netplay/protocol/tests.ts +606 -0
  125. package/src/netplay/protocol/types.ts +20 -0
  126. package/src/netplay/types.ts +395 -0
  127. package/src/rendering/KittyRenderer/index.ts +616 -0
  128. package/src/rendering/NativeRenderer/index.ts +279 -0
  129. package/src/rendering/NativeRenderer/tests.ts +133 -0
  130. package/src/rendering/TerminalRenderer/index.ts +770 -0
  131. package/src/rendering/consts.ts +401 -0
  132. package/src/rendering/fonts/CozetteVector.ttf +0 -0
  133. package/src/rendering/index.ts +26 -0
  134. package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
  135. package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
  136. package/src/rendering/nativeUi/consts.ts +6 -0
  137. package/src/rendering/nativeUi/index.ts +20 -0
  138. package/src/rendering/postProcessing/consts.ts +38 -0
  139. package/src/rendering/postProcessing/index.ts +923 -0
  140. package/src/rendering/shared/ansi/consts.ts +12 -0
  141. package/src/rendering/shared/ansi/index.ts +104 -0
  142. package/src/rendering/shared/consts.ts +2 -0
  143. package/src/rendering/shared/fitToTerminal/index.ts +67 -0
  144. package/src/ui/AddRomsPrompt/consts.ts +13 -0
  145. package/src/ui/AddRomsPrompt/index.tsx +781 -0
  146. package/src/ui/App/consts.ts +2 -0
  147. package/src/ui/App/index.tsx +456 -0
  148. package/src/ui/AppCapabilities/index.tsx +67 -0
  149. package/src/ui/ConfigContext/index.tsx +56 -0
  150. package/src/ui/CoreManager/consts.ts +11 -0
  151. package/src/ui/CoreManager/index.tsx +779 -0
  152. package/src/ui/CoreSelector/consts.ts +2 -0
  153. package/src/ui/CoreSelector/index.tsx +251 -0
  154. package/src/ui/DialogContainer/index.tsx +42 -0
  155. package/src/ui/DialogOptionsList/index.tsx +61 -0
  156. package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
  157. package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
  158. package/src/ui/GamepadContext/consts.ts +15 -0
  159. package/src/ui/GamepadContext/index.tsx +295 -0
  160. package/src/ui/NativeDialog/index.tsx +120 -0
  161. package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
  162. package/src/ui/NetplayPauseMenu/consts.ts +2 -0
  163. package/src/ui/NetplayPauseMenu/index.tsx +97 -0
  164. package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
  165. package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
  166. package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
  167. package/src/ui/RomBrowser/consts.ts +61 -0
  168. package/src/ui/RomBrowser/index.tsx +1164 -0
  169. package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
  170. package/src/ui/RomBrowser/types.ts +67 -0
  171. package/src/ui/SaveStateDialog/consts.ts +2 -0
  172. package/src/ui/SaveStateDialog/index.tsx +225 -0
  173. package/src/ui/WarningDialog/index.tsx +113 -0
  174. package/src/ui/consts.ts +27 -0
  175. package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
  176. package/src/ui/hooks/useClearTerminal/index.ts +37 -0
  177. package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
  178. package/src/ui/hooks/useGamepad/consts.ts +21 -0
  179. package/src/ui/hooks/useGamepad/index.ts +194 -0
  180. package/src/ui/index.ts +27 -0
  181. package/src/utils/buffer/consts.ts +17 -0
  182. package/src/utils/buffer/index.ts +129 -0
  183. package/src/utils/color/consts.ts +58 -0
  184. package/src/utils/color/index.ts +183 -0
  185. package/src/utils/compression/consts.ts +50 -0
  186. package/src/utils/compression/index.ts +101 -0
  187. package/src/utils/consts.ts +2 -0
  188. package/src/utils/crc32/consts.ts +22 -0
  189. package/src/utils/crc32/index.ts +83 -0
  190. package/src/utils/ensureDirectory/index.ts +10 -0
  191. package/src/utils/format/consts.ts +8 -0
  192. package/src/utils/format/index.ts +53 -0
  193. package/src/utils/getErrorMessage/index.ts +10 -0
  194. package/src/utils/index.ts +113 -0
  195. package/src/utils/ini/index.ts +200 -0
  196. package/src/utils/kitty/consts.ts +13 -0
  197. package/src/utils/kitty/index.ts +181 -0
  198. package/src/utils/logger/consts.ts +35 -0
  199. package/src/utils/logger/index.ts +217 -0
  200. package/src/utils/paths/consts.ts +18 -0
  201. package/src/utils/paths/index.ts +151 -0
  202. package/src/utils/png/consts.ts +34 -0
  203. package/src/utils/png/index.ts +131 -0
  204. package/src/utils/readJsonFile/index.ts +16 -0
  205. package/src/utils/rotateLogFile/index.ts +44 -0
  206. package/src/utils/safeClose/index.ts +10 -0
  207. package/src/utils/terminal/consts.ts +8 -0
  208. package/src/utils/terminal/index.ts +102 -0
  209. package/src/utils/thumbnailRenderer/consts.ts +2 -0
  210. package/src/utils/thumbnailRenderer/index.ts +147 -0
  211. package/src/utils/typedError/index.ts +26 -0
  212. package/tsconfig.json +31 -0
  213. package/vitest.config.ts +13 -0
@@ -0,0 +1,616 @@
1
+ import { clamp } from 'remeda';
2
+ import { appendFileSync, writeFileSync } from 'fs';
3
+ import { deflateSync } from 'zlib';
4
+ import { PostProcessingPipeline, type EffectOptions } from '../postProcessing';
5
+ import { buildGammaLUT, rgb15ToRgb24, calculateLuminance8 } from '../../utils/color';
6
+ import { getTerminalDimensions } from '../../utils/terminal';
7
+ import { APC, ST, moveCursor, clearScreen, clearLine, hideCursor, showCursor } from '../shared/ansi';
8
+ import { fitToTerminal } from '../shared/fitToTerminal';
9
+ import { PNG_SIGNATURE, createPngChunk } from '../../utils/png';
10
+ import { isRgb15Buffer, type FrameBuffer } from '../../core/core';
11
+ import {
12
+ DEFAULT_NATIVE_WIDTH,
13
+ DEFAULT_NATIVE_HEIGHT,
14
+ STATUS_LINE_ROWS,
15
+ CELL_WIDTH_PX,
16
+ CELL_HEIGHT_PX,
17
+ INITIAL_FULL_RENDER_FRAMES,
18
+ KITTY_CHUNK_SIZE,
19
+ PNG_BIT_DEPTH,
20
+ PNG_COLOR_TYPE_INDEXED,
21
+ PNG_COLOR_TYPE_RGB,
22
+ DEFAULT_PNG_COMPRESSION,
23
+ DEFAULT_RENDER_SCALE,
24
+ MIN_RENDER_SCALE,
25
+ MAX_RENDER_SCALE,
26
+ MEMORY_LOG_INTERVAL,
27
+ DEFAULT_GAMMA,
28
+ RGB24_BYTES_PER_PIXEL,
29
+ PNG_IHDR_LENGTH,
30
+ PNG_IHDR_HEIGHT_OFFSET,
31
+ BYTES_PER_KB,
32
+ PACK_RED_SHIFT,
33
+ PACK_GREEN_SHIFT,
34
+ PALETTE_BUFFER_SIZE,
35
+ } from '..';
36
+
37
+ // Debug logging for Kitty graphics issues
38
+ const DEBUG_KITTY = process.env.DEBUG_KITTY === '1';
39
+ const DEBUG_LOG_PATH = '/tmp/kitty-debug.log';
40
+
41
+ const debugLog = (msg: string): void => {
42
+ if (!DEBUG_KITTY) {return;}
43
+ const timestamp = new Date().toISOString();
44
+ appendFileSync(DEBUG_LOG_PATH, `[${timestamp}] ${msg}\n`);
45
+ };
46
+
47
+ // Kitty graphics protocol renderer
48
+ // https://sw.kovidgoyal.net/kitty/graphics-protocol/
49
+
50
+ export interface KittyRendererOptions extends EffectOptions {
51
+ scale?: number; // Scale factor for the image (undefined = auto-fit to terminal)
52
+ sourceWidth?: number; // Source framebuffer width (default: 256)
53
+ sourceHeight?: number; // Source framebuffer height (default: 240)
54
+ colorSpace?: 'rgb15' | 'rgb24'; // Color format (default: rgb24)
55
+ pixelAspectRatio?: number; // Pixel aspect ratio for correct display (default: 1.0)
56
+ enableDiffRendering?: boolean; // Enable diff-based rendering optimization (default: true)
57
+ colorEnabled?: boolean; // When false, render in grayscale (default: true)
58
+ pngCompressionLevel?: number; // PNG compression level 1-9 (default: 1, higher = smaller but slower)
59
+ }
60
+
61
+ // Color space type for frame conversion
62
+ type ColorSpace = 'rgb15' | 'rgb24';
63
+
64
+ export class KittyRenderer {
65
+ private scale: number; // Scale factor (0.25, 0.5, 1, 2, 3, etc.) - supports both up and downscaling
66
+ private imageId: number = 1;
67
+ private frameNumber: number = 0;
68
+ private displayCols: number;
69
+ private displayRows: number;
70
+ private offsetCol: number = 1; // Horizontal offset for centering
71
+ private offsetRow: number = 1; // Vertical offset for centering
72
+ // Source framebuffer dimensions
73
+ private sourceWidth: number;
74
+ private sourceHeight: number;
75
+ // Pixel aspect ratio for correct display (e.g., 8/7 for NES)
76
+ private pixelAspectRatio: number;
77
+ // Scaled image dimensions in pixels (integer multiple of source resolution)
78
+ private scaledWidth: number;
79
+ private scaledHeight: number;
80
+ // Pre-allocated RGB buffer for native resolution (before scaling, for post-processing)
81
+ private nativeRgbBuffer!: Uint8Array;
82
+ // Pre-allocated RGB buffer for scaled output
83
+ private scaledRgbBuffer!: Uint8Array;
84
+ // Pre-allocated row buffer for horizontal scaling
85
+ private scaledRowBuffer!: Uint8Array;
86
+ // Previous frame buffer for row-level memoization (skip unchanged rows)
87
+ // For rgb15: Uint16Array (2 bytes per pixel)
88
+ // For rgb24: Uint8Array (3 bytes per pixel)
89
+ private prevFrameBuffer: Uint8Array | Uint16Array;
90
+ // Cached PNG data from last encode
91
+ private pngBuffer: Buffer = Buffer.alloc(0);
92
+ // Color space for framebuffer interpretation
93
+ private colorSpace: ColorSpace;
94
+ // Diff-based rendering optimization
95
+ private enableDiffRendering: boolean;
96
+ // Color mode (true = full color, false = grayscale)
97
+ private colorEnabled: boolean;
98
+ // PNG compression level (1-9, higher = smaller but slower)
99
+ private pngCompressionLevel: number;
100
+ // Gamma lookup table for frame conversion
101
+ private gammaLUT: Uint8Array;
102
+ // Post-processing effects pipeline
103
+ private postProcessing: PostProcessingPipeline;
104
+
105
+ constructor(options: KittyRendererOptions = {}) {
106
+ this.sourceWidth = options.sourceWidth ?? DEFAULT_NATIVE_WIDTH;
107
+ this.sourceHeight = options.sourceHeight ?? DEFAULT_NATIVE_HEIGHT;
108
+ this.colorSpace = options.colorSpace ?? 'rgb24';
109
+ this.pixelAspectRatio = options.pixelAspectRatio ?? 1.0;
110
+ this.enableDiffRendering = options.enableDiffRendering ?? true;
111
+ this.colorEnabled = options.colorEnabled ?? true;
112
+ this.pngCompressionLevel = options.pngCompressionLevel ?? DEFAULT_PNG_COMPRESSION;
113
+
114
+ // Build gamma LUT for frame conversion (separate from post-processing)
115
+ const gamma = options.gamma ?? DEFAULT_GAMMA;
116
+ this.gammaLUT = buildGammaLUT(gamma);
117
+
118
+ // Create post-processing pipeline with effect options
119
+ this.postProcessing = new PostProcessingPipeline({
120
+ gamma,
121
+ scanlines: options.scanlines,
122
+ saturation: options.saturation,
123
+ brightness: options.brightness,
124
+ contrast: options.contrast,
125
+ vignette: options.vignette,
126
+ bloom: options.bloom,
127
+ bloomThreshold: options.bloomThreshold,
128
+ ntsc: options.ntsc,
129
+ curvature: options.curvature,
130
+ chromaticAberration: options.chromaticAberration,
131
+ });
132
+
133
+ // Allocate prevFrameBuffer based on color space
134
+ const pixelCount = this.sourceWidth * this.sourceHeight;
135
+ if (this.colorSpace === 'rgb15') {
136
+ this.prevFrameBuffer = new Uint16Array(pixelCount);
137
+ } else {
138
+ this.prevFrameBuffer = new Uint8Array(pixelCount * RGB24_BYTES_PER_PIXEL);
139
+ }
140
+
141
+ // Render scale controls internal buffer resolution (default: DEFAULT_RENDER_SCALE)
142
+ // Display size always fills terminal, Kitty handles the final scaling
143
+ // Supports fractional scales (0.25, 0.5) for downscaling and integer scales (1, 2, 3, 4) for upscaling
144
+ const rawScale = options.scale ?? DEFAULT_RENDER_SCALE;
145
+ this.scale = clamp(rawScale, { min: MIN_RENDER_SCALE, max: MAX_RENDER_SCALE });
146
+
147
+ // Calculate display size to fill terminal (Kitty will scale the image)
148
+ const { cols, rows, offsetCol, offsetRow } = this.calculateDisplaySize();
149
+ this.displayCols = cols;
150
+ this.displayRows = rows;
151
+ this.offsetCol = offsetCol;
152
+ this.offsetRow = offsetRow;
153
+
154
+ // Internal buffer uses render scale (round to ensure integer dimensions)
155
+ this.scaledWidth = Math.max(1, Math.round(this.sourceWidth * this.scale));
156
+ this.scaledHeight = Math.max(1, Math.round(this.sourceHeight * this.scale));
157
+
158
+ // Allocate buffers: native resolution for post-processing, scaled for output
159
+ this.nativeRgbBuffer = new Uint8Array(this.sourceWidth * this.sourceHeight * RGB24_BYTES_PER_PIXEL);
160
+ this.scaledRgbBuffer = new Uint8Array(this.scaledWidth * this.scaledHeight * RGB24_BYTES_PER_PIXEL);
161
+ this.scaledRowBuffer = new Uint8Array(this.scaledWidth * RGB24_BYTES_PER_PIXEL);
162
+
163
+ // Initialize debug log
164
+ if (DEBUG_KITTY) {
165
+ writeFileSync(DEBUG_LOG_PATH, `=== Kitty Debug Log Started ===\n`);
166
+ debugLog(`Init: sourceSize=${this.sourceWidth}x${this.sourceHeight}, scale=${this.scale}, scaledSize=${this.scaledWidth}x${this.scaledHeight}`);
167
+ debugLog(`Init: displayCols=${this.displayCols}, displayRows=${this.displayRows}, colorSpace=${this.colorSpace}`);
168
+ debugLog(`Init: pngCompression=${this.pngCompressionLevel}, diffRendering=${this.enableDiffRendering}`);
169
+ }
170
+ }
171
+
172
+ // Cleanup method (no-op, kept for API compatibility)
173
+ destroy(): void {
174
+ debugLog(`Destroy: frameNumber=${this.frameNumber}`);
175
+ }
176
+
177
+ // Calculate display size to fill terminal while maintaining aspect ratio
178
+ // Kitty handles scaling from internal buffer to display size
179
+ private calculateDisplaySize(): { cols: number; rows: number; offsetCol: number; offsetRow: number } {
180
+ const { width: termCols, height: termRows } = getTerminalDimensions();
181
+
182
+ const availableRows = termRows - STATUS_LINE_ROWS;
183
+ const availableCols = termCols;
184
+
185
+ // Formula: cols = rows * (sourceWidth * PAR) / sourceHeight * (cellHeight / cellWidth)
186
+ // For NES (256x240, 8:7 PAR): cols ≈ rows * 2.438
187
+ // For GBC (160x144, 1:1 PAR): cols ≈ rows * 2.222
188
+ const aspectRatio = (this.sourceWidth * this.pixelAspectRatio * CELL_HEIGHT_PX) / (this.sourceHeight * CELL_WIDTH_PX);
189
+
190
+ const { width: displayCols, height: displayRows } = fitToTerminal({
191
+ availableCols,
192
+ availableRows,
193
+ aspectRatio,
194
+ });
195
+
196
+ // Calculate centering offsets (1-based for ANSI escape sequences)
197
+ const offsetCol = Math.max(1, Math.floor((termCols - displayCols) / 2) + 1);
198
+ const offsetRow = Math.max(1, Math.floor((availableRows - displayRows) / 2) + 1);
199
+
200
+ return { cols: displayCols, rows: displayRows, offsetCol, offsetRow };
201
+ }
202
+
203
+ // Get current scale (useful for display info)
204
+ getScale(): number {
205
+ return this.scale;
206
+ }
207
+
208
+ // Get display dimensions
209
+ getDisplaySize(): { cols: number; rows: number } {
210
+ return { cols: this.displayCols, rows: this.displayRows };
211
+ }
212
+
213
+ // Update display dimensions (for terminal resize handling)
214
+ // Internal buffer scale stays fixed, only display size changes
215
+ setDimensions(): void {
216
+ const { cols, rows, offsetCol, offsetRow } = this.calculateDisplaySize();
217
+ this.displayCols = cols;
218
+ this.displayRows = rows;
219
+ this.offsetCol = offsetCol;
220
+ this.offsetRow = offsetRow;
221
+ }
222
+
223
+ // Generic frame comparison - check if entire frame is unchanged
224
+ private isFrameUnchanged(frameBuffer: Uint8Array | Uint16Array): boolean {
225
+ const prev = this.prevFrameBuffer;
226
+ const len = frameBuffer.length;
227
+ for (let i = 0; i < len; i++) {
228
+ if (frameBuffer[i] !== prev[i]) {return false;}
229
+ }
230
+ return true;
231
+ }
232
+
233
+ // Convert frame to RGB at native resolution (no scaling)
234
+ // Handles all color spaces: rgb15, rgb24
235
+ private frameToRgbNative(
236
+ frameBuffer: FrameBuffer,
237
+ colorSpace: ColorSpace
238
+ ): void {
239
+ const dst = this.nativeRgbBuffer;
240
+ const gammaLUT = this.gammaLUT;
241
+ const colorEnabled = this.colorEnabled;
242
+ const width = this.sourceWidth;
243
+ const height = this.sourceHeight;
244
+
245
+ for (let y = 0; y < height; y++) {
246
+ const srcRowStart = colorSpace === 'rgb24'
247
+ ? y * width * RGB24_BYTES_PER_PIXEL
248
+ : y * width;
249
+ const dstRowStart = y * width * RGB24_BYTES_PER_PIXEL;
250
+
251
+ for (let x = 0; x < width; x++) {
252
+ let r: number, g: number, b: number;
253
+
254
+ if (isRgb15Buffer(colorSpace, frameBuffer)) {
255
+ // RGB15: XBBBBBGGGGGRRRRR (5 bits per channel)
256
+ const color = frameBuffer[srcRowStart + x];
257
+ const [r8, g8, b8] = rgb15ToRgb24(color);
258
+ r = gammaLUT[r8];
259
+ g = gammaLUT[g8];
260
+ b = gammaLUT[b8];
261
+ } else {
262
+ // RGB24: direct 8-bit channels
263
+ const srcIdx = srcRowStart + x * RGB24_BYTES_PER_PIXEL;
264
+ r = gammaLUT[frameBuffer[srcIdx]];
265
+ g = gammaLUT[frameBuffer[srcIdx + 1]];
266
+ b = gammaLUT[frameBuffer[srcIdx + 2]];
267
+ }
268
+
269
+ // Convert to grayscale if color is disabled
270
+ const dstIdx = dstRowStart + x * RGB24_BYTES_PER_PIXEL;
271
+ if (!colorEnabled) {
272
+ const gray = calculateLuminance8(r, g, b);
273
+ dst[dstIdx] = gray;
274
+ dst[dstIdx + 1] = gray;
275
+ dst[dstIdx + 2] = gray;
276
+ } else {
277
+ dst[dstIdx] = r;
278
+ dst[dstIdx + 1] = g;
279
+ dst[dstIdx + 2] = b;
280
+ }
281
+ }
282
+ }
283
+ }
284
+
285
+ // Scale RGB buffer from native to display resolution
286
+ // Supports both upscaling (scale > 1) and downscaling (scale < 1)
287
+ private scaleRgbBuffer(): void {
288
+ const src = this.nativeRgbBuffer;
289
+ const dst = this.scaledRgbBuffer;
290
+ const scale = this.scale;
291
+ const srcWidth = this.sourceWidth;
292
+ const srcHeight = this.sourceHeight;
293
+ const dstWidth = this.scaledWidth;
294
+ const dstHeight = this.scaledHeight;
295
+
296
+ if (scale >= 1) {
297
+ // Upscaling: duplicate pixels (integer scale path)
298
+ const intScale = Math.round(scale);
299
+ const dstRowBytes = dstWidth * RGB24_BYTES_PER_PIXEL;
300
+ const rowBuffer = this.scaledRowBuffer;
301
+
302
+ for (let srcY = 0; srcY < srcHeight; srcY++) {
303
+ const srcRowStart = srcY * srcWidth * RGB24_BYTES_PER_PIXEL;
304
+
305
+ // Scale one source row horizontally into rowBuffer
306
+ let rowIdx = 0;
307
+ for (let srcX = 0; srcX < srcWidth; srcX++) {
308
+ const srcIdx = srcRowStart + srcX * RGB24_BYTES_PER_PIXEL;
309
+ const r = src[srcIdx];
310
+ const g = src[srcIdx + 1];
311
+ const b = src[srcIdx + 2];
312
+
313
+ // Write pixel 'scale' times horizontally
314
+ for (let sx = 0; sx < intScale; sx++) {
315
+ rowBuffer[rowIdx] = r;
316
+ rowBuffer[rowIdx + 1] = g;
317
+ rowBuffer[rowIdx + 2] = b;
318
+ rowIdx += RGB24_BYTES_PER_PIXEL;
319
+ }
320
+ }
321
+
322
+ // Copy the scaled row 'scale' times vertically
323
+ const dstRowStart = srcY * intScale * dstRowBytes;
324
+ for (let sy = 0; sy < intScale; sy++) {
325
+ dst.set(rowBuffer, dstRowStart + sy * dstRowBytes);
326
+ }
327
+ }
328
+ } else {
329
+ // Downscaling: sample pixels using nearest-neighbor
330
+ // For each destination pixel, find the corresponding source pixel
331
+ const invScale = 1 / scale;
332
+
333
+ for (let dstY = 0; dstY < dstHeight; dstY++) {
334
+ const srcY = Math.min(Math.floor(dstY * invScale), srcHeight - 1);
335
+ const srcRowStart = srcY * srcWidth * RGB24_BYTES_PER_PIXEL;
336
+ const dstRowStart = dstY * dstWidth * RGB24_BYTES_PER_PIXEL;
337
+
338
+ for (let dstX = 0; dstX < dstWidth; dstX++) {
339
+ const srcX = Math.min(Math.floor(dstX * invScale), srcWidth - 1);
340
+ const srcIdx = srcRowStart + srcX * RGB24_BYTES_PER_PIXEL;
341
+ const dstIdx = dstRowStart + dstX * RGB24_BYTES_PER_PIXEL;
342
+
343
+ dst[dstIdx] = src[srcIdx];
344
+ dst[dstIdx + 1] = src[srcIdx + 1];
345
+ dst[dstIdx + 2] = src[srcIdx + 2];
346
+ }
347
+ }
348
+ }
349
+ }
350
+
351
+ // Encode scaled RGB buffer to PNG format (indexed color with fallback to RGB)
352
+ private encodePng(): void {
353
+ const width = this.scaledWidth;
354
+ const height = this.scaledHeight;
355
+ const rgbData = this.scaledRgbBuffer;
356
+ const pixelCount = width * height;
357
+
358
+ // Build palette by scanning all pixels
359
+ // Use a Map with packed RGB as key for fast lookup
360
+ const colorToIndex = new Map<number, number>();
361
+ const palette: number[] = []; // Flat RGB array
362
+ let fallbackToRgb = false;
363
+
364
+ // First pass: collect unique colors
365
+ for (let i = 0; i < pixelCount; i++) {
366
+ const idx = i * RGB24_BYTES_PER_PIXEL;
367
+ const r = rgbData[idx];
368
+ const g = rgbData[idx + 1];
369
+ const b = rgbData[idx + 2];
370
+ const packed = (r << PACK_RED_SHIFT) | (g << PACK_GREEN_SHIFT) | b;
371
+
372
+ if (!colorToIndex.has(packed)) {
373
+ if (palette.length >= PALETTE_BUFFER_SIZE) {
374
+ fallbackToRgb = true;
375
+ break;
376
+ }
377
+ colorToIndex.set(packed, palette.length / RGB24_BYTES_PER_PIXEL);
378
+ palette.push(r, g, b);
379
+ }
380
+ }
381
+
382
+ if (fallbackToRgb) {
383
+ // More than 256 colors - fall back to RGB encoding
384
+ this.encodePngRgb();
385
+ return;
386
+ }
387
+
388
+ // Second pass: build indexed pixel data
389
+ const rawDataSize = height * (1 + width); // 1 filter byte + 1 byte per pixel per row
390
+ const rawData = Buffer.alloc(rawDataSize);
391
+
392
+ for (let y = 0; y < height; y++) {
393
+ const rawRowStart = y * (1 + width);
394
+ rawData[rawRowStart] = 0; // Filter type: none
395
+
396
+ for (let x = 0; x < width; x++) {
397
+ const srcIdx = (y * width + x) * RGB24_BYTES_PER_PIXEL;
398
+ const r = rgbData[srcIdx];
399
+ const g = rgbData[srcIdx + 1];
400
+ const b = rgbData[srcIdx + 2];
401
+ const packed = (r << PACK_RED_SHIFT) | (g << PACK_GREEN_SHIFT) | b;
402
+ rawData[rawRowStart + 1 + x] = colorToIndex.get(packed)!;
403
+ }
404
+ }
405
+
406
+ const compressed = deflateSync(rawData, { level: this.pngCompressionLevel });
407
+
408
+ // Build IHDR
409
+ const ihdr = Buffer.alloc(PNG_IHDR_LENGTH);
410
+ ihdr.writeUInt32BE(width, 0);
411
+ ihdr.writeUInt32BE(height, PNG_IHDR_HEIGHT_OFFSET);
412
+ ihdr[8] = PNG_BIT_DEPTH; // bit depth
413
+ ihdr[9] = PNG_COLOR_TYPE_INDEXED; // color type: indexed
414
+ ihdr[10] = 0; // compression
415
+ ihdr[11] = 0; // filter
416
+ ihdr[12] = 0; // interlace
417
+
418
+ // Build PLTE chunk
419
+ const plteData = Buffer.from(palette);
420
+
421
+ const ihdrChunk = createPngChunk('IHDR', ihdr);
422
+ const plteChunk = createPngChunk('PLTE', plteData);
423
+ const idatChunk = createPngChunk('IDAT', compressed);
424
+ const iendChunk = createPngChunk('IEND', Buffer.alloc(0));
425
+
426
+ this.pngBuffer = Buffer.concat([PNG_SIGNATURE, ihdrChunk, plteChunk, idatChunk, iendChunk]);
427
+ }
428
+
429
+ // Fallback RGB encoding when palette exceeds 256 colors
430
+ private encodePngRgb(): void {
431
+ const width = this.scaledWidth;
432
+ const height = this.scaledHeight;
433
+ const rgbData = this.scaledRgbBuffer;
434
+
435
+ const rowBytes = width * RGB24_BYTES_PER_PIXEL;
436
+ const rawDataSize = height * (1 + rowBytes);
437
+ const rawData = Buffer.alloc(rawDataSize);
438
+
439
+ for (let y = 0; y < height; y++) {
440
+ const rawRowStart = y * (1 + rowBytes);
441
+ rawData[rawRowStart] = 0; // Filter type: none
442
+ const srcStart = y * rowBytes;
443
+ for (let i = 0; i < rowBytes; i++) {
444
+ rawData[rawRowStart + 1 + i] = rgbData[srcStart + i];
445
+ }
446
+ }
447
+
448
+ const compressed = deflateSync(rawData, { level: this.pngCompressionLevel });
449
+
450
+ const ihdr = Buffer.alloc(PNG_IHDR_LENGTH);
451
+ ihdr.writeUInt32BE(width, 0);
452
+ ihdr.writeUInt32BE(height, PNG_IHDR_HEIGHT_OFFSET);
453
+ ihdr[8] = PNG_BIT_DEPTH; // bit depth
454
+ ihdr[9] = PNG_COLOR_TYPE_RGB; // color type: RGB
455
+ ihdr[10] = 0; // compression
456
+ ihdr[11] = 0; // filter
457
+ ihdr[12] = 0; // interlace
458
+
459
+ const ihdrChunk = createPngChunk('IHDR', ihdr);
460
+ const idatChunk = createPngChunk('IDAT', compressed);
461
+ const iendChunk = createPngChunk('IEND', Buffer.alloc(0));
462
+
463
+ this.pngBuffer = Buffer.concat([PNG_SIGNATURE, ihdrChunk, idatChunk, iendChunk]);
464
+ }
465
+
466
+ // Send image using Kitty graphics protocol with chunked transmission
467
+ private sendImage(): string {
468
+ // Encode to PNG (compressed)
469
+ this.encodePng();
470
+ const base64 = this.pngBuffer.toString('base64');
471
+ const chunks: string[] = [];
472
+
473
+ // Use large chunks to avoid crashes in some terminals (e.g., iTerm2)
474
+ // 4096 is Kitty's recommendation but causes issues with chunked transmission in some terminals
475
+ // Post-processing can create ~120KB+ base64, so use 256KB chunks
476
+ const chunkSize = KITTY_CHUNK_SIZE;
477
+
478
+ // Use alternating image IDs for double-buffering effect
479
+ const currentId = this.imageId + (this.frameNumber % 2);
480
+ const previousId = this.imageId + ((this.frameNumber + 1) % 2);
481
+
482
+ // Debug logging
483
+ const numChunks = Math.ceil(base64.length / chunkSize);
484
+ debugLog(`Frame ${this.frameNumber}: imageId=${currentId}, prevId=${previousId}, pngBytes=${this.pngBuffer.length}, base64Len=${base64.length}, chunks=${numChunks}`);
485
+
486
+ for (let i = 0; i < base64.length; i += chunkSize) {
487
+ const chunk = base64.slice(i, i + chunkSize);
488
+ const isFirst = i === 0;
489
+ const isLast = i + chunkSize >= base64.length;
490
+
491
+ let control: string;
492
+
493
+ if (isFirst) {
494
+ // First chunk: transmit image data
495
+ // a=T: transmit and display
496
+ // f=100: PNG format
497
+ // i=id: image ID
498
+ // p=1: placement ID (allows replacing in place)
499
+ // q=2: suppress response
500
+ // C=1: do not move cursor after displaying
501
+ // c=cols, r=rows: display size in terminal cells
502
+ // m=1: more chunks follow (0 if last)
503
+ const displayParams = `,c=${this.displayCols},r=${this.displayRows}`;
504
+ control = `a=T,f=100,i=${currentId},p=1,q=2,C=1${displayParams},m=${isLast ? 0 : 1}`;
505
+ debugLog(`Frame ${this.frameNumber}: SEND control="${control}"`);
506
+ } else {
507
+ // Subsequent chunks: just continuation
508
+ control = `m=${isLast ? 0 : 1}`;
509
+ }
510
+
511
+ chunks.push(`${APC}${control};${chunk}${ST}`);
512
+ }
513
+
514
+ // Delete previous frame's image after displaying new one
515
+ if (this.frameNumber > 0) {
516
+ debugLog(`Frame ${this.frameNumber}: DELETE imageId=${previousId}`);
517
+ chunks.push(`${APC}a=d,d=I,i=${previousId},q=2${ST}`);
518
+ }
519
+
520
+ this.frameNumber++;
521
+
522
+ return chunks.join('');
523
+ }
524
+
525
+ // Unified render method for all color spaces
526
+ private renderInternal(
527
+ frameBuffer: FrameBuffer,
528
+ colorSpace: ColorSpace
529
+ ): string {
530
+ // Check if frame buffer size changed (can happen with SNES resolution changes)
531
+ // If so, reallocate prevFrameBuffer to match
532
+ if (frameBuffer.length !== this.prevFrameBuffer.length) {
533
+ if (frameBuffer instanceof Uint16Array) {
534
+ this.prevFrameBuffer = new Uint16Array(frameBuffer.length);
535
+ } else {
536
+ this.prevFrameBuffer = new Uint8Array(frameBuffer.length);
537
+ }
538
+ }
539
+
540
+ // Force full rendering for initial frames to ensure display is populated
541
+ const isInitialFrame = this.frameNumber < INITIAL_FULL_RENDER_FRAMES;
542
+
543
+ // Periodic memory logging (every 60 frames ≈ 1 second)
544
+ if (DEBUG_KITTY && this.frameNumber % MEMORY_LOG_INTERVAL === 0) {
545
+ const mem = process.memoryUsage();
546
+ debugLog(`Memory @ frame ${this.frameNumber}: heapUsed=${Math.round(mem.heapUsed / BYTES_PER_KB / BYTES_PER_KB)}MB, heapTotal=${Math.round(mem.heapTotal / BYTES_PER_KB / BYTES_PER_KB)}MB, rss=${Math.round(mem.rss / BYTES_PER_KB / BYTES_PER_KB)}MB`);
547
+ }
548
+
549
+ // Skip entirely if frame unchanged (after initial frames)
550
+ if (this.enableDiffRendering && !isInitialFrame && this.isFrameUnchanged(frameBuffer)) {
551
+ debugLog(`Frame ${this.frameNumber}: SKIPPED (unchanged)`);
552
+ return '';
553
+ }
554
+
555
+ // Convert frame to RGB at native resolution
556
+ this.frameToRgbNative(frameBuffer, colorSpace);
557
+
558
+ // Save current frame for next frame's diff check
559
+ this.prevFrameBuffer.set(frameBuffer);
560
+
561
+ // Apply post-processing effects at native resolution (much faster than scaled)
562
+ this.postProcessing.apply(this.nativeRgbBuffer, this.sourceWidth, this.sourceHeight);
563
+
564
+ // Scale up to display resolution
565
+ this.scaleRgbBuffer();
566
+
567
+ // Build output
568
+ let output = '';
569
+
570
+ // Move cursor to centered position for image placement
571
+ output += moveCursor(this.offsetRow, this.offsetCol);
572
+
573
+ // Always use PNG - compression helps with terminal I/O even for dynamic frames
574
+ output += this.sendImage();
575
+
576
+ return output;
577
+ }
578
+
579
+ // Render RGB15 frame buffer to Kitty graphics
580
+ renderRgb15(frameBuffer: Uint16Array): string {
581
+ return this.renderInternal(frameBuffer, 'rgb15');
582
+ }
583
+
584
+ // Render RGB24 frame buffer to Kitty graphics
585
+ renderRgb24(frameBuffer: Uint8Array): string {
586
+ return this.renderInternal(frameBuffer, 'rgb24');
587
+ }
588
+
589
+ // Clear screen
590
+ clearScreen(): string {
591
+ // Delete all images and clear screen
592
+ return `${APC}a=d,d=A,q=2${ST}${clearScreen()}`;
593
+ }
594
+
595
+ // Hide cursor
596
+ hideCursor(): string {
597
+ return hideCursor();
598
+ }
599
+
600
+ // Show cursor
601
+ showCursor(): string {
602
+ return showCursor();
603
+ }
604
+
605
+ // Get status row (below the image)
606
+ getStatusRow(): number {
607
+ // We know exactly how many rows the image uses from displayRows
608
+ // Account for vertical centering offset
609
+ return this.offsetRow + this.displayRows;
610
+ }
611
+
612
+ // Move cursor to status row
613
+ moveCursorToRow(row: number): string {
614
+ return moveCursor(row, 1) + clearLine();
615
+ }
616
+ }