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,770 @@
1
+ import { clamp } from 'remeda';
2
+ import {
3
+ rgb15ToRgb24,
4
+ rgb15ToLuminance,
5
+ rgb15ToEmoji,
6
+ rgb15ToGrayscaleEmoji,
7
+ rgb24ToEmoji,
8
+ rgb24ToGrayscaleEmoji,
9
+ calculateLuminance,
10
+ rgbToAnsi256,
11
+ } from '../../utils/color';
12
+ import { getTerminalDimensions } from '../../utils/terminal';
13
+ import {
14
+ RESET,
15
+ HALF_BLOCK_TOP,
16
+ fgTrueColor,
17
+ bgTrueColor,
18
+ fgAnsi256,
19
+ bgAnsi256,
20
+ moveCursor,
21
+ moveCursorToRow,
22
+ clearScreen,
23
+ hideCursor,
24
+ showCursor,
25
+ } from '../shared/ansi';
26
+ import type { EffectOptions } from '../postProcessing';
27
+ import {
28
+ DEFAULT_SOURCE_WIDTH,
29
+ DEFAULT_SOURCE_HEIGHT,
30
+ DEFAULT_DISPLAY_WIDTH,
31
+ DEFAULT_DISPLAY_HEIGHT,
32
+ STATUS_LINE_ROWS,
33
+ DIFF_GAP_THRESHOLD,
34
+ EMOJI_COLUMN_WIDTH,
35
+ COLOR_CHANNEL_MAX,
36
+ CONTRAST_MIDPOINT,
37
+ GAMMA_LUT_SIZE,
38
+ DEFAULT_GAMMA,
39
+ DEFAULT_SCANLINES,
40
+ DEFAULT_SATURATION,
41
+ DEFAULT_BRIGHTNESS,
42
+ DEFAULT_CONTRAST,
43
+ DEFAULT_VIGNETTE,
44
+ LUMINANCE_R,
45
+ LUMINANCE_G,
46
+ LUMINANCE_B,
47
+ RGB24_BYTES_PER_PIXEL,
48
+ PACK_RED_SHIFT,
49
+ PACK_GREEN_SHIFT,
50
+ } from '..';
51
+
52
+ // RGB15 ANSI color helpers using shared utilities
53
+ const rgb15ToTrueColor = (color: number): string => {
54
+ const [r, g, b] = rgb15ToRgb24(color);
55
+ return fgTrueColor(r, g, b);
56
+ };
57
+
58
+ const rgb15ToBgTrueColor = (color: number): string => {
59
+ const [r, g, b] = rgb15ToRgb24(color);
60
+ return bgTrueColor(r, g, b);
61
+ };
62
+
63
+ // RGB24 luminance helper using shared utilities
64
+ const rgb24ToLuminance = (r: number, g: number, b: number): number => calculateLuminance(r, g, b);
65
+
66
+ export interface RendererOptions extends Required<Pick<EffectOptions, 'gamma' | 'scanlines' | 'saturation' | 'brightness' | 'contrast' | 'vignette'>> {
67
+ width: number;
68
+ height: number;
69
+ colorEnabled: boolean;
70
+ trueColorEnabled: boolean;
71
+ asciiMode: boolean;
72
+ emojiMode: boolean;
73
+ sourceWidth: number; // Source framebuffer width (e.g., 256, 160 for GBC)
74
+ sourceHeight: number; // Source framebuffer height (e.g., 240, 144 for GBC)
75
+ enableDiffRendering: boolean; // Enable diff-based rendering optimization (default: true)
76
+ }
77
+
78
+ // ASCII character ramps for different density levels
79
+ const ASCII_CHARS_DENSE = ' .\'`^",:;Il!i><~+_-][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$';
80
+ const ASCII_CHARS_SIMPLE = ' .-:=+*#%@';
81
+
82
+ export class TerminalRenderer {
83
+ private width: number;
84
+ private height: number;
85
+ private colorEnabled: boolean;
86
+ private trueColorEnabled: boolean;
87
+ private asciiMode: boolean;
88
+ private emojiMode: boolean;
89
+ private asciiChars: string;
90
+ private offsetCol: number = 0; // Horizontal offset for centering (0-based for padding)
91
+ private offsetRow: number = 1; // Vertical offset for centering (1-based for ANSI)
92
+ private sourceWidth: number; // Source framebuffer width
93
+ private sourceHeight: number; // Source framebuffer height
94
+ // Pre-computed padding string for centering
95
+ private paddingString: string = '';
96
+ // Diff-based rendering optimization
97
+ private enableDiffRendering: boolean;
98
+ // Diff-based rendering state
99
+ private prevFrameBufferRgb15: Uint16Array | null = null;
100
+ // Previous output grid for character-level diff detection
101
+ // Stores the rendered string for each character position
102
+ private prevOutputGrid: string[][] | null = null;
103
+ private frameNumber: number = 0;
104
+ // Color state tracking for batching (avoid redundant escape codes)
105
+ private currentFg: number = -1; // Current foreground color (packed RGB or -1 for unset)
106
+ private currentBg: number = -1; // Current background color (packed RGB or -1 for unset)
107
+ // Post-processing effects
108
+ private gamma: number = 1.0;
109
+ private scanlines: number = 0;
110
+ private saturation: number = 1.0;
111
+ private brightness: number = 1.0;
112
+ private contrast: number = 1.0;
113
+ private vignette: number = 0;
114
+ // Pre-computed lookup tables for effects
115
+ private gammaLUT: Uint8Array | null = null;
116
+ private vignetteMap: Float32Array | null = null;
117
+ // Track if any effects are enabled
118
+ private effectsEnabled: boolean = false;
119
+
120
+ constructor(options: Partial<RendererOptions> = {}) {
121
+ this.width = options.width ?? DEFAULT_DISPLAY_WIDTH;
122
+ this.height = options.height ?? DEFAULT_DISPLAY_HEIGHT;
123
+ this.colorEnabled = options.colorEnabled ?? true;
124
+ this.trueColorEnabled = options.trueColorEnabled ?? true;
125
+ this.asciiMode = options.asciiMode ?? false;
126
+ this.emojiMode = options.emojiMode ?? false;
127
+ this.sourceWidth = options.sourceWidth ?? DEFAULT_SOURCE_WIDTH;
128
+ this.sourceHeight = options.sourceHeight ?? DEFAULT_SOURCE_HEIGHT;
129
+ this.enableDiffRendering = options.enableDiffRendering ?? true;
130
+ // Post-processing effects
131
+ this.gamma = options.gamma ?? DEFAULT_GAMMA;
132
+ this.scanlines = options.scanlines ?? DEFAULT_SCANLINES;
133
+ this.saturation = options.saturation ?? DEFAULT_SATURATION;
134
+ this.brightness = options.brightness ?? DEFAULT_BRIGHTNESS;
135
+ this.contrast = options.contrast ?? DEFAULT_CONTRAST;
136
+ this.vignette = options.vignette ?? DEFAULT_VIGNETTE;
137
+ // Use dense character set for better detail in ASCII mode
138
+ this.asciiChars = this.asciiMode ? ASCII_CHARS_DENSE : ASCII_CHARS_SIMPLE;
139
+ // Calculate centering offsets
140
+ this.calculateOffsets();
141
+ // Initialize effect lookup tables
142
+ this.initializeEffects();
143
+ }
144
+
145
+ // Calculate centering offsets based on terminal size
146
+ private calculateOffsets(): void {
147
+ const { width: termCols, height: termRows } = getTerminalDimensions();
148
+
149
+ // Leave rows for status line
150
+ const availableRows = termRows - STATUS_LINE_ROWS;
151
+
152
+ // Horizontal centering (0-based for padding)
153
+ // Emoji mode: each character is EMOJI_COLUMN_WIDTH terminal columns wide
154
+ const displayWidth = this.emojiMode ? this.width * EMOJI_COLUMN_WIDTH : this.width;
155
+ this.offsetCol = Math.max(0, Math.floor((termCols - displayWidth) / 2));
156
+
157
+ // Vertical centering (1-based for ANSI escape sequences)
158
+ this.offsetRow = Math.max(1, Math.floor((availableRows - this.height) / 2) + 1);
159
+
160
+ // Pre-compute padding string for centering
161
+ this.paddingString = this.offsetCol > 0 ? ' '.repeat(this.offsetCol) : '';
162
+
163
+ // Invalidate diff cache when offsets change
164
+ this.invalidateDiffCache();
165
+ }
166
+
167
+ // Invalidate the diff cache (called on resize or mode change)
168
+ private invalidateDiffCache(): void {
169
+ this.prevFrameBufferRgb15 = null;
170
+ this.prevOutputGrid = null;
171
+ this.frameNumber = 0;
172
+ }
173
+
174
+ // Initialize effect lookup tables
175
+ private initializeEffects(): void {
176
+ // Check if any effects are enabled
177
+ this.effectsEnabled = this.gamma !== DEFAULT_GAMMA || this.scanlines > DEFAULT_SCANLINES ||
178
+ this.saturation !== DEFAULT_SATURATION || this.brightness !== DEFAULT_BRIGHTNESS ||
179
+ this.contrast !== DEFAULT_CONTRAST || this.vignette > DEFAULT_VIGNETTE;
180
+
181
+ // Build gamma lookup table
182
+ if (this.gamma !== DEFAULT_GAMMA) {
183
+ this.gammaLUT = new Uint8Array(GAMMA_LUT_SIZE);
184
+ for (let i = 0; i < GAMMA_LUT_SIZE; i++) {
185
+ this.gammaLUT[i] = Math.round(Math.pow(i / COLOR_CHANNEL_MAX, this.gamma) * COLOR_CHANNEL_MAX);
186
+ }
187
+ } else {
188
+ this.gammaLUT = null;
189
+ }
190
+
191
+ // Build vignette map (based on display dimensions)
192
+ if (this.vignette > DEFAULT_VIGNETTE) {
193
+ // Use source dimensions for vignette calculation
194
+ // Half-block mode uses 2 vertical pixels per character
195
+ const HALF_BLOCK_VERTICAL_SCALE = 2;
196
+ const vignetteWidth = this.width;
197
+ const vignetteHeight = this.asciiMode || this.emojiMode ? this.height : this.height * HALF_BLOCK_VERTICAL_SCALE;
198
+ this.vignetteMap = new Float32Array(vignetteWidth * vignetteHeight);
199
+ const centerX = vignetteWidth / 2;
200
+ const centerY = vignetteHeight / 2;
201
+ const maxDist = Math.sqrt(centerX * centerX + centerY * centerY);
202
+
203
+ for (let y = 0; y < vignetteHeight; y++) {
204
+ for (let x = 0; x < vignetteWidth; x++) {
205
+ const dx = x - centerX;
206
+ const dy = y - centerY;
207
+ const dist = Math.sqrt(dx * dx + dy * dy) / maxDist;
208
+ // Smooth falloff using squared distance
209
+ const factor = 1.0 - this.vignette * dist * dist;
210
+ this.vignetteMap[y * vignetteWidth + x] = Math.max(0, factor);
211
+ }
212
+ }
213
+ } else {
214
+ this.vignetteMap = null;
215
+ }
216
+ }
217
+
218
+ // Apply all effects to an RGB color
219
+ // charX, charY are in character coordinates; isScanlineRow indicates output-based scanline
220
+ private applyEffects(r: number, g: number, b: number, charX: number, charY: number, isScanlineRow: boolean): [number, number, number] {
221
+ if (!this.effectsEnabled) {
222
+ return [r, g, b];
223
+ }
224
+
225
+ // Apply saturation
226
+ if (this.saturation !== DEFAULT_SATURATION) {
227
+ const gray = LUMINANCE_R * r + LUMINANCE_G * g + LUMINANCE_B * b;
228
+ r = Math.round(gray + this.saturation * (r - gray));
229
+ g = Math.round(gray + this.saturation * (g - gray));
230
+ b = Math.round(gray + this.saturation * (b - gray));
231
+ }
232
+
233
+ // Apply brightness
234
+ if (this.brightness !== DEFAULT_BRIGHTNESS) {
235
+ r = Math.round(r * this.brightness);
236
+ g = Math.round(g * this.brightness);
237
+ b = Math.round(b * this.brightness);
238
+ }
239
+
240
+ // Apply contrast
241
+ if (this.contrast !== DEFAULT_CONTRAST) {
242
+ r = Math.round((r - CONTRAST_MIDPOINT) * this.contrast + CONTRAST_MIDPOINT);
243
+ g = Math.round((g - CONTRAST_MIDPOINT) * this.contrast + CONTRAST_MIDPOINT);
244
+ b = Math.round((b - CONTRAST_MIDPOINT) * this.contrast + CONTRAST_MIDPOINT);
245
+ }
246
+
247
+ // Apply gamma correction
248
+ if (this.gammaLUT) {
249
+ r = this.gammaLUT[clamp(r, { min: 0, max: COLOR_CHANNEL_MAX })];
250
+ g = this.gammaLUT[clamp(g, { min: 0, max: COLOR_CHANNEL_MAX })];
251
+ b = this.gammaLUT[clamp(b, { min: 0, max: COLOR_CHANNEL_MAX })];
252
+ }
253
+
254
+ // Apply vignette
255
+ if (this.vignetteMap) {
256
+ const vignetteWidth = this.width;
257
+ const idx = Math.min(charY * vignetteWidth + charX, this.vignetteMap.length - 1);
258
+ const factor = this.vignetteMap[Math.max(0, idx)];
259
+ r = Math.round(r * factor);
260
+ g = Math.round(g * factor);
261
+ b = Math.round(b * factor);
262
+ }
263
+
264
+ // Apply scanlines (output-based: consistent spacing regardless of scale)
265
+ if (this.scanlines > DEFAULT_SCANLINES && isScanlineRow) {
266
+ const darkFactor = DEFAULT_BRIGHTNESS - this.scanlines;
267
+ r = Math.round(r * darkFactor);
268
+ g = Math.round(g * darkFactor);
269
+ b = Math.round(b * darkFactor);
270
+ }
271
+
272
+ // Clamp values
273
+ return [
274
+ clamp(r, { min: 0, max: COLOR_CHANNEL_MAX }),
275
+ clamp(g, { min: 0, max: COLOR_CHANNEL_MAX }),
276
+ clamp(b, { min: 0, max: COLOR_CHANNEL_MAX })
277
+ ];
278
+ }
279
+
280
+ // Reset color state (call at start of frame or after cursor movement)
281
+ private resetColorState(): void {
282
+ this.currentFg = -1;
283
+ this.currentBg = -1;
284
+ }
285
+
286
+ // Pack RGB into a single number for comparison
287
+ private packRgb(r: number, g: number, b: number): number {
288
+ return (r << PACK_RED_SHIFT) | (g << PACK_GREEN_SHIFT) | b;
289
+ }
290
+
291
+ // Emit foreground color escape sequence only if changed
292
+ private emitFg(r: number, g: number, b: number): string {
293
+ const packed = this.packRgb(r, g, b);
294
+ if (packed === this.currentFg) {return '';}
295
+ this.currentFg = packed;
296
+ return fgTrueColor(r, g, b);
297
+ }
298
+
299
+ // Emit background color escape sequence only if changed
300
+ private emitBg(r: number, g: number, b: number): string {
301
+ const packed = this.packRgb(r, g, b);
302
+ if (packed === this.currentBg) {return '';}
303
+ this.currentBg = packed;
304
+ return bgTrueColor(r, g, b);
305
+ }
306
+
307
+ // Emit ANSI 256 foreground color only if changed
308
+ private emitFg256(code: number): string {
309
+ if (code === this.currentFg) {return '';}
310
+ this.currentFg = code;
311
+ return fgAnsi256(code);
312
+ }
313
+
314
+ // Emit ANSI 256 background color only if changed
315
+ private emitBg256(code: number): string {
316
+ if (code === this.currentBg) {return '';}
317
+ this.currentBg = code;
318
+ return bgAnsi256(code);
319
+ }
320
+
321
+ // Simple full-frame render for RGB15 without any diff caching
322
+ // Optimized: tracks color state to avoid redundant escape codes
323
+ private renderFullFrameSimpleRgb15(frameBuffer: Uint16Array, scaleX: number, scaleY: number): string {
324
+ const output: string[] = [];
325
+
326
+ for (let charY = 0; charY < this.height; charY++) {
327
+ // Reset color state at start of each line
328
+ this.resetColorState();
329
+ let line = this.paddingString;
330
+
331
+ if (this.emojiMode) {
332
+ for (let charX = 0; charX < this.width; charX++) {
333
+ const srcX = Math.floor(charX * scaleX);
334
+ const srcY = Math.floor(charY * scaleY);
335
+ const pixel = frameBuffer[srcY * this.sourceWidth + srcX];
336
+ line += this.colorEnabled ? rgb15ToEmoji(pixel) : rgb15ToGrayscaleEmoji(pixel);
337
+ }
338
+ } else if (this.asciiMode) {
339
+ for (let charX = 0; charX < this.width; charX++) {
340
+ const srcX = Math.floor(charX * scaleX);
341
+ const srcY = Math.floor(charY * scaleY);
342
+ const pixel = frameBuffer[srcY * this.sourceWidth + srcX];
343
+ const lum = rgb15ToLuminance(pixel);
344
+ const char = this.grayscaleChar(lum);
345
+ if (this.colorEnabled) {
346
+ let [r, g, b] = rgb15ToRgb24(pixel);
347
+ [r, g, b] = this.applyEffects(r, g, b, charX, charY, (charY & 1) === 1);
348
+ line += this.emitFg(r, g, b) + char;
349
+ } else {
350
+ line += char;
351
+ }
352
+ }
353
+ if (this.colorEnabled) {line += RESET;}
354
+ } else {
355
+ // Terminal mode: half-block characters with color batching
356
+ for (let charX = 0; charX < this.width; charX++) {
357
+ const srcX = Math.floor(charX * scaleX);
358
+ const srcY1 = Math.floor(charY * 2 * scaleY);
359
+ const srcY2 = Math.floor((charY * 2 + 1) * scaleY);
360
+ const topPixel = frameBuffer[srcY1 * this.sourceWidth + srcX];
361
+ const bottomPixel = frameBuffer[srcY2 * this.sourceWidth + srcX];
362
+
363
+ if (this.colorEnabled) {
364
+ if (this.trueColorEnabled) {
365
+ let [r1, g1, b1] = rgb15ToRgb24(topPixel);
366
+ let [r2, g2, b2] = rgb15ToRgb24(bottomPixel);
367
+ [r1, g1, b1] = this.applyEffects(r1, g1, b1, charX, charY, false);
368
+ [r2, g2, b2] = this.applyEffects(r2, g2, b2, charX, charY, true);
369
+ line += this.emitFg(r1, g1, b1);
370
+ line += this.emitBg(r2, g2, b2);
371
+ line += HALF_BLOCK_TOP;
372
+ } else {
373
+ let [r1, g1, b1] = rgb15ToRgb24(topPixel);
374
+ let [r2, g2, b2] = rgb15ToRgb24(bottomPixel);
375
+ [r1, g1, b1] = this.applyEffects(r1, g1, b1, charX, charY, false);
376
+ [r2, g2, b2] = this.applyEffects(r2, g2, b2, charX, charY, true);
377
+ const fg = rgbToAnsi256(r1, g1, b1);
378
+ const bg = rgbToAnsi256(r2, g2, b2);
379
+ line += this.emitFg256(fg) + this.emitBg256(bg) + HALF_BLOCK_TOP;
380
+ }
381
+ } else {
382
+ const lumTop = rgb15ToLuminance(topPixel);
383
+ const lumBottom = rgb15ToLuminance(bottomPixel);
384
+ let grayTop = Math.round(lumTop * COLOR_CHANNEL_MAX);
385
+ let grayBottom = Math.round(lumBottom * COLOR_CHANNEL_MAX);
386
+ [grayTop, , ] = this.applyEffects(grayTop, grayTop, grayTop, charX, charY, false);
387
+ [grayBottom, , ] = this.applyEffects(grayBottom, grayBottom, grayBottom, charX, charY, true);
388
+ line += this.emitFg(grayTop, grayTop, grayTop);
389
+ line += this.emitBg(grayBottom, grayBottom, grayBottom);
390
+ line += HALF_BLOCK_TOP;
391
+ }
392
+ }
393
+ line += RESET;
394
+ }
395
+ output.push(line);
396
+ }
397
+
398
+ this.frameNumber++;
399
+ return this.moveCursorHome() + output.join('\n');
400
+ }
401
+
402
+ // Render a single character for RGB15 mode
403
+ // Returns the ANSI escape sequence + character for this position
404
+ private renderCharRgb15(frameBuffer: Uint16Array, charX: number, charY: number, scaleX: number, scaleY: number): string {
405
+ const srcX = Math.floor(charX * scaleX);
406
+
407
+ if (this.emojiMode) {
408
+ const srcY = Math.floor(charY * scaleY);
409
+ const pixel = frameBuffer[srcY * this.sourceWidth + srcX];
410
+ return this.colorEnabled ? rgb15ToEmoji(pixel) : rgb15ToGrayscaleEmoji(pixel);
411
+ } else if (this.asciiMode) {
412
+ const srcY = Math.floor(charY * scaleY);
413
+ const pixel = frameBuffer[srcY * this.sourceWidth + srcX];
414
+ const lum = rgb15ToLuminance(pixel);
415
+ const char = this.grayscaleChar(lum);
416
+
417
+ if (this.colorEnabled) {
418
+ return rgb15ToTrueColor(pixel) + char + RESET;
419
+ } else {
420
+ return char;
421
+ }
422
+ } else {
423
+ // Terminal mode: half-block characters
424
+ const srcY1 = Math.floor(charY * 2 * scaleY);
425
+ const srcY2 = Math.floor((charY * 2 + 1) * scaleY);
426
+
427
+ const topPixel = frameBuffer[srcY1 * this.sourceWidth + srcX];
428
+ const bottomPixel = frameBuffer[srcY2 * this.sourceWidth + srcX];
429
+
430
+ if (this.colorEnabled) {
431
+ if (this.trueColorEnabled) {
432
+ return rgb15ToTrueColor(topPixel) + rgb15ToBgTrueColor(bottomPixel) + HALF_BLOCK_TOP + RESET;
433
+ } else {
434
+ const [r1, g1, b1] = rgb15ToRgb24(topPixel);
435
+ const [r2, g2, b2] = rgb15ToRgb24(bottomPixel);
436
+ const fg = rgbToAnsi256(r1, g1, b1);
437
+ const bg = rgbToAnsi256(r2, g2, b2);
438
+ return fgAnsi256(fg) + bgAnsi256(bg) + HALF_BLOCK_TOP + RESET;
439
+ }
440
+ } else {
441
+ // Grayscale mode: use half-blocks with grayscale ANSI colors
442
+ const lumTop = rgb15ToLuminance(topPixel);
443
+ const lumBottom = rgb15ToLuminance(bottomPixel);
444
+ const grayTop = Math.round(lumTop * COLOR_CHANNEL_MAX);
445
+ const grayBottom = Math.round(lumBottom * COLOR_CHANNEL_MAX);
446
+ return fgTrueColor(grayTop, grayTop, grayTop) + bgTrueColor(grayBottom, grayBottom, grayBottom) + HALF_BLOCK_TOP + RESET;
447
+ }
448
+ }
449
+ }
450
+
451
+ // Check if source pixels for a character position have changed (RGB15 version)
452
+ private hasPixelChangedRgb15(
453
+ frameBuffer: Uint16Array,
454
+ prevFrameBuffer: Uint16Array,
455
+ charX: number,
456
+ charY: number,
457
+ scaleX: number,
458
+ scaleY: number
459
+ ): boolean {
460
+ const srcX = Math.floor(charX * scaleX);
461
+
462
+ if (this.asciiMode || this.emojiMode) {
463
+ const srcY = Math.floor(charY * scaleY);
464
+ const idx = srcY * this.sourceWidth + srcX;
465
+ return frameBuffer[idx] !== prevFrameBuffer[idx];
466
+ } else {
467
+ const srcY1 = Math.floor(charY * 2 * scaleY);
468
+ const srcY2 = Math.floor((charY * 2 + 1) * scaleY);
469
+ const idx1 = srcY1 * this.sourceWidth + srcX;
470
+ const idx2 = srcY2 * this.sourceWidth + srcX;
471
+ return frameBuffer[idx1] !== prevFrameBuffer[idx1] ||
472
+ frameBuffer[idx2] !== prevFrameBuffer[idx2];
473
+ }
474
+ }
475
+
476
+ // Render full frame without diff optimization for RGB15 (used for first frame or when >50% changed)
477
+ // Uses color batching for output, but still updates grid for future diff comparisons
478
+ private renderFullFrameRgb15(frameBuffer: Uint16Array, scaleX: number, scaleY: number): string {
479
+ const outputGrid = this.prevOutputGrid!;
480
+
481
+ // Update all characters in output grid (needed for future diff comparisons)
482
+ for (let y = 0; y < this.height; y++) {
483
+ for (let x = 0; x < this.width; x++) {
484
+ outputGrid[y][x] = this.renderCharRgb15(frameBuffer, x, y, scaleX, scaleY);
485
+ }
486
+ }
487
+
488
+ // Update previous frame buffer
489
+ this.prevFrameBufferRgb15!.set(frameBuffer);
490
+
491
+ // Render with color batching (more efficient than joining grid entries)
492
+ const result = this.renderFullFrameSimpleRgb15(frameBuffer, scaleX, scaleY);
493
+
494
+ // renderFullFrameSimpleRgb15 increments frameNumber, so don't double-increment
495
+ return result;
496
+ }
497
+
498
+ // Render RGB15 frame buffer (for GBC and other RGB15 cores) with diff-based optimization
499
+ renderRgb15(frameBuffer: Uint16Array): string {
500
+ const scaleX = this.sourceWidth / this.width;
501
+ const scaleY = (this.asciiMode || this.emojiMode)
502
+ ? this.sourceHeight / this.height
503
+ : this.sourceHeight / (this.height * 2);
504
+
505
+ // If diff rendering is disabled, always render the full frame
506
+ if (!this.enableDiffRendering) {
507
+ return this.renderFullFrameSimpleRgb15(frameBuffer, scaleX, scaleY);
508
+ }
509
+
510
+ // Check if frame buffer size changed (can happen with SNES resolution changes)
511
+ if (this.prevFrameBufferRgb15 !== null && frameBuffer.length !== this.prevFrameBufferRgb15.length) {
512
+ this.prevFrameBufferRgb15 = new Uint16Array(frameBuffer.length);
513
+ }
514
+
515
+ // First frame or after cache invalidation: render everything
516
+ const isFirstFrame = this.prevFrameBufferRgb15 === null || this.prevOutputGrid === null;
517
+
518
+ if (isFirstFrame) {
519
+ // Initialize previous frame buffer
520
+ this.prevFrameBufferRgb15 = new Uint16Array(frameBuffer.length);
521
+ this.prevFrameBufferRgb15.set(frameBuffer);
522
+
523
+ // Initialize output grid (needed for future diff comparisons)
524
+ this.prevOutputGrid = [];
525
+ for (let y = 0; y < this.height; y++) {
526
+ this.prevOutputGrid[y] = [];
527
+ for (let x = 0; x < this.width; x++) {
528
+ this.prevOutputGrid[y][x] = this.renderCharRgb15(frameBuffer, x, y, scaleX, scaleY);
529
+ }
530
+ }
531
+
532
+ // Render with color batching (renderFullFrameSimpleRgb15 increments frameNumber)
533
+ return this.renderFullFrameSimpleRgb15(frameBuffer, scaleX, scaleY);
534
+ }
535
+
536
+ // These are guaranteed non-null after the isFirstFrame check above
537
+ const prevFrame = this.prevFrameBufferRgb15!;
538
+ const outputGrid = this.prevOutputGrid!;
539
+
540
+ // Count changed characters to decide whether to use diff or full render
541
+ const totalChars = this.width * this.height;
542
+ let changedCount = 0;
543
+
544
+ for (let charY = 0; charY < this.height && changedCount <= totalChars / 2; charY++) {
545
+ for (let charX = 0; charX < this.width && changedCount <= totalChars / 2; charX++) {
546
+ if (this.hasPixelChangedRgb15(frameBuffer, prevFrame, charX, charY, scaleX, scaleY)) {
547
+ changedCount++;
548
+ }
549
+ }
550
+ }
551
+
552
+ // If more than 50% changed, use full frame render (more efficient)
553
+ if (changedCount > totalChars / 2) {
554
+ return this.renderFullFrameRgb15(frameBuffer, scaleX, scaleY);
555
+ }
556
+
557
+ // Diff-based rendering: only output changed characters
558
+ const output: string[] = [];
559
+
560
+ // Column width in terminal cells (emoji is EMOJI_COLUMN_WIDTH columns wide)
561
+ const colWidth = this.emojiMode ? EMOJI_COLUMN_WIDTH : 1;
562
+
563
+ for (let charY = 0; charY < this.height; charY++) {
564
+ // Find runs of changed characters on this line
565
+ let runStart = -1;
566
+ let runChars: string[] = [];
567
+
568
+ for (let charX = 0; charX < this.width; charX++) {
569
+ const changed = this.hasPixelChangedRgb15(frameBuffer, prevFrame, charX, charY, scaleX, scaleY);
570
+
571
+ if (changed) {
572
+ // Render the new character
573
+ const charStr = this.renderCharRgb15(frameBuffer, charX, charY, scaleX, scaleY);
574
+ outputGrid[charY][charX] = charStr;
575
+
576
+ if (runStart === -1) {
577
+ runStart = charX;
578
+ }
579
+ runChars.push(charStr);
580
+ } else if (runStart !== -1) {
581
+ // End of a run - check if we should continue or output
582
+ const gapEnd = Math.min(charX + DIFF_GAP_THRESHOLD, this.width);
583
+ let nextChanged = -1;
584
+ for (let i = charX; i < gapEnd; i++) {
585
+ if (this.hasPixelChangedRgb15(frameBuffer, prevFrame, i, charY, scaleX, scaleY)) {
586
+ nextChanged = i;
587
+ break;
588
+ }
589
+ }
590
+
591
+ if (nextChanged !== -1) {
592
+ // Small gap - include unchanged chars to avoid cursor movement
593
+ for (let i = charX; i < nextChanged; i++) {
594
+ runChars.push(outputGrid[charY][i]);
595
+ }
596
+ // Skip past the gap - loop will increment to nextChanged
597
+ charX = nextChanged - 1;
598
+ } else {
599
+ // Large gap or end of line - output the run
600
+ const row = this.offsetRow + charY;
601
+ const col = this.offsetCol + runStart * colWidth + 1;
602
+ output.push(moveCursor(row, col) + runChars.join(''));
603
+ runStart = -1;
604
+ runChars = [];
605
+ }
606
+ }
607
+ }
608
+
609
+ // Output any remaining run at end of line
610
+ if (runStart !== -1) {
611
+ const row = this.offsetRow + charY;
612
+ const col = this.offsetCol + runStart * colWidth + 1;
613
+ output.push(moveCursor(row, col) + runChars.join(''));
614
+ }
615
+ }
616
+
617
+ // Update previous frame buffer
618
+ prevFrame.set(frameBuffer);
619
+ this.frameNumber++;
620
+
621
+ // If nothing changed, return empty string
622
+ if (output.length === 0) {
623
+ return '';
624
+ }
625
+
626
+ return output.join('');
627
+ }
628
+
629
+ // Render RGB24 frame buffer (for libretro and other RGB24 cores)
630
+ // Optimized: tracks color state to avoid redundant escape codes
631
+ renderRgb24(frameBuffer: Uint8Array): string {
632
+ const scaleX = this.sourceWidth / this.width;
633
+ const scaleY = (this.asciiMode || this.emojiMode)
634
+ ? this.sourceHeight / this.height
635
+ : this.sourceHeight / (this.height * 2);
636
+
637
+ const output: string[] = [];
638
+
639
+ for (let charY = 0; charY < this.height; charY++) {
640
+ // Reset color state at start of each line
641
+ this.resetColorState();
642
+ let line = this.paddingString;
643
+
644
+ if (this.emojiMode) {
645
+ for (let charX = 0; charX < this.width; charX++) {
646
+ const srcX = Math.floor(charX * scaleX);
647
+ const srcY = Math.floor(charY * scaleY);
648
+ const idx = (srcY * this.sourceWidth + srcX) * RGB24_BYTES_PER_PIXEL;
649
+ const r = frameBuffer[idx];
650
+ const g = frameBuffer[idx + 1];
651
+ const b = frameBuffer[idx + 2];
652
+ line += this.colorEnabled ? rgb24ToEmoji(r, g, b) : rgb24ToGrayscaleEmoji(r, g, b);
653
+ }
654
+ } else if (this.asciiMode) {
655
+ for (let charX = 0; charX < this.width; charX++) {
656
+ const srcX = Math.floor(charX * scaleX);
657
+ const srcY = Math.floor(charY * scaleY);
658
+ const idx = (srcY * this.sourceWidth + srcX) * RGB24_BYTES_PER_PIXEL;
659
+ let r = frameBuffer[idx];
660
+ let g = frameBuffer[idx + 1];
661
+ let b = frameBuffer[idx + 2];
662
+ const lum = rgb24ToLuminance(r, g, b);
663
+ const char = this.grayscaleChar(lum);
664
+ if (this.colorEnabled) {
665
+ [r, g, b] = this.applyEffects(r, g, b, charX, charY, (charY & 1) === 1);
666
+ line += this.emitFg(r, g, b) + char;
667
+ } else {
668
+ line += char;
669
+ }
670
+ }
671
+ if (this.colorEnabled) {line += RESET;}
672
+ } else {
673
+ // Terminal mode: half-block characters with color batching
674
+ for (let charX = 0; charX < this.width; charX++) {
675
+ const srcX = Math.floor(charX * scaleX);
676
+ const srcY1 = Math.floor(charY * 2 * scaleY);
677
+ const srcY2 = Math.floor((charY * 2 + 1) * scaleY);
678
+ const idx1 = (srcY1 * this.sourceWidth + srcX) * RGB24_BYTES_PER_PIXEL;
679
+ const idx2 = (srcY2 * this.sourceWidth + srcX) * RGB24_BYTES_PER_PIXEL;
680
+ let r1 = frameBuffer[idx1];
681
+ let g1 = frameBuffer[idx1 + 1];
682
+ let b1 = frameBuffer[idx1 + 2];
683
+ let r2 = frameBuffer[idx2];
684
+ let g2 = frameBuffer[idx2 + 1];
685
+ let b2 = frameBuffer[idx2 + 2];
686
+
687
+ if (this.colorEnabled) {
688
+ if (this.trueColorEnabled) {
689
+ [r1, g1, b1] = this.applyEffects(r1, g1, b1, charX, charY, false);
690
+ [r2, g2, b2] = this.applyEffects(r2, g2, b2, charX, charY, true);
691
+ line += this.emitFg(r1, g1, b1);
692
+ line += this.emitBg(r2, g2, b2);
693
+ line += HALF_BLOCK_TOP;
694
+ } else {
695
+ [r1, g1, b1] = this.applyEffects(r1, g1, b1, charX, charY, false);
696
+ [r2, g2, b2] = this.applyEffects(r2, g2, b2, charX, charY, true);
697
+ const fg = rgbToAnsi256(r1, g1, b1);
698
+ const bg = rgbToAnsi256(r2, g2, b2);
699
+ line += this.emitFg256(fg) + this.emitBg256(bg) + HALF_BLOCK_TOP;
700
+ }
701
+ } else {
702
+ const lum1 = rgb24ToLuminance(r1, g1, b1);
703
+ const lum2 = rgb24ToLuminance(r2, g2, b2);
704
+ let grayTop = Math.round(lum1 * COLOR_CHANNEL_MAX);
705
+ let grayBottom = Math.round(lum2 * COLOR_CHANNEL_MAX);
706
+ [grayTop, , ] = this.applyEffects(grayTop, grayTop, grayTop, charX, charY, false);
707
+ [grayBottom, , ] = this.applyEffects(grayBottom, grayBottom, grayBottom, charX, charY, true);
708
+ line += this.emitFg(grayTop, grayTop, grayTop);
709
+ line += this.emitBg(grayBottom, grayBottom, grayBottom);
710
+ line += HALF_BLOCK_TOP;
711
+ }
712
+ }
713
+ line += RESET;
714
+ }
715
+ output.push(line);
716
+ }
717
+
718
+ this.frameNumber++;
719
+ return this.moveCursorHome() + output.join('\n');
720
+ }
721
+
722
+ // Convert luminance to ASCII character
723
+ private grayscaleChar(luminance: number): string {
724
+ const index = Math.floor(luminance * (this.asciiChars.length - 1));
725
+ return this.asciiChars[Math.min(index, this.asciiChars.length - 1)];
726
+ }
727
+
728
+ // Get ANSI escape sequence to move cursor to centered start position
729
+ moveCursorHome(): string {
730
+ return moveCursor(this.offsetRow, 1);
731
+ }
732
+
733
+ // Clear screen
734
+ clearScreen(): string {
735
+ return clearScreen();
736
+ }
737
+
738
+ // Hide cursor
739
+ hideCursor(): string {
740
+ return hideCursor();
741
+ }
742
+
743
+ // Show cursor
744
+ showCursor(): string {
745
+ return showCursor();
746
+ }
747
+
748
+ // Get the row number for the status line (below the rendered frame)
749
+ getStatusRow(): number {
750
+ return this.offsetRow + this.height;
751
+ }
752
+
753
+ // Move cursor to a specific row
754
+ moveCursorToRow(row: number): string {
755
+ return moveCursorToRow(row);
756
+ }
757
+
758
+ // Update display dimensions (for terminal resize handling)
759
+ setDimensions(width: number, height: number): void {
760
+ this.width = width;
761
+ this.height = height;
762
+ // Recalculate centering offsets
763
+ this.calculateOffsets();
764
+ }
765
+
766
+ // Get current dimensions
767
+ getDimensions(): { width: number; height: number } {
768
+ return { width: this.width, height: this.height };
769
+ }
770
+ }