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,1195 @@
1
+ # Native UI Rendering - Technical Requirements Document
2
+
3
+ This document describes the requirements and implementation approach for rendering the Ink-based UI (ROM browser, settings, dialogs) through a native window instead of the terminal.
4
+
5
+ > **Implementation note:** This design shipped via the [`ink-native`](https://www.npmjs.com/package/ink-native) package — a bundled `fenster` native window backend plus an embedded Cozette bitmap font, with **zero system dependencies** to install. The CLI flag is `--native` and the config value is `video_driver = "native"`. UI and game share a single native window created once via `createStreams()`; the app hands the window off between UI and game with `window.pause()` / `window.resume()`, and the game renderer writes each post-processed frame directly into ink-native's shared `Uint32Array` framebuffer (`0xAARRGGBB`, via `packColor` + `renderer.present()`). This replaced the custom-SDL2-bindings approach originally proposed in this document.
6
+ >
7
+ > **Known limitations of the shipped implementation:** no runtime `setTitle` (ink-native exposes none), no runtime scale-factor change (`menu_scale_factor` maps to ink-native's `scaleFactor`, which only applies at window creation), and no programmatic window resize (the game letterboxes into the fixed window instead).
8
+ >
9
+ > Aside from the "Overview", "Configuration", and "Dependencies" sections (updated to reflect what shipped), the remaining sections below — Implementation Approaches, Text Rendering internals, SDL Bindings Extensions, ANSI Sequence Parsing, Input Handling, UI Window Management, Rendering Pipeline, File Structure, Integration Point, Implementation Phases, Testing Strategy, HiDPI / Retina Display Support, and Alternatives Considered — describe the original custom-SDL2-bindings exploration that predated adopting `ink-native`. They are retained as historical design context — `ink-native` handles window, font, and input internally, so emoemu does not implement any of this bespoke SDL/TTF/ANSI-parsing code, and file/class names like `sdl-ui/`, `SdlUiRenderer`, or `SdlWindowManager` do not exist in the shipped code. Where a passage below asserts something as a current requirement that is no longer true (e.g. a stated dependency on SDL2_ttf), treat this note as the correction.
10
+
11
+ **Key principle:** UI rendering mode follows game rendering mode. When a user selects native rendering (`--native` or `video_driver = "native"`), both the UI and game render to the native window. When a user selects terminal rendering modes (kitty, terminal, ascii, emoji), both UI and game render to the terminal. There is no separate UI mode selection.
12
+
13
+ ## Overview
14
+
15
+ ### Goals
16
+
17
+ 1. **Unified Experience Per Mode**: UI rendering follows game rendering mode - native mode renders everything in the native window, terminal modes render everything in terminal
18
+ 2. **Consistent Visuals**: UI and game share the same output target, eliminating context switches
19
+ 3. **Seamless Transitions**: No terminal/window split when using native mode
20
+ 4. **Preserve Terminal Mode**: Terminal rendering modes (kitty, terminal, ascii, emoji) continue to work exactly as today
21
+
22
+ ### Non-Goals
23
+
24
+ 1. Separate UI renderer selection - UI mode always matches game renderer mode
25
+ 2. Complex GUI toolkit features (drag-and-drop, advanced animations)
26
+ 3. Custom widget rendering (reuse Ink's component model where possible)
27
+ 4. GPU-accelerated UI rendering
28
+
29
+ ### Background: Pre-`ink-native` Architecture
30
+
31
+ Before this TRD's design shipped, the (now-removed) `ink-sdl` architecture separated UI and game rendering when SDL mode was selected:
32
+
33
+ ```
34
+ ┌─────────────────────────────────────┐
35
+ │ Terminal (Ink.js/React) │
36
+ │ ├── ROM Browser │
37
+ │ ├── Settings Panel │
38
+ │ ├── Core Selector │
39
+ │ └── Dialogs │
40
+ └─────────────────────────────────────┘
41
+ ↓ (user selects ROM)
42
+ stdin reset
43
+
44
+ ┌─────────────────────────────────────┐
45
+ │ Native Window (SDL Renderer) │
46
+ │ ├── Game Output │
47
+ │ └── Status Bar │
48
+ └─────────────────────────────────────┘
49
+ ```
50
+
51
+ **Pain points (SDL mode only):**
52
+
53
+ | Issue | Impact |
54
+ |-------|--------|
55
+ | **Context switching** | User sees terminal → SDL window → terminal transitions |
56
+ | **Visual inconsistency** | Terminal UI looks different from SDL game display |
57
+ | **Input mode changes** | Keyboard handling differs between terminal (Kitty protocol) and SDL |
58
+ | **Window management** | Two separate contexts to manage |
59
+
60
+ **Note:** Terminal modes (kitty, terminal, ascii, emoji) don't have this problem - everything renders to the terminal consistently. This TRD addresses the native/SDL mode experience only.
61
+
62
+ ### Target Architecture (Shipped, via `ink-native`)
63
+
64
+ The render mode selection determines both UI and game output:
65
+
66
+ **Terminal Modes (kitty, terminal, ascii, emoji):**
67
+ ```
68
+ ┌─────────────────────────────────────┐
69
+ │ Terminal (unified) │
70
+ │ ├── ROM Browser (Ink) │
71
+ │ ├── Game Output (selected mode) │
72
+ │ └── Status Bar │
73
+ └─────────────────────────────────────┘
74
+ (no change from today)
75
+ ```
76
+
77
+ **Native Mode:**
78
+ ```
79
+ ┌─────────────────────────────────────────┐
80
+ │ Native Window (unified) │
81
+ │ ├── ROM Browser (Ink, via ink-native) │
82
+ │ ├── Game Output (framebuffer blit) │
83
+ │ └── Status Bar │
84
+ └─────────────────────────────────────────┘
85
+ (unified experience - shipped)
86
+ ```
87
+
88
+ ---
89
+
90
+ ## Architecture
91
+
92
+ ### Ink Rendering Model
93
+
94
+ Ink uses a custom renderer built on React's reconciler. Key concepts:
95
+
96
+ ```typescript
97
+ // Ink's render pipeline
98
+ React Component → Ink Reconciler → Output (yoga layout) → Terminal Output
99
+
100
+ // Terminal output generates ANSI escape sequences
101
+ // Written to stdout via ink's internal render loop
102
+ ```
103
+
104
+ **Key files in Ink:**
105
+
106
+ - `ink/src/render.ts` - Reconciler and render loop
107
+ - `ink/src/output.ts` - Terminal output generation
108
+ - `ink/src/components/` - Box, Text, and other primitives
109
+
110
+ ### Proposed Solution: Custom Ink Output Target
111
+
112
+ Create a custom output target that renders to an SDL texture instead of generating ANSI sequences:
113
+
114
+ ```
115
+ React Component → Ink Reconciler → Yoga Layout → SDL Texture Output
116
+
117
+ SDL Window Display
118
+ ```
119
+
120
+ ### High-Level Architecture
121
+
122
+ ```typescript
123
+ // New rendering pipeline
124
+ interface SdlUiRenderer {
125
+ // Receive layout from Ink
126
+ renderFrame(nodes: InkNode[]): void;
127
+
128
+ // Text rendering
129
+ drawText(text: string, x: number, y: number, style: TextStyle): void;
130
+
131
+ // Box rendering
132
+ drawBox(x: number, y: number, width: number, height: number, style: BoxStyle): void;
133
+
134
+ // Present to screen
135
+ present(): void;
136
+ }
137
+ ```
138
+
139
+ ---
140
+
141
+ ## Implementation Approaches
142
+
143
+ ### Option A: Fork Ink's Output Layer
144
+
145
+ Modify Ink's output generation to target SDL instead of terminal:
146
+
147
+ **Pros:**
148
+ - Maintains full Ink component compatibility
149
+ - Existing UI code works unchanged
150
+ - React devtools and debugging still work
151
+
152
+ **Cons:**
153
+ - Requires deep understanding of Ink internals
154
+ - May break with Ink updates
155
+ - Complex integration with yoga layout
156
+
157
+ ### Option B: Custom React Renderer for SDL
158
+
159
+ Build a new React reconciler that renders directly to SDL:
160
+
161
+ **Pros:**
162
+ - Clean separation from Ink
163
+ - Full control over rendering pipeline
164
+ - Can optimize for SDL-specific features
165
+
166
+ **Cons:**
167
+ - Significant implementation effort
168
+ - Must reimplement component primitives (Box, Text, etc.)
169
+ - Duplicates much of Ink's work
170
+
171
+ ### Option C: Ink Output Interception (Recommended)
172
+
173
+ Intercept Ink's rendered output and convert to SDL rendering commands:
174
+
175
+ ```typescript
176
+ // Intercept Ink's stdout writes
177
+ const inkOutputStream = new InkToSdlStream(sdlRenderer);
178
+ render(<App />, { stdout: inkOutputStream });
179
+
180
+ class InkToSdlStream extends Writable {
181
+ write(chunk: Buffer): boolean {
182
+ const text = chunk.toString();
183
+ // Parse ANSI sequences and convert to SDL draw calls
184
+ this.parseAndRender(text);
185
+ return true;
186
+ }
187
+ }
188
+ ```
189
+
190
+ **Pros:**
191
+ - Minimal changes to existing Ink code
192
+ - Works with current UI components
193
+ - Fallback to terminal is trivial (just use real stdout)
194
+
195
+ **Cons:**
196
+ - ANSI parsing adds complexity
197
+ - Some terminal features may not map cleanly to SDL
198
+ - Character-based positioning requires font metrics
199
+
200
+ **Recommendation**: Option C provides the best balance of effort vs. compatibility.
201
+
202
+ ---
203
+
204
+ ## Text Rendering
205
+
206
+ ### Font Requirements
207
+
208
+ | Requirement | Rationale |
209
+ |-------------|-----------|
210
+ | **Monospace** | Ink layouts assume fixed-width characters |
211
+ | **Unicode support** | Box-drawing characters, symbols |
212
+ | **HiDPI support** | Crisp rendering at any scale factor |
213
+ | **Consistent metrics** | Layout must match Ink's character grid |
214
+
215
+ ### Approach Comparison
216
+
217
+ | Factor | TTF (SDL_ttf) | Bitmap Atlas |
218
+ |--------|---------------|--------------|
219
+ | **HiDPI handling** | Excellent - render at exact physical size | Requires multiple atlases (1x, 2x, 3x) |
220
+ | **Fractional scaling** | Native support (Windows 125%, 150%) | Must round to nearest integer |
221
+ | **Display changes** | Re-open font at new size | Swap atlas, may cause visual jump |
222
+ | **Unicode coverage** | Full (depends on font) | Limited to pre-rendered glyphs |
223
+ | **Render performance** | Moderate (glyph caching helps) | Fast (simple texture blits) |
224
+ | **Asset management** | Single .ttf file | Multiple .png atlases |
225
+ | **Aesthetic** | Modern, smooth | Retro, pixel-perfect |
226
+
227
+ ### Recommendation: TTF with Pixel Font (Updated)
228
+
229
+ > **Shipped reality:** `ink-native` uses a fixed, embedded **Cozette bitmap font** — not TTF. There is no runtime font selection or re-rasterization; the font is baked into the package and scales via the fixed `scaleFactor` set at window creation. The TTF analysis below was the pre-`ink-native` exploration and does not describe the shipped font pipeline.
230
+
231
+ Given HiDPI is a core requirement, **TTF is recommended** for the following reasons:
232
+
233
+ 1. **Single asset** - One .ttf file vs. maintaining 3+ bitmap atlases
234
+ 2. **Fractional scaling** - Windows 125%/150% scaling works without rounding hacks
235
+ 3. **Display migration** - Moving window between 1x and 2x displays is seamless
236
+ 4. **Unicode** - Full box-drawing, symbols, and international character support
237
+
238
+ To preserve the retro aesthetic, use a **pixel/bitmap-style TTF font**:
239
+ - [Cozette](https://github.com/slavfox/Cozette) - Bitmap-style TTF, good Unicode coverage
240
+ - [Monocraft](https://github.com/IdreesInc/Monocraft) - Minecraft-inspired, TTF format
241
+ - [PixelMplus](https://github.com/itouhiro/PixelMplus) - Japanese pixel font with Latin
242
+ - [Ark Pixel](https://github.com/TakWolf/ark-pixel-font) - Pan-CJK pixel font
243
+
244
+ These fonts render with a retro look but scale cleanly via TTF.
245
+
246
+ ### SDL_ttf Implementation
247
+
248
+ ```typescript
249
+ // src/rendering/sdl-ui/text-renderer.ts
250
+
251
+ interface SdlTextRenderer {
252
+ private font: Pointer;
253
+ private fontSize: number;
254
+ private scaleFactor: number;
255
+ private glyphCache: Map<string, SDL_Texture>;
256
+
257
+ constructor(fontPath: string, baseSize: number, scaleFactor: number) {
258
+ this.fontSize = baseSize;
259
+ this.scaleFactor = scaleFactor;
260
+ // Open font at physical size for crisp rendering
261
+ const physicalSize = Math.round(baseSize * scaleFactor);
262
+ this.font = TTF_OpenFont(fontPath, physicalSize);
263
+ }
264
+
265
+ // Re-open font when scale factor changes (display change)
266
+ updateScaleFactor(newScale: number): void {
267
+ if (newScale !== this.scaleFactor) {
268
+ TTF_CloseFont(this.font);
269
+ this.scaleFactor = newScale;
270
+ const physicalSize = Math.round(this.fontSize * newScale);
271
+ this.font = TTF_OpenFont(this.fontPath, physicalSize);
272
+ this.glyphCache.clear(); // Invalidate cached glyphs
273
+ }
274
+ }
275
+
276
+ measureText(text: string): { width: number; height: number } {
277
+ const w = Buffer.alloc(4);
278
+ const h = Buffer.alloc(4);
279
+ TTF_SizeUTF8(this.font, text, w, h);
280
+ return { width: w.readInt32LE(0), height: h.readInt32LE(0) };
281
+ }
282
+
283
+ renderText(text: string, color: Color): SDL_Texture {
284
+ // Check cache first
285
+ const cacheKey = `${text}:${color.r}:${color.g}:${color.b}`;
286
+ if (this.glyphCache.has(cacheKey)) {
287
+ return this.glyphCache.get(cacheKey)!;
288
+ }
289
+
290
+ const surface = TTF_RenderUTF8_Blended(this.font, text, color);
291
+ const texture = SDL_CreateTextureFromSurface(this.renderer, surface);
292
+ SDL_FreeSurface(surface);
293
+
294
+ this.glyphCache.set(cacheKey, texture);
295
+ return texture;
296
+ }
297
+ }
298
+ ```
299
+
300
+ ### Fallback: Bitmap Font (Optional)
301
+
302
+ For users who prefer pixel-perfect rendering at the cost of flexibility:
303
+
304
+ ```typescript
305
+ interface BitmapFont {
306
+ textures: Map<'1x' | '2x' | '3x', SDL_Texture>;
307
+ glyphWidth: number; // Logical size (e.g., 8)
308
+ glyphHeight: number; // Logical size (e.g., 16)
309
+
310
+ getAtlasForScale(scale: number): { texture: SDL_Texture; physicalGlyphSize: number } {
311
+ const key = scale >= 2.5 ? '3x' : scale >= 1.5 ? '2x' : '1x';
312
+ const multiplier = key === '3x' ? 3 : key === '2x' ? 2 : 1;
313
+ return {
314
+ texture: this.textures.get(key)!,
315
+ physicalGlyphSize: this.glyphWidth * multiplier,
316
+ };
317
+ }
318
+ }
319
+ ```
320
+
321
+ Can be offered as a config option: `sdl_ui_font_type = "ttf" | "bitmap"`
322
+
323
+ ---
324
+
325
+ ## SDL Bindings Extensions
326
+
327
+ ### New SDL2 Functions Required
328
+
329
+ Add to `src/rendering/sdl-bindings.ts`:
330
+
331
+ ```typescript
332
+ // Text rendering (SDL_ttf)
333
+ TTF_Init(): number;
334
+ TTF_Quit(): void;
335
+ TTF_OpenFont(file: string, ptsize: number): Pointer;
336
+ TTF_CloseFont(font: Pointer): void;
337
+ TTF_RenderUTF8_Blended(font: Pointer, text: string, color: SDL_Color): Pointer;
338
+ TTF_SizeUTF8(font: Pointer, text: string, w: Pointer, h: Pointer): number;
339
+
340
+ // Additional texture operations
341
+ SDL_CreateTextureFromSurface(renderer: Pointer, surface: Pointer): Pointer;
342
+ SDL_FreeSurface(surface: Pointer): void;
343
+ SDL_SetTextureBlendMode(texture: Pointer, blendMode: number): number;
344
+ SDL_SetRenderDrawColor(renderer: Pointer, r: number, g: number, b: number, a: number): number;
345
+ SDL_RenderFillRect(renderer: Pointer, rect: Pointer): number;
346
+ SDL_RenderDrawRect(renderer: Pointer, rect: Pointer): number;
347
+
348
+ // HiDPI support
349
+ SDL_GetRendererOutputSize(renderer: Pointer, w: Pointer, h: Pointer): number;
350
+ SDL_GetWindowDisplayIndex(window: Pointer): number;
351
+ SDL_GetDisplayDPI(displayIndex: number, ddpi: Pointer, hdpi: Pointer, vdpi: Pointer): number;
352
+ ```
353
+
354
+ ### SDL Window Flags
355
+
356
+ ```typescript
357
+ // Window creation flags for HiDPI
358
+ const SDL_WINDOW_ALLOW_HIGHDPI = 0x00002000;
359
+ const SDL_WINDOW_RESIZABLE = 0x00000020;
360
+
361
+ // Window event types for display changes
362
+ const SDL_WINDOWEVENT_DISPLAY_CHANGED = 20;
363
+ const SDL_WINDOWEVENT_SIZE_CHANGED = 6;
364
+ ```
365
+
366
+ ---
367
+
368
+ ## ANSI Sequence Parsing
369
+
370
+ ### Sequences to Support
371
+
372
+ | Sequence | Purpose | SDL Equivalent |
373
+ |----------|---------|----------------|
374
+ | `\x1b[{n};{m}H` | Cursor position | Set draw coordinates |
375
+ | `\x1b[{n}m` | SGR (colors, styles) | Set text/fill color |
376
+ | `\x1b[38;2;{r};{g};{b}m` | 24-bit foreground | RGB text color |
377
+ | `\x1b[48;2;{r};{g};{b}m` | 24-bit background | RGB fill color |
378
+ | `\x1b[1m` | Bold | Bold font variant or brighter color |
379
+ | `\x1b[4m` | Underline | Draw underline |
380
+ | `\x1b[7m` | Reverse video | Swap fg/bg colors |
381
+ | `\x1b[2J` | Clear screen | Clear texture |
382
+
383
+ ### Parser Implementation
384
+
385
+ ```typescript
386
+ // src/rendering/ansi-parser.ts
387
+
388
+ interface DrawCommand {
389
+ type: 'text' | 'fill' | 'clear' | 'cursor';
390
+ // ... command-specific data
391
+ }
392
+
393
+ const parseAnsiStream = (input: string): DrawCommand[] => {
394
+ const commands: DrawCommand[] = [];
395
+ let cursor = { x: 0, y: 0 };
396
+ let style = { fg: WHITE, bg: BLACK, bold: false, underline: false };
397
+
398
+ // State machine to parse escape sequences and text
399
+ // ...
400
+
401
+ return commands;
402
+ };
403
+ ```
404
+
405
+ ---
406
+
407
+ ## Input Handling
408
+
409
+ ### Keyboard Input
410
+
411
+ When UI renders to SDL, keyboard input comes from SDL events instead of stdin:
412
+
413
+ ```typescript
414
+ // Map SDL key events to Ink input
415
+ const sdlKeyToInkKey = (event: SDL_KeyboardEvent): string | null => {
416
+ switch (event.keysym.sym) {
417
+ case SDLK_UP: return 'up';
418
+ case SDLK_DOWN: return 'down';
419
+ case SDLK_LEFT: return 'left';
420
+ case SDLK_RIGHT: return 'right';
421
+ case SDLK_RETURN: return 'return';
422
+ case SDLK_ESCAPE: return 'escape';
423
+ case SDLK_TAB: return 'tab';
424
+ case SDLK_BACKSPACE: return 'backspace';
425
+ default:
426
+ // Handle printable characters
427
+ if (event.keysym.sym >= 32 && event.keysym.sym < 127) {
428
+ return String.fromCharCode(event.keysym.sym);
429
+ }
430
+ return null;
431
+ }
432
+ };
433
+ ```
434
+
435
+ ### Mouse Input
436
+
437
+ Ink supports mouse via terminal mouse protocols. For SDL:
438
+
439
+ ```typescript
440
+ // SDL mouse events
441
+ SDL_MOUSEMOTION → track hover state
442
+ SDL_MOUSEBUTTONDOWN → click events
443
+ SDL_MOUSEWHEEL → scroll events
444
+
445
+ // Convert pixel coordinates to character grid
446
+ const pixelToChar = (x: number, y: number): { col: number; row: number } => ({
447
+ col: Math.floor(x / glyphWidth),
448
+ row: Math.floor(y / glyphHeight),
449
+ });
450
+ ```
451
+
452
+ ### Input Injection
453
+
454
+ Create a fake stdin stream that receives SDL input:
455
+
456
+ ```typescript
457
+ class SdlInputBridge extends Readable {
458
+ handleSdlEvent(event: SDL_Event): void {
459
+ if (event.type === SDL_KEYDOWN) {
460
+ const key = sdlKeyToInkKey(event.key);
461
+ if (key) {
462
+ // Push to Ink's input stream
463
+ this.push(key);
464
+ }
465
+ }
466
+ }
467
+ }
468
+ ```
469
+
470
+ ---
471
+
472
+ ## UI Window Management
473
+
474
+ This section applies to SDL mode only. Terminal modes continue to use the existing Ink-based UI with no changes.
475
+
476
+ ### Mode Selection Flow
477
+
478
+ ```
479
+ Application Start
480
+
481
+ ┌──────────────────────────────────────┐
482
+ │ Check video_driver setting │
483
+ └──────────────────────────────────────┘
484
+ ↓ ↓
485
+ SDL mode Terminal mode
486
+ ↓ ↓
487
+ ┌──────────────┐ ┌──────────────────┐
488
+ │ SDL Window │ │ Terminal (Ink) │
489
+ │ UI + Game │ │ UI + Game │
490
+ └──────────────┘ └──────────────────┘
491
+ ```
492
+
493
+ ### SDL Mode Window Lifecycle
494
+
495
+ ```
496
+ Application Start (SDL mode)
497
+
498
+ ┌──────────────────────────┐
499
+ │ SDL UI Window Created │
500
+ │ - ROM Browser renders │
501
+ │ - Settings accessible │
502
+ └──────────────────────────┘
503
+ ↓ (user launches game)
504
+ ┌──────────────────────────┐
505
+ │ Same Window, New Mode │
506
+ │ - Game renders │
507
+ │ - UI overlay (optional) │
508
+ └──────────────────────────┘
509
+ ↓ (user exits game)
510
+ ┌──────────────────────────┐
511
+ │ Return to UI Mode │
512
+ │ - ROM Browser renders │
513
+ └──────────────────────────┘
514
+ ```
515
+
516
+ ### Unified Window vs. Separate Windows
517
+
518
+ **Option 1: Single Window (Recommended)**
519
+
520
+ One SDL window used for both UI and game:
521
+
522
+ ```typescript
523
+ class UnifiedSdlWindow {
524
+ private mode: 'ui' | 'game' = 'ui';
525
+
526
+ setMode(mode: 'ui' | 'game'): void {
527
+ this.mode = mode;
528
+ if (mode === 'ui') {
529
+ this.resize(UI_WIDTH, UI_HEIGHT);
530
+ } else {
531
+ this.resize(gameWidth * scale, gameHeight * scale);
532
+ }
533
+ }
534
+ }
535
+ ```
536
+
537
+ **Pros:** Seamless transitions, consistent window position
538
+ **Cons:** Window resizes between modes
539
+
540
+ **Option 2: Separate Windows**
541
+
542
+ UI window and game window are independent:
543
+
544
+ **Pros:** No resizing needed, can show both simultaneously
545
+ **Cons:** More complex window management, less integrated experience
546
+
547
+ ---
548
+
549
+ ## Rendering Pipeline
550
+
551
+ ### Frame Composition
552
+
553
+ ```typescript
554
+ class SdlUiRenderer {
555
+ private texture: SDL_Texture; // UI render target
556
+ private dirtyRegions: SDL_Rect[] = [];
557
+
558
+ beginFrame(): void {
559
+ SDL_SetRenderTarget(this.renderer, this.texture);
560
+ SDL_SetRenderDrawColor(this.renderer, 0, 0, 0, 255);
561
+ SDL_RenderClear(this.renderer);
562
+ }
563
+
564
+ drawText(text: string, x: number, y: number, style: TextStyle): void {
565
+ // Render text to texture at character grid position
566
+ const pixelX = x * this.glyphWidth;
567
+ const pixelY = y * this.glyphHeight;
568
+
569
+ // Draw background if set
570
+ if (style.bg) {
571
+ this.fillRect(pixelX, pixelY, text.length * this.glyphWidth, this.glyphHeight, style.bg);
572
+ }
573
+
574
+ // Draw text
575
+ this.renderTextToTexture(text, pixelX, pixelY, style.fg);
576
+ }
577
+
578
+ endFrame(): void {
579
+ SDL_SetRenderTarget(this.renderer, null);
580
+ SDL_RenderCopy(this.renderer, this.texture, null, null);
581
+ SDL_RenderPresent(this.renderer);
582
+ }
583
+ }
584
+ ```
585
+
586
+ ### Dirty Region Optimization
587
+
588
+ Only re-render changed portions:
589
+
590
+ ```typescript
591
+ // Track which character cells changed
592
+ interface DirtyTracker {
593
+ markDirty(col: number, row: number, width: number, height: number): void;
594
+ getDirtyRegions(): SDL_Rect[];
595
+ clear(): void;
596
+ }
597
+ ```
598
+
599
+ ---
600
+
601
+ ## Component Compatibility
602
+
603
+ ### Ink Components That Work Unchanged
604
+
605
+ | Component | Notes |
606
+ |-----------|-------|
607
+ | `Box` | Layout via yoga, renders as colored rectangles |
608
+ | `Text` | Direct text rendering |
609
+ | `Newline` | Cursor positioning |
610
+ | `Spacer` | Layout spacing |
611
+
612
+ ### Components Requiring Adaptation
613
+
614
+ | Component | Challenge | Solution |
615
+ |-----------|-----------|----------|
616
+ | `Static` | Scrollback buffer | Maintain virtual buffer |
617
+ | `TextInput` | Cursor blinking | SDL timer for cursor |
618
+ | Thumbnails (custom) | Kitty graphics protocol | Render directly to SDL |
619
+
620
+ ### Thumbnail Rendering
621
+
622
+ Current thumbnails use Kitty graphics protocol. For SDL:
623
+
624
+ ```typescript
625
+ // Load thumbnail image directly
626
+ const thumbnail = SDL_LoadBMP(thumbnailPath); // or PNG via SDL_image
627
+ const texture = SDL_CreateTextureFromSurface(renderer, thumbnail);
628
+
629
+ // Render at grid position
630
+ SDL_RenderCopy(renderer, texture, null, destRect);
631
+ ```
632
+
633
+ This actually simplifies thumbnail handling since SDL can render images directly.
634
+
635
+ ---
636
+
637
+ ## Configuration
638
+
639
+ ### Behavior by Render Mode
640
+
641
+ UI rendering automatically matches the selected game render mode:
642
+
643
+ | Render Mode | Game Output | UI Output | Input Source |
644
+ |-------------|-------------|-----------|--------------|
645
+ | `native` | Native window (fenster) | Native window (fenster) | ink-native keyboard events |
646
+ | `kitty` | Terminal (Kitty protocol) | Terminal (Ink) | Terminal stdin |
647
+ | `terminal` | Terminal (half-blocks) | Terminal (Ink) | Terminal stdin |
648
+ | `ascii` | Terminal (ASCII) | Terminal (Ink) | Terminal stdin |
649
+ | `emoji` | Terminal (emoji) | Terminal (Ink) | Terminal stdin |
650
+
651
+ No separate configuration needed - selecting `--native` or `video_driver = "native"` automatically enables native UI rendering.
652
+
653
+ ### Config Keys (Shipped)
654
+
655
+ Unlike the `sdl_ui_font` / `sdl_ui_font_size` / `sdl_ui_scale` keys originally proposed below, no new config keys shipped. The font is fixed (bundled Cozette, not configurable) and UI/game scale reuses the existing `menu_scale_factor` key, passed to `ink-native` as `scaleFactor`:
656
+
657
+ | Key | Type | Default | Description |
658
+ |-----|------|---------|-------------|
659
+ | `menu_scale_factor` | number | auto | UI/window scale, passed to `ink-native` as `scaleFactor`. Applies only at window creation - no runtime change. |
660
+
661
+ ### CLI Usage
662
+
663
+ ```bash
664
+ # Native mode: both UI and game render to the native window
665
+ emoemu --native
666
+
667
+ # Terminal modes: both UI and game render to terminal (unchanged behavior)
668
+ emoemu --kitty
669
+ emoemu --terminal
670
+ emoemu --ascii
671
+ emoemu --emoji
672
+ ```
673
+
674
+ ---
675
+
676
+ ## File Structure
677
+
678
+ ```
679
+ src/rendering/
680
+ ├── sdl-renderer.ts # Existing: Game rendering
681
+ ├── sdl-bindings.ts # Existing: SDL2 FFI (extend with TTF)
682
+ ├── sdl-ui/
683
+ │ ├── index.ts # SdlUiRenderer main class
684
+ │ ├── ansi-parser.ts # ANSI escape sequence parser
685
+ │ ├── text-renderer.ts # Text rendering (bitmap or TTF)
686
+ │ ├── input-bridge.ts # SDL input → Ink input translation
687
+ │ ├── bitmap-font.ts # Bitmap font atlas handling
688
+ │ └── fonts/
689
+ │ └── default.png # Built-in bitmap font atlas
690
+
691
+ src/ui/
692
+ ├── App.tsx # Existing: Entry point (no changes needed)
693
+ ├── RomBrowser/ # Existing: ROM browser (no changes needed)
694
+ └── ... # Other UI components (no changes needed)
695
+
696
+ src/
697
+ ├── index.ts # Modified: Route to SDL or terminal UI based on render mode
698
+ └── Emulator/ # Existing: Game rendering (minimal changes)
699
+ ```
700
+
701
+ ---
702
+
703
+ ## Integration Point
704
+
705
+ ### Routing UI to SDL or Terminal
706
+
707
+ The main entry point (`src/index.ts`) determines which UI path to use based on the configured render mode:
708
+
709
+ ```typescript
710
+ // In src/index.ts - launchBrowser or equivalent
711
+
712
+ const renderMode = getRenderMode(); // From config or CLI
713
+
714
+ if (renderMode === 'sdl') {
715
+ // SDL mode: Create SDL window for UI
716
+ const sdlUi = new SdlUiRenderer({
717
+ width: UI_WIDTH,
718
+ height: UI_HEIGHT,
719
+ font: config.sdl_ui_font,
720
+ });
721
+
722
+ // Create custom streams that route to SDL
723
+ const sdlOutputStream = new SdlOutputStream(sdlUi);
724
+ const sdlInputStream = new SdlInputStream(sdlUi);
725
+
726
+ // Render Ink to SDL
727
+ const { waitUntilExit } = render(<App />, {
728
+ stdout: sdlOutputStream,
729
+ stdin: sdlInputStream,
730
+ });
731
+
732
+ await waitUntilExit();
733
+ } else {
734
+ // Terminal mode: Use existing Ink setup (unchanged)
735
+ const { waitUntilExit } = render(<App />);
736
+ await waitUntilExit();
737
+ }
738
+ ```
739
+
740
+ ### Shared SDL Window Between UI and Game
741
+
742
+ When transitioning from UI to game in SDL mode, reuse the same window:
743
+
744
+ ```typescript
745
+ class SdlWindowManager {
746
+ private window: Pointer;
747
+ private renderer: Pointer;
748
+
749
+ // UI mode
750
+ getUiRenderer(): SdlUiRenderer {
751
+ return new SdlUiRenderer({ window: this.window, renderer: this.renderer });
752
+ }
753
+
754
+ // Game mode
755
+ getGameRenderer(width: number, height: number): SdlRenderer {
756
+ return new SdlRenderer({
757
+ window: this.window,
758
+ renderer: this.renderer,
759
+ width,
760
+ height,
761
+ });
762
+ }
763
+ }
764
+ ```
765
+
766
+ ---
767
+
768
+ ## Implementation Phases
769
+
770
+ ### Phase 1: Foundation
771
+
772
+ 1. Create `sdl-ui/` directory structure
773
+ 2. Add SDL_ttf bindings to `sdl-bindings.ts`
774
+ 3. Implement ANSI escape sequence parser
775
+ 4. Create TTF text renderer with scale-aware font sizing
776
+ 5. HiDPI window creation with `SDL_WINDOW_ALLOW_HIGHDPI`
777
+ 6. Scale factor detection and coordinate scaling
778
+ 7. Bundle Cozette TTF font
779
+
780
+ **Deliverable:** Can render crisp plain text to SDL window on both standard and HiDPI displays
781
+
782
+ ### Phase 2: Ink Integration
783
+
784
+ 1. Create output stream interceptor (`SdlOutputStream`)
785
+ 2. Wire Ink render to SDL output
786
+ 3. Implement cursor positioning
787
+ 4. Add color support (SGR sequences)
788
+
789
+ **Deliverable:** Ink components render to SDL (text-only, no interaction)
790
+
791
+ ### Phase 3: Input Handling
792
+
793
+ 1. Implement SDL keyboard → Ink input bridge (`SdlInputStream`)
794
+ 2. Add mouse support
795
+ 3. Handle focus and window events
796
+
797
+ **Deliverable:** Interactive UI in SDL window
798
+
799
+ ### Phase 4: Mode Routing
800
+
801
+ 1. Add render mode check in `src/index.ts`
802
+ 2. Create `SdlWindowManager` for shared window between UI and game
803
+ 3. Implement UI → game → UI transitions in SDL mode
804
+ 4. Verify terminal modes remain unchanged
805
+
806
+ **Deliverable:** `--native` flag gives unified native-window experience; other modes unchanged
807
+
808
+ ### Phase 5: Visual Polish
809
+
810
+ 1. Box-drawing character support
811
+ 2. Bold/underline text styles
812
+ 3. Thumbnail rendering (direct SDL image loading)
813
+ 4. Consistent styling between UI and game status bar
814
+
815
+ **Deliverable:** Feature parity with terminal UI
816
+
817
+ ### Phase 6: Optimization
818
+
819
+ 1. Dirty region tracking
820
+ 2. Texture caching for repeated text
821
+ 3. Glyph atlas optimization
822
+
823
+ **Deliverable:** Smooth 60 FPS UI rendering
824
+
825
+ ---
826
+
827
+ ## Testing Strategy
828
+
829
+ ### Unit Tests
830
+
831
+ | Test | Description |
832
+ |------|-------------|
833
+ | ANSI parser | Verify correct parsing of all supported sequences |
834
+ | Text measurement | Font metrics match expected character grid |
835
+ | Input translation | SDL keys map correctly to Ink input |
836
+ | Color conversion | SGR codes produce correct RGB values |
837
+
838
+ ### Integration Tests
839
+
840
+ | Test | Description |
841
+ |------|-------------|
842
+ | Basic render | Simple Ink app renders to SDL |
843
+ | Layout | Box/flex layouts match terminal rendering |
844
+ | Interaction | Keyboard navigation works in SDL mode |
845
+ | Mode switch | UI → game → UI transitions work cleanly |
846
+
847
+ ### Mode-Specific Tests
848
+
849
+ Verify both modes work independently:
850
+
851
+ | Mode | Test | Expected Behavior |
852
+ |------|------|-------------------|
853
+ | Native | Launch with `--native` | Native window shows ROM browser |
854
+ | SDL | Select game | Same window transitions to game |
855
+ | SDL | Exit game | Same window returns to ROM browser |
856
+ | Kitty | Launch with `--kitty` | Terminal shows ROM browser (unchanged) |
857
+ | Kitty | Select game | Terminal shows game (unchanged) |
858
+ | Terminal | Launch with `--terminal` | Terminal shows ROM browser (unchanged) |
859
+ | Terminal | Select game | Terminal shows game (unchanged) |
860
+
861
+ ### Regression Tests
862
+
863
+ Ensure terminal modes are not affected:
864
+
865
+ | Test | Description |
866
+ |------|-------------|
867
+ | Kitty graphics | Thumbnails render correctly in Kitty mode |
868
+ | Mouse support | Terminal mouse (SGR 1006) still works |
869
+ | Keyboard input | Kitty keyboard protocol still works |
870
+ | Gamepad | Gamepad input works in both modes |
871
+
872
+ ### Visual Tests
873
+
874
+ | Test | Expected Result |
875
+ |------|-----------------|
876
+ | ROM browser (SDL) | Grid layout, thumbnails, selection highlight |
877
+ | ROM browser (terminal) | Same layout, Kitty/Unicode thumbnails |
878
+ | Settings panel | Form inputs, toggles, dropdowns |
879
+ | Dialogs | Modal overlays, button focus |
880
+ | Search | Text input with cursor |
881
+
882
+ ### Performance Tests
883
+
884
+ | Metric | Target |
885
+ |--------|--------|
886
+ | UI frame time (SDL) | < 16ms (60 FPS) |
887
+ | UI frame time (terminal) | Unchanged from current |
888
+ | Input latency | < 50ms |
889
+ | Memory (SDL UI mode) | < 50MB additional |
890
+
891
+ ### HiDPI Tests
892
+
893
+ | Test Case | Display | Expected Result |
894
+ |-----------|---------|-----------------|
895
+ | Text rendering | macOS Retina 2x | Crisp, no blur or fuzziness |
896
+ | Text rendering | macOS 1x | Normal, sharp text |
897
+ | Text rendering | Windows 150% | Crisp (renders at 2x) |
898
+ | Font atlas selection | 2x display | Uses 2x bitmap atlas |
899
+ | Window move | 1x → 2x display | Re-renders at new scale, stays crisp |
900
+ | Window resize | Any | Maintains crisp text at all sizes |
901
+ | Thumbnails | 2x display | Sharp image rendering |
902
+
903
+ ---
904
+
905
+ ## Dependencies
906
+
907
+ ### Required (Shipped)
908
+
909
+ | Dependency | Purpose |
910
+ |------------|---------|
911
+ | `ink-native` | npm package: bundled `fenster` native window backend + embedded Cozette bitmap font. **No system dependencies** (no SDL2, no SDL2_ttf). |
912
+ | Ink | React-based UI (already required) |
913
+
914
+ There is no "Optional" dependency tier - `ink-native` bundles everything needed for window, font, and thumbnail image display; no equivalent of SDL2_image is required.
915
+
916
+ ### Font Asset (Shipped)
917
+
918
+ `ink-native` embeds the **Cozette** bitmap font directly in the package - no separate font file to bundle or select:
919
+
920
+ | Font | Style | Unicode | License |
921
+ |------|-------|---------|---------|
922
+ | [Cozette](https://github.com/slavfox/Cozette) | Bitmap, embedded in `ink-native` | Good (box-drawing, symbols) | MIT |
923
+
924
+ The Monocraft/JetBrains Mono alternatives considered below were not adopted - the font is fixed by `ink-native`, not user-selectable.
925
+
926
+ ---
927
+
928
+ ## HiDPI / Retina Display Support
929
+
930
+ Crisp UI rendering on HiDPI displays (macOS Retina, Windows high-DPI, Linux HiDPI) is essential. Blurry text is unacceptable.
931
+
932
+ ### The HiDPI Challenge
933
+
934
+ On a 2x Retina display, a "1280x720" window actually has 2560x1440 physical pixels. Without proper handling:
935
+ - Text rendered at logical resolution gets scaled up → blurry
936
+ - Bitmap fonts look pixelated
937
+ - UI appears fuzzy compared to native apps
938
+
939
+ ### SDL HiDPI Architecture
940
+
941
+ ```
942
+ ┌─────────────────────────────────────────────────────┐
943
+ │ Logical Size (what we request): 1280 x 720 │
944
+ │ Physical Size (actual pixels): 2560 x 1440 │
945
+ │ Scale Factor: 2x │
946
+ └─────────────────────────────────────────────────────┘
947
+ ```
948
+
949
+ ### Implementation Requirements
950
+
951
+ #### 1. Window Creation with HiDPI Flag
952
+
953
+ ```typescript
954
+ // Enable HiDPI support when creating the window
955
+ const window = SDL_CreateWindow(
956
+ title,
957
+ SDL_WINDOWPOS_CENTERED,
958
+ SDL_WINDOWPOS_CENTERED,
959
+ logicalWidth,
960
+ logicalHeight,
961
+ SDL_WINDOW_ALLOW_HIGHDPI | SDL_WINDOW_RESIZABLE
962
+ );
963
+ ```
964
+
965
+ #### 2. Query Actual Drawable Size
966
+
967
+ ```typescript
968
+ // Get the real pixel dimensions (not logical)
969
+ const getDrawableSize = (window: Pointer): { width: number; height: number } => {
970
+ const w = Buffer.alloc(4);
971
+ const h = Buffer.alloc(4);
972
+ SDL_GetRendererOutputSize(renderer, w, h);
973
+ return {
974
+ width: w.readInt32LE(0),
975
+ height: h.readInt32LE(0),
976
+ };
977
+ };
978
+
979
+ // Calculate scale factor
980
+ const logical = { width: 1280, height: 720 };
981
+ const physical = getDrawableSize(window);
982
+ const scaleFactor = physical.width / logical.width; // 2.0 on Retina
983
+ ```
984
+
985
+ #### 3. Render at Native Resolution
986
+
987
+ All rendering must happen at physical pixel resolution:
988
+
989
+ ```typescript
990
+ class SdlUiRenderer {
991
+ private scaleFactor: number;
992
+ private physicalWidth: number;
993
+ private physicalHeight: number;
994
+
995
+ constructor(options: SdlUiRendererOptions) {
996
+ // Create window at logical size
997
+ this.window = SDL_CreateWindow(..., logicalWidth, logicalHeight, SDL_WINDOW_ALLOW_HIGHDPI);
998
+
999
+ // Get actual pixel dimensions
1000
+ const physical = this.getDrawableSize();
1001
+ this.physicalWidth = physical.width;
1002
+ this.physicalHeight = physical.height;
1003
+ this.scaleFactor = physical.width / logicalWidth;
1004
+
1005
+ // Create texture at PHYSICAL size for crisp rendering
1006
+ this.texture = SDL_CreateTexture(
1007
+ this.renderer,
1008
+ SDL_PIXELFORMAT_RGBA8888,
1009
+ SDL_TEXTUREACCESS_TARGET,
1010
+ this.physicalWidth, // Physical pixels, not logical
1011
+ this.physicalHeight
1012
+ );
1013
+ }
1014
+ }
1015
+ ```
1016
+
1017
+ #### 4. Scale-Aware Font Rendering
1018
+
1019
+ TTF fonts handle HiDPI naturally by rendering at the physical pixel size:
1020
+
1021
+ ```typescript
1022
+ // Scale font size by display factor
1023
+ const baseFontSize = 16; // Logical size (what we'd use at 1x)
1024
+ const physicalFontSize = Math.round(baseFontSize * scaleFactor); // 32 on 2x Retina
1025
+ const font = TTF_OpenFont(fontPath, physicalFontSize);
1026
+
1027
+ // Font renders at native resolution - no scaling artifacts
1028
+ ```
1029
+
1030
+ This is the primary advantage of TTF over bitmap fonts for HiDPI - a single font file works at any scale factor without maintaining multiple assets.
1031
+
1032
+ #### 5. Scale-Aware Coordinate System
1033
+
1034
+ All Ink coordinates (character grid) must be scaled:
1035
+
1036
+ ```typescript
1037
+ // Convert character position to physical pixels
1038
+ const charToPhysical = (col: number, row: number): { x: number; y: number } => ({
1039
+ x: col * glyphWidth * scaleFactor,
1040
+ y: row * glyphHeight * scaleFactor,
1041
+ });
1042
+
1043
+ // Drawing uses physical coordinates
1044
+ drawText(text: string, col: number, row: number, style: TextStyle): void {
1045
+ const { x, y } = this.charToPhysical(col, row);
1046
+ // Render at physical coordinates with scaled font
1047
+ this.renderTextAtPhysicalPos(text, x, y, style);
1048
+ }
1049
+ ```
1050
+
1051
+ ### Platform-Specific Considerations
1052
+
1053
+ | Platform | Scale Factors | Notes |
1054
+ |----------|---------------|-------|
1055
+ | macOS Retina | 2x (common), 1x | `SDL_WINDOW_ALLOW_HIGHDPI` required |
1056
+ | macOS Pro Display XDR | Up to 2x | Same handling |
1057
+ | Windows | 1x, 1.25x, 1.5x, 2x, etc. | Fractional scaling common |
1058
+ | Linux (Wayland) | Integer (1x, 2x) | Wayland handles scaling well |
1059
+ | Linux (X11) | Varies | May need `SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR=0` |
1060
+
1061
+ ### Fractional Scaling
1062
+
1063
+ Windows commonly uses fractional scales (125%, 150%). Options:
1064
+
1065
+ **Option A: Round to nearest integer (Recommended)**
1066
+ ```typescript
1067
+ const effectiveScale = Math.round(scaleFactor); // 1.5 → 2
1068
+ ```
1069
+ Slightly larger UI but always crisp.
1070
+
1071
+ **Option B: Render at exact scale**
1072
+ ```typescript
1073
+ const effectiveScale = scaleFactor; // 1.5 exactly
1074
+ ```
1075
+ Requires careful sub-pixel rendering, may still have artifacts.
1076
+
1077
+ **Recommendation:** Round to nearest integer for guaranteed crispness.
1078
+
1079
+ ### Testing HiDPI
1080
+
1081
+ | Test Case | Expected Result |
1082
+ |-----------|-----------------|
1083
+ | macOS Retina 2x | Crisp text, no blur |
1084
+ | macOS non-Retina 1x | Normal rendering |
1085
+ | Windows 150% scaling | Crisp (rounded to 2x) |
1086
+ | Windows 100% scaling | Normal rendering |
1087
+ | Linux HiDPI | Crisp text |
1088
+ | Mixed-DPI (move window between displays) | Re-query scale, re-render |
1089
+
1090
+ ### Display Change Handling
1091
+
1092
+ Handle window moving between displays with different DPI:
1093
+
1094
+ ```typescript
1095
+ // In SDL event loop
1096
+ if (event.type === SDL_WINDOWEVENT) {
1097
+ if (event.window.event === SDL_WINDOWEVENT_DISPLAY_CHANGED ||
1098
+ event.window.event === SDL_WINDOWEVENT_SIZE_CHANGED) {
1099
+ // Re-query drawable size - scale factor may have changed
1100
+ const newPhysical = this.getDrawableSize();
1101
+ if (newPhysical.width !== this.physicalWidth) {
1102
+ this.handleScaleChange(newPhysical);
1103
+ }
1104
+ }
1105
+ }
1106
+
1107
+ handleScaleChange(newPhysical: Size): void {
1108
+ this.physicalWidth = newPhysical.width;
1109
+ this.physicalHeight = newPhysical.height;
1110
+ this.scaleFactor = newPhysical.width / this.logicalWidth;
1111
+
1112
+ // Recreate texture at new size
1113
+ SDL_DestroyTexture(this.texture);
1114
+ this.texture = SDL_CreateTexture(..., this.physicalWidth, this.physicalHeight);
1115
+
1116
+ // Reload font at new size
1117
+ this.reloadFont();
1118
+
1119
+ // Force full redraw
1120
+ this.invalidateAll();
1121
+ }
1122
+ ```
1123
+
1124
+ ### Config Options
1125
+
1126
+ | Key | Type | Default | Description |
1127
+ |-----|------|---------|-------------|
1128
+ | `sdl_ui_dpi_aware` | boolean | `true` | Enable HiDPI rendering |
1129
+ | `sdl_ui_dpi_rounding` | string | `"nearest"` | `"nearest"`, `"floor"`, `"ceil"` for fractional scaling |
1130
+
1131
+ ---
1132
+
1133
+ ## Limitations
1134
+
1135
+ **Native mode limitations (shipped):**
1136
+
1137
+ 1. **Character grid constraint**: Native UI must match terminal's character-based model for Ink compatibility
1138
+ 2. **No smooth animations**: Frame-based updates like terminal
1139
+ 3. **Limited typography**: Single embedded font (Cozette), no custom fonts or styling
1140
+ 4. **No rich text**: No inline images (except thumbnails in designated areas)
1141
+ 5. **No runtime `setTitle`**: `ink-native` exposes no API to change the window title after creation
1142
+ 6. **No runtime scale-factor change**: `menu_scale_factor` (→ ink-native's `scaleFactor`) only applies at window creation
1143
+ 7. **No programmatic window resize**: the game letterboxes into the fixed window instead of resizing it
1144
+
1145
+ **General limitations:**
1146
+
1147
+ 1. **No mixed modes**: Cannot use native window for game and terminal for UI (or vice versa)
1148
+ 2. **Font dependency**: Native mode is limited to the font bundled with `ink-native` (Cozette) - no custom font support
1149
+
1150
+ ---
1151
+
1152
+ ## Future Enhancements
1153
+
1154
+ 1. **Smooth scrolling**: Pixel-level scroll instead of line-based
1155
+ 2. **Transitions**: Fade/slide animations between screens
1156
+ 3. **Custom themes**: User-configurable colors and fonts
1157
+ 4. **In-game overlay**: Pause menu, save state UI rendered over game
1158
+ 5. **Sub-pixel text rendering**: LCD sub-pixel antialiasing for even crisper text
1159
+
1160
+ ---
1161
+
1162
+ ## Alternatives Considered
1163
+
1164
+ ### Dear ImGui
1165
+
1166
+ Immediate-mode GUI library with SDL backend:
1167
+
1168
+ **Pros:** Battle-tested SDL integration, extensive widget library
1169
+ **Cons:** Would require rewriting all UI code, different paradigm than React
1170
+
1171
+ ### Electron/WebView
1172
+
1173
+ Embed web browser for UI:
1174
+
1175
+ **Pros:** Full HTML/CSS capabilities, could reuse Ink with web target
1176
+ **Cons:** Heavy dependency, complex integration, performance overhead
1177
+
1178
+ ### SDL_gui / Other SDL GUI Libraries
1179
+
1180
+ Various SDL GUI libraries exist:
1181
+
1182
+ **Pros:** Native SDL integration
1183
+ **Cons:** Most are unmaintained, would still require UI rewrite
1184
+
1185
+ **Conclusion:** Intercepting Ink output is the least invasive approach that preserves existing UI code while enabling SDL rendering.
1186
+
1187
+ ---
1188
+
1189
+ ## Resources
1190
+
1191
+ - [Ink Documentation](https://github.com/vadimdemedes/ink)
1192
+ - [Ink Reconciler Source](https://github.com/vadimdemedes/ink/blob/master/src/reconciler.ts)
1193
+ - [SDL_ttf Documentation](https://wiki.libsdl.org/SDL2_ttf/FrontPage)
1194
+ - [ANSI Escape Codes Reference](https://en.wikipedia.org/wiki/ANSI_escape_code)
1195
+ - [Yoga Layout Engine](https://yogalayout.dev/)