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,113 @@
1
+ /**
2
+ * Warning Dialog Component
3
+ *
4
+ * A general-purpose warning dialog that displays a message and allows
5
+ * the user to choose between continuing (OK) or exiting the application.
6
+ */
7
+
8
+ import { Box, Text } from 'ink';
9
+ import { useDialogNavigation } from '../hooks/useDialogNavigation';
10
+ import { DialogContainer } from '../DialogContainer';
11
+ import { launchDialog, type DialogRenderOptions } from '../NativeDialog';
12
+ import { DIALOG_BOX_PADDING } from '..';
13
+
14
+ export type WarningChoice = 'ok' | 'exit';
15
+
16
+ interface WarningDialogProps {
17
+ message: string;
18
+ title?: string;
19
+ onChoice: (choice: WarningChoice) => void;
20
+ }
21
+
22
+ const WarningDialog = ({ message, title = 'Warning', onChoice }: WarningDialogProps) => {
23
+ const options: { label: string; choice: WarningChoice; color: string }[] = [
24
+ { label: 'OK', choice: 'ok', color: 'green' },
25
+ { label: 'Exit', choice: 'exit', color: 'red' },
26
+ ];
27
+
28
+ const { selectedIndex } = useDialogNavigation({
29
+ itemCount: options.length,
30
+ onSelect: (index) => onChoice(options[index].choice),
31
+ onCancel: () => onChoice('ok'),
32
+ horizontal: true,
33
+ spaceToSelect: true,
34
+ onCtrlC: () => onChoice('exit'),
35
+ });
36
+
37
+ return (
38
+ <DialogContainer>
39
+ {(boxWidth) => (
40
+ <>
41
+ {/* Main Dialog Box */}
42
+ <Box
43
+ flexDirection="column"
44
+ borderStyle="round"
45
+ borderColor="yellow"
46
+ paddingX={2}
47
+ paddingY={1}
48
+ width={boxWidth}
49
+ >
50
+ {/* Header with warning icon */}
51
+ <Box justifyContent="center" marginBottom={1}>
52
+ <Text bold color="yellow">{'\u26A0'} {title}</Text>
53
+ </Box>
54
+
55
+ {/* Message */}
56
+ <Box justifyContent="center" marginBottom={1}>
57
+ <Text wrap="wrap">{message}</Text>
58
+ </Box>
59
+
60
+ {/* Separator */}
61
+ <Box justifyContent="center" marginY={1}>
62
+ <Text color="gray" dimColor>{'─'.repeat(boxWidth - DIALOG_BOX_PADDING - 2)}</Text>
63
+ </Box>
64
+
65
+ {/* Buttons - horizontal layout */}
66
+ <Box justifyContent="center" gap={2}>
67
+ {options.map((option, index) => (
68
+ <Box
69
+ key={option.choice}
70
+ paddingX={2}
71
+ borderStyle={selectedIndex === index ? 'round' : 'single'}
72
+ borderColor={selectedIndex === index ? option.color : 'gray'}
73
+ >
74
+ <Text
75
+ color={selectedIndex === index ? option.color : 'gray'}
76
+ bold={selectedIndex === index}
77
+ >
78
+ {option.label}
79
+ </Text>
80
+ </Box>
81
+ ))}
82
+ </Box>
83
+ </Box>
84
+
85
+ {/* Footer */}
86
+ <Box marginTop={1}>
87
+ <Text color="gray" dimColor>
88
+ {'\u2190\u2192'} Navigate {'\u23CE'}/Space Select ESC Continue
89
+ </Text>
90
+ </Box>
91
+ </>
92
+ )}
93
+ </DialogContainer>
94
+ );
95
+ };
96
+
97
+ /**
98
+ * Show the warning dialog and get user's choice
99
+ *
100
+ * @param message - The warning message to display
101
+ * @param options - Dialog render options (native mode, title, etc.)
102
+ * @returns 'ok' if user chooses to continue, 'exit' if user chooses to exit
103
+ */
104
+ export const showWarningDialog = (
105
+ message: string,
106
+ options: DialogRenderOptions & { title?: string } = {}
107
+ ): Promise<WarningChoice> => launchDialog<WarningChoice>(
108
+ (onChoice) => <WarningDialog message={message} title={options.title} onChoice={onChoice} />,
109
+ 'ok',
110
+ { ...options, title: options.title ?? 'emoemu - Warning' },
111
+ );
112
+
113
+ export default WarningDialog;
@@ -0,0 +1,27 @@
1
+ // =============================================================================
2
+ // Terminal Dimensions
3
+ // =============================================================================
4
+
5
+ /** Default terminal dimensions */
6
+ export const DEFAULT_TERM_WIDTH = 80;
7
+ export const DEFAULT_TERM_HEIGHT = 24;
8
+
9
+ // =============================================================================
10
+ // Timing
11
+ // =============================================================================
12
+
13
+ /** Delay for stdin settling (ms) */
14
+ export const STDIN_SETTLE_DELAY_MS = 100;
15
+
16
+ // =============================================================================
17
+ // Dialog
18
+ // =============================================================================
19
+
20
+ /** Dialog box padding (used by multiple dialogs) */
21
+ export const DIALOG_BOX_PADDING = 4;
22
+
23
+ /** Default minimum width for dialog boxes */
24
+ export const DIALOG_BOX_MIN_WIDTH = 50;
25
+
26
+ /** Padding subtracted from box width when rendering separator lines */
27
+ export const SEPARATOR_LINE_PADDING = 6;
@@ -0,0 +1,2 @@
1
+ /** Clear screen, move cursor home, hide cursor */
2
+ export const CLEAR_TERMINAL_SEQUENCE = '\x1b[2J\x1b[H\x1b[?25l';
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Hook to clear the terminal on mount and trigger a re-render.
3
+ *
4
+ * When a component clears the terminal in useEffect, Ink doesn't know the
5
+ * screen was cleared, so it won't re-render until user input. This hook
6
+ * handles both clearing and forcing a re-render so content displays immediately.
7
+ */
8
+
9
+ import { useState, useEffect } from 'react';
10
+ import { CLEAR_TERMINAL_SEQUENCE } from './consts';
11
+
12
+ export * from './consts';
13
+
14
+ /**
15
+ * Clear the terminal on mount and return whether the component is ready to render.
16
+ *
17
+ * @returns true after the terminal has been cleared and re-render triggered
18
+ *
19
+ * @example
20
+ * const MyComponent = () => {
21
+ * const ready = useClearTerminal();
22
+ * if (!ready) return null;
23
+ * return <Box>Content</Box>;
24
+ * };
25
+ */
26
+ export const useClearTerminal = (): boolean => {
27
+ const [ready, setReady] = useState(false);
28
+
29
+ useEffect(() => {
30
+ // Clear screen, move cursor to home position, and hide cursor
31
+ process.stdout.write(CLEAR_TERMINAL_SEQUENCE);
32
+ // Trigger re-render so Ink draws the component
33
+ setReady(true);
34
+ }, []);
35
+
36
+ return ready;
37
+ };
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Dialog Navigation Hook
3
+ *
4
+ * Manages selectedIndex state, keyboard (useInput), and gamepad navigation
5
+ * for dialog components with a list of selectable options.
6
+ */
7
+
8
+ import { useState } from 'react';
9
+ import { useInput, useApp } from 'ink';
10
+ import { useGamepad } from '../useGamepad';
11
+
12
+ interface UseDialogNavigationOptions {
13
+ /** Number of selectable items */
14
+ itemCount: number;
15
+ /** Called when the user confirms a selection (Enter, number key, gamepad A) */
16
+ onSelect: (index: number) => void;
17
+ /** Called when the user cancels (ESC, gamepad B) */
18
+ onCancel: () => void;
19
+ /** Enable left/right arrow navigation in addition to up/down */
20
+ horizontal?: boolean;
21
+ /** Accept space bar as confirmation (in addition to Enter) */
22
+ spaceToSelect?: boolean;
23
+ /** Custom CTRL-C handler; if omitted, CTRL-C is not specially handled */
24
+ onCtrlC?: () => void;
25
+ }
26
+
27
+ /**
28
+ * Hook that provides standard dialog navigation: up/down arrows, Enter to select,
29
+ * ESC to cancel, number shortcuts, and gamepad support.
30
+ */
31
+ export const useDialogNavigation = ({
32
+ itemCount,
33
+ onSelect,
34
+ onCancel,
35
+ horizontal = false,
36
+ spaceToSelect = false,
37
+ onCtrlC,
38
+ }: UseDialogNavigationOptions) => {
39
+ const [selectedIndex, setSelectedIndex] = useState(0);
40
+ const { exit } = useApp();
41
+
42
+ const selectAndExit = (index: number) => {
43
+ onSelect(index);
44
+ exit();
45
+ };
46
+
47
+ const cancelAndExit = () => {
48
+ onCancel();
49
+ exit();
50
+ };
51
+
52
+ useInput((input, key) => {
53
+ // Optional CTRL-C handling
54
+ if (onCtrlC && (input === '\x03' || (key.ctrl && input === 'c'))) {
55
+ onCtrlC();
56
+ exit();
57
+ return;
58
+ }
59
+
60
+ if (key.escape) {
61
+ cancelAndExit();
62
+ return;
63
+ }
64
+
65
+ if (key.upArrow || (horizontal && key.leftArrow)) {
66
+ setSelectedIndex(prev => Math.max(0, prev - 1));
67
+ return;
68
+ }
69
+
70
+ if (key.downArrow || (horizontal && key.rightArrow)) {
71
+ setSelectedIndex(prev => Math.min(itemCount - 1, prev + 1));
72
+ return;
73
+ }
74
+
75
+ if (key.return || (spaceToSelect && input === ' ')) {
76
+ selectAndExit(selectedIndex);
77
+ return;
78
+ }
79
+
80
+ // Number shortcuts (1-based)
81
+ const num = parseInt(input, 10);
82
+ if (num >= 1 && num <= itemCount) {
83
+ selectAndExit(num - 1);
84
+ }
85
+ });
86
+
87
+ useGamepad({
88
+ onUp: () => setSelectedIndex(prev => Math.max(0, prev - 1)),
89
+ onDown: () => setSelectedIndex(prev => Math.min(itemCount - 1, prev + 1)),
90
+ ...(horizontal && {
91
+ onLeft: () => setSelectedIndex(prev => Math.max(0, prev - 1)),
92
+ onRight: () => setSelectedIndex(prev => Math.min(itemCount - 1, prev + 1)),
93
+ }),
94
+ onConfirm: () => selectAndExit(selectedIndex),
95
+ onCancel: cancelAndExit,
96
+ });
97
+
98
+ return { selectedIndex, setSelectedIndex };
99
+ };
@@ -0,0 +1,21 @@
1
+ // Gamepad acceleration settings for held direction buttons
2
+
3
+ /** Delay before repeating starts (ms) */
4
+ export const INITIAL_DELAY_MS = 400;
5
+
6
+ /** Starting repeat interval (ms) */
7
+ export const INITIAL_REPEAT_MS = 200;
8
+
9
+ /** Fastest repeat interval (ms) */
10
+ export const MIN_REPEAT_MS = 40;
11
+
12
+ /** Time to reach max speed (ms) */
13
+ export const ACCELERATION_TIME_MS = 1500;
14
+
15
+ // Ease-in-out curve coefficients (smoothstep function: 3t^2 - 2t^3)
16
+
17
+ /** Smoothstep cubic factor */
18
+ export const EASE_CUBIC_FACTOR = 3;
19
+
20
+ /** Smoothstep cubic divisor */
21
+ export const EASE_CUBIC_DIVISOR = 2;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Gamepad Hook for UI Components
3
+ *
4
+ * Provides gamepad navigation support for Ink-based UI components.
5
+ * Handles Up/Down for navigation, A for confirm, B for cancel.
6
+ * Directional inputs support accelerating repeat when held.
7
+ */
8
+
9
+ import { useEffect, useRef } from 'react';
10
+ import { GamepadManager } from '@/input/GamepadManager';
11
+ import { StandardButton } from '@/core/button';
12
+ import {
13
+ INITIAL_DELAY_MS,
14
+ INITIAL_REPEAT_MS,
15
+ MIN_REPEAT_MS,
16
+ ACCELERATION_TIME_MS,
17
+ EASE_CUBIC_FACTOR,
18
+ EASE_CUBIC_DIVISOR,
19
+ } from './consts';
20
+
21
+ export * from './consts';
22
+
23
+ export interface GamepadCallbacks {
24
+ onUp?: () => void;
25
+ onDown?: () => void;
26
+ onLeft?: () => void;
27
+ onRight?: () => void;
28
+ onConfirm?: () => void; // A button
29
+ onCancel?: () => void; // B button
30
+ onStart?: () => void; // Start button
31
+ }
32
+
33
+ type Direction = 'up' | 'down' | 'left' | 'right';
34
+
35
+ const DIRECTION_CALLBACKS: Record<Direction, keyof Pick<GamepadCallbacks, 'onUp' | 'onDown' | 'onLeft' | 'onRight'>> = {
36
+ up: 'onUp',
37
+ down: 'onDown',
38
+ left: 'onLeft',
39
+ right: 'onRight',
40
+ };
41
+
42
+ const fireDirectionalCallback = (callbacks: GamepadCallbacks, direction: Direction): void => {
43
+ callbacks[DIRECTION_CALLBACKS[direction]]?.();
44
+ };
45
+
46
+ interface RepeatState {
47
+ direction: Direction;
48
+ startTime: number;
49
+ timeoutId: ReturnType<typeof setTimeout> | null;
50
+ }
51
+
52
+ /**
53
+ * Hook for gamepad input in UI components.
54
+ * Automatically manages GamepadManager lifecycle.
55
+ * Directional buttons accelerate when held.
56
+ *
57
+ * @param callbacks Object with callback functions for different inputs
58
+ * @param enabled Whether gamepad input is enabled (default: true)
59
+ */
60
+ export const useGamepad = (callbacks: GamepadCallbacks, enabled: boolean = true): void => {
61
+ const managerRef = useRef<GamepadManager | null>(null);
62
+ const callbacksRef = useRef(callbacks);
63
+ const repeatStateRef = useRef<RepeatState | null>(null);
64
+
65
+ // Keep callbacks ref up to date
66
+ callbacksRef.current = callbacks;
67
+
68
+ useEffect(() => {
69
+ if (!enabled) {return;}
70
+
71
+ // Calculate repeat interval based on how long button has been held
72
+ const getRepeatInterval = (heldDuration: number): number => {
73
+ if (heldDuration < INITIAL_DELAY_MS) {
74
+ return INITIAL_DELAY_MS - heldDuration;
75
+ }
76
+
77
+ // Calculate acceleration progress (0 to 1)
78
+ const accelerationProgress = Math.min(
79
+ 1,
80
+ (heldDuration - INITIAL_DELAY_MS) / ACCELERATION_TIME_MS
81
+ );
82
+
83
+ // Ease-in-out curve for smooth acceleration (smoothstep)
84
+ const easedProgress = accelerationProgress * accelerationProgress * (EASE_CUBIC_FACTOR - EASE_CUBIC_DIVISOR * accelerationProgress);
85
+
86
+ // Interpolate between initial and minimum repeat interval
87
+ return INITIAL_REPEAT_MS - (INITIAL_REPEAT_MS - MIN_REPEAT_MS) * easedProgress;
88
+ };
89
+
90
+ // Fire callback for direction and schedule next repeat
91
+ const fireAndSchedule = (direction: Direction) => {
92
+ const cb = callbacksRef.current;
93
+ const state = repeatStateRef.current;
94
+
95
+ if (!state || state.direction !== direction) {return;}
96
+
97
+ // Fire the appropriate callback
98
+ fireDirectionalCallback(cb, direction);
99
+
100
+ // Schedule next repeat with accelerated interval
101
+ const heldDuration = Date.now() - state.startTime;
102
+ const interval = getRepeatInterval(heldDuration);
103
+
104
+ state.timeoutId = setTimeout(() => fireAndSchedule(direction), interval);
105
+ };
106
+
107
+ // Start repeat for a direction
108
+ const startRepeat = (direction: Direction) => {
109
+ // Cancel any existing repeat
110
+ stopRepeat();
111
+
112
+ // Fire callback immediately
113
+ fireDirectionalCallback(callbacksRef.current, direction);
114
+
115
+ // Start repeat state
116
+ repeatStateRef.current = {
117
+ direction,
118
+ startTime: Date.now(),
119
+ timeoutId: setTimeout(() => fireAndSchedule(direction), INITIAL_DELAY_MS),
120
+ };
121
+ };
122
+
123
+ // Stop any active repeat
124
+ const stopRepeat = () => {
125
+ const state = repeatStateRef.current;
126
+ if (state?.timeoutId) {
127
+ clearTimeout(state.timeoutId);
128
+ }
129
+ repeatStateRef.current = null;
130
+ };
131
+
132
+ // Map button to direction
133
+ const buttonToDirection = (button: StandardButton): Direction | null => {
134
+ switch (button) {
135
+ case StandardButton.Up:
136
+ case StandardButton.LeftStickUp:
137
+ return 'up';
138
+ case StandardButton.Down:
139
+ case StandardButton.LeftStickDown:
140
+ return 'down';
141
+ case StandardButton.Left:
142
+ case StandardButton.LeftStickLeft:
143
+ return 'left';
144
+ case StandardButton.Right:
145
+ case StandardButton.LeftStickRight:
146
+ return 'right';
147
+ default:
148
+ return null;
149
+ }
150
+ };
151
+
152
+ // Create and start the gamepad manager
153
+ const manager = new GamepadManager();
154
+ managerRef.current = manager;
155
+
156
+ manager.onButtonChange = (_port, button, pressed) => {
157
+ const cb = callbacksRef.current;
158
+ const direction = buttonToDirection(button);
159
+
160
+ if (direction) {
161
+ // Directional buttons with acceleration
162
+ if (pressed) {
163
+ startRepeat(direction);
164
+ } else {
165
+ // Only stop if this is the direction currently being held
166
+ if (repeatStateRef.current?.direction === direction) {
167
+ stopRepeat();
168
+ }
169
+ }
170
+ } else if (pressed) {
171
+ // Non-directional buttons: fire on press only
172
+ switch (button) {
173
+ case StandardButton.A:
174
+ cb.onConfirm?.();
175
+ break;
176
+ case StandardButton.B:
177
+ cb.onCancel?.();
178
+ break;
179
+ case StandardButton.Start:
180
+ cb.onStart?.();
181
+ break;
182
+ }
183
+ }
184
+ };
185
+
186
+ manager.start();
187
+
188
+ return () => {
189
+ stopRepeat();
190
+ manager.stop();
191
+ managerRef.current = null;
192
+ };
193
+ }, [enabled]);
194
+ };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * UI Module Exports
3
+ */
4
+
5
+ export * from './consts';
6
+ export { launchBrowser, importDirectory } from './App';
7
+ export type { BrowserResult, NetplayOptions } from './App';
8
+ export { selectCore } from './CoreSelector';
9
+ export type { CoreSelection } from './CoreSelector';
10
+ export { showSaveStateDialog, showCorruptedStateDialog } from './SaveStateDialog';
11
+ export type { SaveStateInfo, SaveStateChoice, CorruptedStateInfo, CorruptedStateChoice } from './SaveStateDialog';
12
+ export { showNetplayDisconnectedDialog } from './NetplayDisconnectedDialog';
13
+ export type { DisconnectInfo, DisconnectChoice } from './NetplayDisconnectedDialog';
14
+ export { showWarningDialog } from './WarningDialog';
15
+ export type { WarningChoice } from './WarningDialog';
16
+ export { scanDirectory, groupBySystem, validateRomFile } from '../frontend/romScanner';
17
+ export type { RomInfo, RomMetadata, ValidateRomResult } from '../frontend/romScanner';
18
+ export { GamepadProvider, useGamepadContext } from './GamepadContext';
19
+ export type { GamepadCallbacks } from './GamepadContext';
20
+ export { AppCapabilitiesProvider, useAppCapabilities, useKittyGraphicsSupported, useNativeSupported } from './AppCapabilities';
21
+ export type { AppCapabilities } from './AppCapabilities';
22
+ export { ConfigProvider, useConfig } from './ConfigContext';
23
+ export { showDuplicateCrcPrompt } from './DuplicateCrcPrompt';
24
+ export type { DuplicateCrcInfo, DuplicateCrcChoice } from './DuplicateCrcPrompt';
25
+ export type { DialogRenderOptions } from './NativeDialog';
26
+ export { useClearTerminal } from './hooks/useClearTerminal';
27
+ export { useGamepad, type GamepadCallbacks as HookGamepadCallbacks } from './hooks/useGamepad';
@@ -0,0 +1,17 @@
1
+ /** Bit shift for second byte */
2
+ export const BYTE_SHIFT_1 = 8;
3
+
4
+ /** Center value for unsigned 8-bit analog input (0-255 range) */
5
+ export const ANALOG_CENTER_8BIT = 128;
6
+
7
+ /** Default deadzone for 8-bit analog input */
8
+ export const ANALOG_DEADZONE_8BIT = 50;
9
+
10
+ /** Default deadzone for signed 16-bit analog input */
11
+ export const ANALOG_DEADZONE_SIGNED = 8000;
12
+
13
+ /** Maximum positive value for signed 16-bit integer */
14
+ export const INT16_MAX = 32767;
15
+
16
+ /** Full range of unsigned 16-bit integer (for signed conversion) */
17
+ export const UINT16_RANGE = 65536;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Buffer Reading Utilities
3
+ *
4
+ * Utilities for reading binary data and gamepad input processing.
5
+ */
6
+
7
+ import { StandardButton } from '../../core/button';
8
+ import {
9
+ BYTE_SHIFT_1,
10
+ ANALOG_CENTER_8BIT,
11
+ ANALOG_DEADZONE_8BIT,
12
+ ANALOG_DEADZONE_SIGNED,
13
+ INT16_MAX,
14
+ UINT16_RANGE,
15
+ } from './consts';
16
+
17
+ export * from './consts';
18
+
19
+ /**
20
+ * D-pad direction state
21
+ */
22
+ export interface DpadState {
23
+ up: boolean;
24
+ down: boolean;
25
+ left: boolean;
26
+ right: boolean;
27
+ }
28
+
29
+ /**
30
+ * Read unsigned 16-bit little-endian value from buffer
31
+ */
32
+ export const readUint16LE = (data: Buffer, offset: number): number =>
33
+ data[offset] | (data[offset + 1] << BYTE_SHIFT_1);
34
+
35
+ /**
36
+ * Read signed 16-bit little-endian value from buffer
37
+ * Converts unsigned 16-bit to signed (-32768 to +32767)
38
+ */
39
+ export const readInt16LE = (data: Buffer, offset: number): number => {
40
+ const raw = readUint16LE(data, offset);
41
+ return raw > INT16_MAX ? raw - UINT16_RANGE : raw;
42
+ };
43
+
44
+ /**
45
+ * Apply analog stick values to d-pad buttons with deadzone
46
+ * For signed analog values centered at 0 (-32768 to +32767)
47
+ */
48
+ export const applySignedAnalogToDpad = (
49
+ buttons: Map<StandardButton, boolean>,
50
+ x: number,
51
+ y: number,
52
+ deadzone: number = ANALOG_DEADZONE_SIGNED
53
+ ): void => {
54
+ if (x < -deadzone) { buttons.set(StandardButton.Left, true); }
55
+ if (x > deadzone) { buttons.set(StandardButton.Right, true); }
56
+ if (y < -deadzone) { buttons.set(StandardButton.Down, true); }
57
+ if (y > deadzone) { buttons.set(StandardButton.Up, true); }
58
+ };
59
+
60
+ /**
61
+ * Convert analog stick values to digital d-pad state
62
+ * For unsigned values (0-255 range with 128 as center)
63
+ */
64
+ export const analogToDpad = (
65
+ x: number,
66
+ y: number,
67
+ deadzone: number = ANALOG_DEADZONE_8BIT,
68
+ center: number = ANALOG_CENTER_8BIT
69
+ ): DpadState => ({
70
+ left: x < center - deadzone,
71
+ right: x > center + deadzone,
72
+ up: y < center - deadzone,
73
+ down: y > center + deadzone,
74
+ });
75
+
76
+ /**
77
+ * Neutral d-pad state (no buttons pressed)
78
+ */
79
+ const DPAD_NEUTRAL: DpadState = { up: false, down: false, left: false, right: false };
80
+
81
+ /**
82
+ * Standard HID hat values: 0=N, 1=NE, 2=E, 3=SE, 4=S, 5=SW, 6=W, 7=NW, 8/15=center
83
+ */
84
+ const STANDARD_HAT_MAP: Record<number, DpadState> = {
85
+ 0: { up: true, down: false, left: false, right: false }, // N
86
+ 1: { up: true, down: false, left: false, right: true }, // NE
87
+ 2: { up: false, down: false, left: false, right: true }, // E
88
+ 3: { up: false, down: true, left: false, right: true }, // SE
89
+ 4: { up: false, down: true, left: false, right: false }, // S
90
+ 5: { up: false, down: true, left: true, right: false }, // SW
91
+ 6: { up: false, down: false, left: true, right: false }, // W
92
+ 7: { up: true, down: false, left: true, right: false }, // NW
93
+ };
94
+
95
+ /**
96
+ * Xbox-style hat values: 0=none, 1=N, 2=NE, 3=E, 4=SE, 5=S, 6=SW, 7=W, 8=NW
97
+ */
98
+ const XBOX_HAT_MAP: Record<number, DpadState> = {
99
+ 0: DPAD_NEUTRAL, // None
100
+ 1: { up: true, down: false, left: false, right: false }, // N
101
+ 2: { up: true, down: false, left: false, right: true }, // NE
102
+ 3: { up: false, down: false, left: false, right: true }, // E
103
+ 4: { up: false, down: true, left: false, right: true }, // SE
104
+ 5: { up: false, down: true, left: false, right: false }, // S
105
+ 6: { up: false, down: true, left: true, right: false }, // SW
106
+ 7: { up: false, down: false, left: true, right: false }, // W
107
+ 8: { up: true, down: false, left: true, right: false }, // NW
108
+ };
109
+
110
+ /**
111
+ * Parse hat switch value to d-pad state
112
+ * @param hat - Hat switch value from HID report
113
+ * @param xboxStyle - If true, use Xbox 1-indexed hat values (0=none, 1=N, etc.)
114
+ */
115
+ export const hatToDpad = (hat: number, xboxStyle: boolean = false): DpadState =>
116
+ (xboxStyle ? XBOX_HAT_MAP : STANDARD_HAT_MAP)[hat] ?? DPAD_NEUTRAL;
117
+
118
+ /**
119
+ * Apply d-pad state to button map
120
+ */
121
+ export const applyDpadToButtons = (
122
+ buttons: Map<StandardButton, boolean>,
123
+ dpad: DpadState
124
+ ): void => {
125
+ if (dpad.up) { buttons.set(StandardButton.Up, true); }
126
+ if (dpad.down) { buttons.set(StandardButton.Down, true); }
127
+ if (dpad.left) { buttons.set(StandardButton.Left, true); }
128
+ if (dpad.right) { buttons.set(StandardButton.Right, true); }
129
+ };