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,279 @@
1
+ /**
2
+ * Native Window Renderer
3
+ *
4
+ * Renders emulator output into ink-native's shared framebuffer instead of the
5
+ * terminal. Bypasses terminal I/O for best performance. The window is owned by
6
+ * the NativeWindowManager (ink-native); this renderer writes pixels and reads
7
+ * keyboard/close events from that shared window.
8
+ */
9
+ import { clamp } from 'remeda';
10
+ import { packColor, type NativeKeyboardEvent, type Window, type UiRenderer } from 'ink-native';
11
+ import { getWindowManager } from '../nativeUi';
12
+ import { PostProcessingPipeline, type EffectOptions } from '../postProcessing';
13
+ import { buildGammaLUT, rgb15ToRgb24, calculateLuminance8 } from '@/utils/color';
14
+ import { logger } from '@/utils/logger';
15
+ import {
16
+ DEFAULT_NATIVE_WIDTH,
17
+ DEFAULT_NATIVE_HEIGHT,
18
+ DEFAULT_GAMMA,
19
+ RGB24_BYTES_PER_PIXEL,
20
+ DEFAULT_NATIVE_SCALE,
21
+ MIN_NATIVE_SCALE,
22
+ MAX_NATIVE_SCALE,
23
+ } from '..';
24
+
25
+ export type NativeKeyboardCallback = (key: string, pressed: boolean) => void;
26
+
27
+ export interface NativeRendererOptions extends EffectOptions {
28
+ scale?: number;
29
+ sourceWidth?: number;
30
+ sourceHeight?: number;
31
+ pixelAspectRatio?: number;
32
+ colorEnabled?: boolean;
33
+ title?: string;
34
+ frameRate?: number;
35
+ }
36
+
37
+ /** Aspect-ratio-correct, centered destination rect within the framebuffer. */
38
+ export const computeDestRect = (
39
+ fbWidth: number,
40
+ fbHeight: number,
41
+ targetAspectRatio: number,
42
+ ): { x: number; y: number; width: number; height: number } => {
43
+ const outputAspect = fbWidth / fbHeight;
44
+ let width: number;
45
+ let height: number;
46
+ if (outputAspect > targetAspectRatio) {
47
+ // Window wider than content — pillarbox (bars on sides)
48
+ height = fbHeight;
49
+ width = Math.round(fbHeight * targetAspectRatio);
50
+ } else {
51
+ // Window taller than content — letterbox (bars top/bottom)
52
+ width = fbWidth;
53
+ height = Math.round(fbWidth / targetAspectRatio);
54
+ }
55
+ const x = Math.round((fbWidth - width) / 2);
56
+ const y = Math.round((fbHeight - height) / 2);
57
+ return { x, y, width, height };
58
+ };
59
+
60
+ /** Normalize an ink-native key name to the legacy (lowercased-letter) form. */
61
+ export const normalizeKey = (key: string): string => {
62
+ return key.length === 1 ? key.toLowerCase() : key;
63
+ };
64
+
65
+ export class NativeRenderer {
66
+ readonly isWindowBased = true;
67
+
68
+ private window: Window;
69
+ private renderer: UiRenderer;
70
+
71
+ private sourceWidth: number;
72
+ private sourceHeight: number;
73
+ private pixelAspectRatio: number;
74
+
75
+ private colorEnabled: boolean;
76
+ private title: string;
77
+ private closed = false;
78
+
79
+ private targetAspectRatio: number;
80
+
81
+ public onKeyboard: NativeKeyboardCallback | null = null;
82
+
83
+ private rgbBuffer: Uint8Array;
84
+ private gammaLUT: Uint8Array;
85
+ private postProcessing: PostProcessingPipeline;
86
+
87
+ private keydownHandler: (event: NativeKeyboardEvent) => void;
88
+ private keyupHandler: (event: NativeKeyboardEvent) => void;
89
+ private closeHandler: () => void;
90
+
91
+ constructor(options: NativeRendererOptions = {}) {
92
+ this.sourceWidth = options.sourceWidth ?? DEFAULT_NATIVE_WIDTH;
93
+ this.sourceHeight = options.sourceHeight ?? DEFAULT_NATIVE_HEIGHT;
94
+ this.pixelAspectRatio = options.pixelAspectRatio ?? 1.0;
95
+ this.colorEnabled = options.colorEnabled ?? true;
96
+ this.title = options.title ?? 'emoemu';
97
+
98
+ const rawScale = options.scale ?? DEFAULT_NATIVE_SCALE;
99
+ const scale = clamp(Math.round(rawScale), { min: MIN_NATIVE_SCALE, max: MAX_NATIVE_SCALE });
100
+
101
+ this.targetAspectRatio = (this.sourceWidth * this.pixelAspectRatio) / this.sourceHeight;
102
+
103
+ const gamma = options.gamma ?? DEFAULT_GAMMA;
104
+ this.gammaLUT = buildGammaLUT(gamma);
105
+
106
+ this.postProcessing = new PostProcessingPipeline({
107
+ gamma,
108
+ scanlines: options.scanlines,
109
+ saturation: options.saturation,
110
+ brightness: options.brightness,
111
+ contrast: options.contrast,
112
+ vignette: options.vignette,
113
+ bloom: options.bloom,
114
+ bloomThreshold: options.bloomThreshold,
115
+ ntsc: options.ntsc,
116
+ curvature: options.curvature,
117
+ chromaticAberration: options.chromaticAberration,
118
+ });
119
+
120
+ this.rgbBuffer = new Uint8Array(this.sourceWidth * this.sourceHeight * RGB24_BYTES_PER_PIXEL);
121
+
122
+ // Attach to the shared native window (create it at game size if not up yet).
123
+ const wm = getWindowManager();
124
+ if (!wm.isInitialized()) {
125
+ const windowWidth = Math.round(this.sourceWidth * scale * this.pixelAspectRatio);
126
+ const windowHeight = this.sourceHeight * scale;
127
+ wm.init({ title: this.title, width: windowWidth, height: windowHeight, frameRate: options.frameRate });
128
+ }
129
+ this.window = wm.getWindow();
130
+ this.renderer = wm.getRenderer();
131
+ wm.setMode('game');
132
+
133
+ // Wire input + close from the shared window.
134
+ this.keydownHandler = (event) => {
135
+ this.onKeyboard?.(normalizeKey(event.key), true);
136
+ };
137
+ this.keyupHandler = (event) => {
138
+ this.onKeyboard?.(normalizeKey(event.key), false);
139
+ };
140
+ this.closeHandler = () => {
141
+ this.closed = true;
142
+ };
143
+ this.window.on('keydown', this.keydownHandler);
144
+ this.window.on('keyup', this.keyupHandler);
145
+ this.window.on('close', this.closeHandler);
146
+
147
+ logger.info(
148
+ `Native game renderer attached to shared window (source: ${this.sourceWidth}x${this.sourceHeight})`,
149
+ 'Native',
150
+ );
151
+ }
152
+
153
+ shouldClose(): boolean {
154
+ return this.closed || this.window.isClosed();
155
+ }
156
+
157
+ renderRgb15(frameBuffer: Uint16Array): string {
158
+ this.frameToRgbFromRgb15(frameBuffer);
159
+ this.presentFrame();
160
+ return '';
161
+ }
162
+
163
+ renderRgb24(frameBuffer: Uint8Array): string {
164
+ this.frameToRgbFromRgb24(frameBuffer);
165
+ this.presentFrame();
166
+ return '';
167
+ }
168
+
169
+ private frameToRgbFromRgb15(frameBuffer: Uint16Array): void {
170
+ const dst = this.rgbBuffer;
171
+ const gammaLUT = this.gammaLUT;
172
+ const colorEnabled = this.colorEnabled;
173
+ const width = this.sourceWidth;
174
+ const height = this.sourceHeight;
175
+ for (let y = 0; y < height; y++) {
176
+ const srcRowStart = y * width;
177
+ const dstRowStart = y * width * RGB24_BYTES_PER_PIXEL;
178
+ for (let x = 0; x < width; x++) {
179
+ const color = frameBuffer[srcRowStart + x];
180
+ const [r8, g8, b8] = rgb15ToRgb24(color);
181
+ const r = gammaLUT[r8];
182
+ const g = gammaLUT[g8];
183
+ const b = gammaLUT[b8];
184
+ const dstIdx = dstRowStart + x * RGB24_BYTES_PER_PIXEL;
185
+ if (!colorEnabled) {
186
+ const gray = calculateLuminance8(r, g, b);
187
+ dst[dstIdx] = gray; dst[dstIdx + 1] = gray; dst[dstIdx + 2] = gray;
188
+ } else {
189
+ dst[dstIdx] = r; dst[dstIdx + 1] = g; dst[dstIdx + 2] = b;
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ private frameToRgbFromRgb24(frameBuffer: Uint8Array): void {
196
+ const dst = this.rgbBuffer;
197
+ const gammaLUT = this.gammaLUT;
198
+ const colorEnabled = this.colorEnabled;
199
+ const width = this.sourceWidth;
200
+ const height = this.sourceHeight;
201
+ for (let y = 0; y < height; y++) {
202
+ const rowStart = y * width * RGB24_BYTES_PER_PIXEL;
203
+ for (let x = 0; x < width; x++) {
204
+ const srcIdx = rowStart + x * RGB24_BYTES_PER_PIXEL;
205
+ const r = gammaLUT[frameBuffer[srcIdx]];
206
+ const g = gammaLUT[frameBuffer[srcIdx + 1]];
207
+ const b = gammaLUT[frameBuffer[srcIdx + 2]];
208
+ const dstIdx = rowStart + x * RGB24_BYTES_PER_PIXEL;
209
+ if (!colorEnabled) {
210
+ const gray = calculateLuminance8(r, g, b);
211
+ dst[dstIdx] = gray; dst[dstIdx + 1] = gray; dst[dstIdx + 2] = gray;
212
+ } else {
213
+ dst[dstIdx] = r; dst[dstIdx + 1] = g; dst[dstIdx + 2] = b;
214
+ }
215
+ }
216
+ }
217
+ }
218
+
219
+ /** Post-process, scale/letterbox into the framebuffer, and present. */
220
+ private presentFrame(): void {
221
+ this.postProcessing.apply(this.rgbBuffer, this.sourceWidth, this.sourceHeight);
222
+ this.blitToFramebuffer();
223
+ this.renderer.present();
224
+ }
225
+
226
+ private blitToFramebuffer(): void {
227
+ const fb = this.renderer.getFramebuffer();
228
+ const { pixels, width: fbWidth, height: fbHeight } = fb;
229
+
230
+ // Clear whole framebuffer to black (letterbox/pillarbox bars).
231
+ pixels.fill(packColor(0, 0, 0));
232
+
233
+ const dest = computeDestRect(fbWidth, fbHeight, this.targetAspectRatio);
234
+ const src = this.rgbBuffer;
235
+ const sw = this.sourceWidth;
236
+ const sh = this.sourceHeight;
237
+
238
+ for (let dy = 0; dy < dest.height; dy++) {
239
+ const sy = Math.min(sh - 1, Math.floor((dy * sh) / dest.height));
240
+ const fbRow = (dest.y + dy) * fbWidth + dest.x;
241
+ const srcRow = sy * sw * RGB24_BYTES_PER_PIXEL;
242
+ for (let dx = 0; dx < dest.width; dx++) {
243
+ const sx = Math.min(sw - 1, Math.floor((dx * sw) / dest.width));
244
+ const s = srcRow + sx * RGB24_BYTES_PER_PIXEL;
245
+ pixels[fbRow + dx] = packColor(src[s], src[s + 1], src[s + 2]);
246
+ }
247
+ }
248
+ }
249
+
250
+ setTitle(title: string): void {
251
+ // ink-native has no runtime setTitle; retained for interface compatibility.
252
+ this.title = title;
253
+ }
254
+
255
+ setDimensions(width: number, height: number): void {
256
+ if (width === this.sourceWidth && height === this.sourceHeight) {
257
+ return;
258
+ }
259
+ this.sourceWidth = width;
260
+ this.sourceHeight = height;
261
+ this.targetAspectRatio = (width * this.pixelAspectRatio) / height;
262
+ this.rgbBuffer = new Uint8Array(width * height * RGB24_BYTES_PER_PIXEL);
263
+ logger.info(`Native renderer source resized to ${width}x${height}`, 'Native');
264
+ }
265
+
266
+ // Terminal-specific methods (no-op for native window)
267
+ clearScreen(): string { return ''; }
268
+ hideCursor(): string { return ''; }
269
+ showCursor(): string { return ''; }
270
+ getStatusRow(): number { return 0; }
271
+ moveCursorToRow(_row: number): string { return ''; }
272
+
273
+ destroy(): void {
274
+ this.window.off('keydown', this.keydownHandler);
275
+ this.window.off('keyup', this.keyupHandler);
276
+ this.window.off('close', this.closeHandler);
277
+ logger.info('Native game renderer detached from shared window', 'Native');
278
+ }
279
+ }
@@ -0,0 +1,133 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { packColor } from 'ink-native';
3
+
4
+ // Mock the shared native window manager so NativeRenderer never touches a
5
+ // real window. Fixtures live inside vi.hoisted() because vi.mock calls are
6
+ // hoisted above regular top-level const declarations, and importing
7
+ // NativeRenderer below pulls in '../nativeUi' before the rest of this file's
8
+ // body executes.
9
+ const { fakeWindow, fakeRenderer, fakeWindowManager } = vi.hoisted(() => {
10
+ const fakeWindow = {
11
+ on: vi.fn(),
12
+ off: vi.fn(),
13
+ isClosed: vi.fn(() => false),
14
+ };
15
+ const fakeRenderer = {
16
+ getFramebuffer: vi.fn(),
17
+ present: vi.fn(),
18
+ };
19
+ const fakeWindowManager = {
20
+ isInitialized: vi.fn(() => true),
21
+ init: vi.fn(),
22
+ getWindow: vi.fn(() => fakeWindow),
23
+ getRenderer: vi.fn(() => fakeRenderer),
24
+ setMode: vi.fn(),
25
+ };
26
+ return { fakeWindow, fakeRenderer, fakeWindowManager };
27
+ });
28
+ vi.mock('../nativeUi', () => ({
29
+ getWindowManager: () => fakeWindowManager,
30
+ }));
31
+
32
+ import { NativeRenderer, computeDestRect, normalizeKey } from '.';
33
+
34
+ const makeFramebuffer = (width: number, height: number): { pixels: Uint32Array; width: number; height: number } => {
35
+ return { pixels: new Uint32Array(width * height), width, height };
36
+ };
37
+
38
+ describe('computeDestRect', () => {
39
+ it('pillarboxes when the window is wider than the content (4:3 into 16:9)', () => {
40
+ // target aspect 4/3 ≈ 1.333; framebuffer 1600x900 (16:9)
41
+ const rect = computeDestRect(1600, 900, 4 / 3);
42
+ expect(rect.height).toBe(900);
43
+ expect(rect.width).toBe(1200); // 900 * 4/3
44
+ expect(rect.x).toBe(200); // centered: (1600-1200)/2
45
+ expect(rect.y).toBe(0);
46
+ });
47
+
48
+ it('letterboxes when the window is taller than the content', () => {
49
+ // target aspect 4/3; framebuffer 800x900 (taller)
50
+ const rect = computeDestRect(800, 900, 4 / 3);
51
+ expect(rect.width).toBe(800);
52
+ expect(rect.height).toBe(600); // 800 / (4/3)
53
+ expect(rect.x).toBe(0);
54
+ expect(rect.y).toBe(150); // (900-600)/2
55
+ });
56
+
57
+ it('fills exactly when aspect ratios match', () => {
58
+ const rect = computeDestRect(1024, 768, 4 / 3);
59
+ expect(rect).toEqual({ x: 0, y: 0, width: 1024, height: 768 });
60
+ });
61
+ });
62
+
63
+ describe('normalizeKey', () => {
64
+ it('lowercases single letters to match legacy SDL key names', () => {
65
+ expect(normalizeKey('A')).toBe('a');
66
+ expect(normalizeKey('z')).toBe('z');
67
+ });
68
+ it('leaves named keys untouched', () => {
69
+ expect(normalizeKey('ArrowUp')).toBe('ArrowUp');
70
+ expect(normalizeKey('Enter')).toBe('Enter');
71
+ expect(normalizeKey(' ')).toBe(' ');
72
+ });
73
+ });
74
+
75
+ describe('NativeRenderer', () => {
76
+ beforeEach(() => {
77
+ vi.clearAllMocks();
78
+ fakeWindowManager.isInitialized.mockReturnValue(true);
79
+ fakeWindowManager.getWindow.mockReturnValue(fakeWindow);
80
+ fakeWindowManager.getRenderer.mockReturnValue(fakeRenderer);
81
+ });
82
+
83
+ it('blits a source frame into the letterboxed dest rect, fills bars with black, and presents', () => {
84
+ // 2x2 source (square) into a 4x2 framebuffer (2:1) pillarboxes to a
85
+ // centered 2x2 dest rect at x=1..2, leaving column 0 and column 3 as bars.
86
+ fakeRenderer.getFramebuffer.mockReturnValue(makeFramebuffer(4, 2));
87
+
88
+ const renderer = new NativeRenderer({ sourceWidth: 2, sourceHeight: 2, scale: 1 });
89
+
90
+ // RGB24, 2x2, row-major: row0 = [red, green], row1 = [blue, white]
91
+ const frame = new Uint8Array([
92
+ 255, 0, 0, 0, 255, 0,
93
+ 0, 0, 255, 255, 255, 255,
94
+ ]);
95
+ renderer.renderRgb24(frame);
96
+
97
+ const fb = fakeRenderer.getFramebuffer.mock.results[0]?.value as { pixels: Uint32Array };
98
+ const black = packColor(0, 0, 0);
99
+
100
+ // Pillarbox bars (dest x=0 and x=3 on both rows)
101
+ expect(fb.pixels[0]).toBe(black); // (0,0)
102
+ expect(fb.pixels[3]).toBe(black); // (3,0)
103
+ expect(fb.pixels[4]).toBe(black); // (0,1)
104
+ expect(fb.pixels[7]).toBe(black); // (3,1)
105
+
106
+ // Content area (dest x=1..2), mapped 1:1 to the source pixels
107
+ expect(fb.pixels[1]).toBe(packColor(255, 0, 0)); // dest(1,0) <- src red
108
+ expect(fb.pixels[2]).toBe(packColor(0, 255, 0)); // dest(2,0) <- src green
109
+ expect(fb.pixels[5]).toBe(packColor(0, 0, 255)); // dest(1,1) <- src blue
110
+ expect(fb.pixels[6]).toBe(packColor(255, 255, 255)); // dest(2,1) <- src white
111
+
112
+ expect(fakeRenderer.present).toHaveBeenCalledTimes(1);
113
+ });
114
+
115
+ it('registers keydown/keyup/close on the window and removes the same handlers on destroy', () => {
116
+ fakeRenderer.getFramebuffer.mockReturnValue(makeFramebuffer(2, 2));
117
+
118
+ const renderer = new NativeRenderer({ sourceWidth: 2, sourceHeight: 2, scale: 1 });
119
+
120
+ expect(fakeWindow.on).toHaveBeenCalledTimes(3);
121
+ const registered = new Map(fakeWindow.on.mock.calls.map(([event, handler]) => [event as string, handler]));
122
+ expect(registered.get('keydown')).toBeInstanceOf(Function);
123
+ expect(registered.get('keyup')).toBeInstanceOf(Function);
124
+ expect(registered.get('close')).toBeInstanceOf(Function);
125
+
126
+ renderer.destroy();
127
+
128
+ expect(fakeWindow.off).toHaveBeenCalledTimes(3);
129
+ for (const [event, handler] of fakeWindow.off.mock.calls) {
130
+ expect(registered.get(event as string)).toBe(handler);
131
+ }
132
+ });
133
+ });