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.
- package/.claude/settings.local.json +77 -0
- package/.node-version +1 -0
- package/CLAUDE.md +435 -0
- package/README.md +404 -0
- package/TODO.md +655 -0
- package/dist/index.cjs +25108 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +25085 -0
- package/docs/building-libretro-cores-arm-mac.md +237 -0
- package/docs/config-file-format.md +488 -0
- package/docs/cores-trd.md +425 -0
- package/docs/headless-hardware-rendering-trd.md +676 -0
- package/docs/libretro-cores-trd.md +997 -0
- package/docs/mupen64-software-rendering-trd.md +751 -0
- package/docs/n64-support-trd.md +306 -0
- package/docs/native-rendering-trd.md +540 -0
- package/docs/native-ui-rendering-trd.md +1195 -0
- package/docs/netplay-trd.md +665 -0
- package/docs/retroarch-netplay-docs.md +277 -0
- package/docs/save-state-format.md +666 -0
- package/eslint.config.js +111 -0
- package/icon/icon.png +0 -0
- package/icon/icon.pxd +0 -0
- package/package.json +63 -0
- package/pnpm-workspace.yaml +10 -0
- package/src/Emulator/consts.ts +14 -0
- package/src/Emulator/index.ts +2496 -0
- package/src/Emulator/saveState/index.ts +155 -0
- package/src/Emulator/screenshot/index.ts +160 -0
- package/src/Emulator/terminalDimensions/index.ts +79 -0
- package/src/Emulator/types.ts +83 -0
- package/src/cli/commands/consts.ts +10 -0
- package/src/cli/commands/index.ts +462 -0
- package/src/cli/parseArgs/consts.ts +17 -0
- package/src/cli/parseArgs/index.ts +457 -0
- package/src/cli/parseArgs/types.ts +61 -0
- package/src/cli/runEmulator/index.ts +406 -0
- package/src/cli/runEmulator/types.ts +7 -0
- package/src/consts.ts +19 -0
- package/src/core/button/consts.ts +35 -0
- package/src/core/button/index.ts +123 -0
- package/src/core/core.ts +300 -0
- package/src/core/index.ts +19 -0
- package/src/cores/libretro/api/index.ts +334 -0
- package/src/cores/libretro/api/types.ts +148 -0
- package/src/cores/libretro/callbacks/consts.ts +41 -0
- package/src/cores/libretro/callbacks/index.ts +456 -0
- package/src/cores/libretro/consts.ts +45 -0
- package/src/cores/libretro/coreOptions/consts.ts +36 -0
- package/src/cores/libretro/coreOptions/index.ts +222 -0
- package/src/cores/libretro/environment/consts.ts +118 -0
- package/src/cores/libretro/environment/index.ts +1095 -0
- package/src/cores/libretro/index.ts +937 -0
- package/src/cores/libretro/loader/index.ts +496 -0
- package/src/cores/libretro/pixelFormat/consts.ts +43 -0
- package/src/cores/libretro/pixelFormat/index.ts +397 -0
- package/src/cores/libretro/types.ts +339 -0
- package/src/frontend/AudioManager/index.ts +420 -0
- package/src/frontend/SettingsManager/index.ts +250 -0
- package/src/frontend/config/index.ts +608 -0
- package/src/frontend/config/tests.ts +354 -0
- package/src/frontend/config/types.ts +36 -0
- package/src/frontend/consts.ts +114 -0
- package/src/frontend/coreBuilder/index.ts +644 -0
- package/src/frontend/coreBuilder/types.ts +15 -0
- package/src/frontend/coreDownloader/index.ts +620 -0
- package/src/frontend/coreDownloader/types.ts +17 -0
- package/src/frontend/corePreferences/index.ts +69 -0
- package/src/frontend/coreRegistry/index.ts +276 -0
- package/src/frontend/directoryCache/index.ts +75 -0
- package/src/frontend/index.ts +79 -0
- package/src/frontend/notifications/consts.ts +14 -0
- package/src/frontend/notifications/index.ts +250 -0
- package/src/frontend/playlist/consts.ts +168 -0
- package/src/frontend/playlist/index.ts +899 -0
- package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
- package/src/frontend/playlist/labelFormatter/index.ts +155 -0
- package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
- package/src/frontend/playlist/reader/index.ts +559 -0
- package/src/frontend/playlist/sync/index.ts +511 -0
- package/src/frontend/playlist/systemLookup/index.ts +233 -0
- package/src/frontend/playlist/utils/index.ts +50 -0
- package/src/frontend/romScanner/consts.ts +348 -0
- package/src/frontend/romScanner/index.ts +1957 -0
- package/src/frontend/saveServices/consts.ts +2 -0
- package/src/frontend/saveServices/index.ts +313 -0
- package/src/frontend/serviceProvider/index.ts +108 -0
- package/src/frontend/serviceProvider/types.ts +13 -0
- package/src/index.ts +428 -0
- package/src/input/Controller/consts.ts +50 -0
- package/src/input/Controller/index.ts +81 -0
- package/src/input/GamepadManager/consts.ts +22 -0
- package/src/input/GamepadManager/index.ts +418 -0
- package/src/input/InputManager/consts.ts +86 -0
- package/src/input/InputManager/index.ts +593 -0
- package/src/input/InputMapper/consts.ts +33 -0
- package/src/input/InputMapper/index.ts +436 -0
- package/src/input/consts.ts +410 -0
- package/src/input/gamepadProfiles/index.ts +1100 -0
- package/src/input/index.ts +38 -0
- package/src/input/inputUtils/index.ts +31 -0
- package/src/netplay/FrameBuffer/consts.ts +2 -0
- package/src/netplay/FrameBuffer/index.ts +364 -0
- package/src/netplay/FrameBuffer/tests.ts +286 -0
- package/src/netplay/InputBuffer/consts.ts +7 -0
- package/src/netplay/InputBuffer/index.ts +347 -0
- package/src/netplay/InputBuffer/tests.ts +166 -0
- package/src/netplay/NetplayClient/index.ts +976 -0
- package/src/netplay/NetplayConnection/index.ts +536 -0
- package/src/netplay/NetplayDiscovery/consts.ts +41 -0
- package/src/netplay/NetplayDiscovery/index.ts +525 -0
- package/src/netplay/NetplayServer/index.ts +1407 -0
- package/src/netplay/SyncManager/index.ts +984 -0
- package/src/netplay/SyncManager/tests.ts +419 -0
- package/src/netplay/consts.ts +371 -0
- package/src/netplay/crc32/consts.ts +14 -0
- package/src/netplay/crc32/index.ts +97 -0
- package/src/netplay/crc32/tests.ts +40 -0
- package/src/netplay/index.ts +41 -0
- package/src/netplay/netplayLogger/consts.ts +30 -0
- package/src/netplay/netplayLogger/index.ts +345 -0
- package/src/netplay/protocol/consts.ts +86 -0
- package/src/netplay/protocol/index.ts +1280 -0
- package/src/netplay/protocol/tests.ts +606 -0
- package/src/netplay/protocol/types.ts +20 -0
- package/src/netplay/types.ts +395 -0
- package/src/rendering/KittyRenderer/index.ts +616 -0
- package/src/rendering/NativeRenderer/index.ts +279 -0
- package/src/rendering/NativeRenderer/tests.ts +133 -0
- package/src/rendering/TerminalRenderer/index.ts +770 -0
- package/src/rendering/consts.ts +401 -0
- package/src/rendering/fonts/CozetteVector.ttf +0 -0
- package/src/rendering/index.ts +26 -0
- package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
- package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
- package/src/rendering/nativeUi/consts.ts +6 -0
- package/src/rendering/nativeUi/index.ts +20 -0
- package/src/rendering/postProcessing/consts.ts +38 -0
- package/src/rendering/postProcessing/index.ts +923 -0
- package/src/rendering/shared/ansi/consts.ts +12 -0
- package/src/rendering/shared/ansi/index.ts +104 -0
- package/src/rendering/shared/consts.ts +2 -0
- package/src/rendering/shared/fitToTerminal/index.ts +67 -0
- package/src/ui/AddRomsPrompt/consts.ts +13 -0
- package/src/ui/AddRomsPrompt/index.tsx +781 -0
- package/src/ui/App/consts.ts +2 -0
- package/src/ui/App/index.tsx +456 -0
- package/src/ui/AppCapabilities/index.tsx +67 -0
- package/src/ui/ConfigContext/index.tsx +56 -0
- package/src/ui/CoreManager/consts.ts +11 -0
- package/src/ui/CoreManager/index.tsx +779 -0
- package/src/ui/CoreSelector/consts.ts +2 -0
- package/src/ui/CoreSelector/index.tsx +251 -0
- package/src/ui/DialogContainer/index.tsx +42 -0
- package/src/ui/DialogOptionsList/index.tsx +61 -0
- package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
- package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
- package/src/ui/GamepadContext/consts.ts +15 -0
- package/src/ui/GamepadContext/index.tsx +295 -0
- package/src/ui/NativeDialog/index.tsx +120 -0
- package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
- package/src/ui/NetplayPauseMenu/consts.ts +2 -0
- package/src/ui/NetplayPauseMenu/index.tsx +97 -0
- package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
- package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
- package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
- package/src/ui/RomBrowser/consts.ts +61 -0
- package/src/ui/RomBrowser/index.tsx +1164 -0
- package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
- package/src/ui/RomBrowser/types.ts +67 -0
- package/src/ui/SaveStateDialog/consts.ts +2 -0
- package/src/ui/SaveStateDialog/index.tsx +225 -0
- package/src/ui/WarningDialog/index.tsx +113 -0
- package/src/ui/consts.ts +27 -0
- package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
- package/src/ui/hooks/useClearTerminal/index.ts +37 -0
- package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
- package/src/ui/hooks/useGamepad/consts.ts +21 -0
- package/src/ui/hooks/useGamepad/index.ts +194 -0
- package/src/ui/index.ts +27 -0
- package/src/utils/buffer/consts.ts +17 -0
- package/src/utils/buffer/index.ts +129 -0
- package/src/utils/color/consts.ts +58 -0
- package/src/utils/color/index.ts +183 -0
- package/src/utils/compression/consts.ts +50 -0
- package/src/utils/compression/index.ts +101 -0
- package/src/utils/consts.ts +2 -0
- package/src/utils/crc32/consts.ts +22 -0
- package/src/utils/crc32/index.ts +83 -0
- package/src/utils/ensureDirectory/index.ts +10 -0
- package/src/utils/format/consts.ts +8 -0
- package/src/utils/format/index.ts +53 -0
- package/src/utils/getErrorMessage/index.ts +10 -0
- package/src/utils/index.ts +113 -0
- package/src/utils/ini/index.ts +200 -0
- package/src/utils/kitty/consts.ts +13 -0
- package/src/utils/kitty/index.ts +181 -0
- package/src/utils/logger/consts.ts +35 -0
- package/src/utils/logger/index.ts +217 -0
- package/src/utils/paths/consts.ts +18 -0
- package/src/utils/paths/index.ts +151 -0
- package/src/utils/png/consts.ts +34 -0
- package/src/utils/png/index.ts +131 -0
- package/src/utils/readJsonFile/index.ts +16 -0
- package/src/utils/rotateLogFile/index.ts +44 -0
- package/src/utils/safeClose/index.ts +10 -0
- package/src/utils/terminal/consts.ts +8 -0
- package/src/utils/terminal/index.ts +102 -0
- package/src/utils/thumbnailRenderer/consts.ts +2 -0
- package/src/utils/thumbnailRenderer/index.ts +147 -0
- package/src/utils/typedError/index.ts +26 -0
- package/tsconfig.json +31 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,997 @@
|
|
|
1
|
+
# Libretro Core Support - Technical Requirements Document
|
|
2
|
+
|
|
3
|
+
This document describes the design for loading and running native libretro cores (RetroArch cores) within emoemu, enabling support for additional systems like Sega Genesis, PlayStation, and others without writing new emulation code.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
### Goals
|
|
8
|
+
|
|
9
|
+
1. **Leverage Existing Cores**: Use battle-tested libretro cores (PicoDrive, Beetle PSX, etc.) instead of writing new emulators
|
|
10
|
+
2. **Seamless Integration**: Wrap libretro cores to implement our existing `Core` interface
|
|
11
|
+
3. **Cross-Platform**: Support macOS (.dylib), Linux (.so), and Windows (.dll)
|
|
12
|
+
4. **Minimal Overhead**: Efficient data passing between JavaScript and native code
|
|
13
|
+
|
|
14
|
+
### Non-Goals
|
|
15
|
+
|
|
16
|
+
1. Full RetroArch compatibility (shaders, netplay, achievements, etc.)
|
|
17
|
+
2. Dynamic core downloading (cores must be provided by user)
|
|
18
|
+
3. Support for cores requiring OpenGL/Vulkan contexts
|
|
19
|
+
|
|
20
|
+
### Background: Libretro API
|
|
21
|
+
|
|
22
|
+
Libretro is a C API that defines a standard interface between emulator cores and frontends. Key characteristics:
|
|
23
|
+
|
|
24
|
+
- **Dynamic Libraries**: Cores are `.so`/`.dll`/`.dylib` files
|
|
25
|
+
- **Callback-Based**: Frontend provides function pointers for video/audio/input
|
|
26
|
+
- **Environment Queries**: Cores request capabilities via environment callback
|
|
27
|
+
- **Standardized I/O**: Common formats for framebuffer, audio samples, input polling
|
|
28
|
+
|
|
29
|
+
Popular libretro cores:
|
|
30
|
+
- **PicoDrive**: Sega Genesis/Mega Drive, Master System, Game Gear, 32X, Sega CD
|
|
31
|
+
- **Beetle PSX**: PlayStation 1
|
|
32
|
+
- **mGBA**: Game Boy Advance
|
|
33
|
+
- **Snes9x**: Super Nintendo (alternative to our native core)
|
|
34
|
+
- **Mupen64Plus-Next**: Nintendo 64
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Architecture
|
|
39
|
+
|
|
40
|
+
### High-Level Design
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
┌─────────────────────────────────────────────────────────┐
|
|
44
|
+
│ emoemu Frontend │
|
|
45
|
+
│ (Emulator, Renderers, Audio, Input, State Manager) │
|
|
46
|
+
└─────────────────────────┬───────────────────────────────┘
|
|
47
|
+
│ Core Interface
|
|
48
|
+
▼
|
|
49
|
+
┌─────────────────────────────────────────────────────────┐
|
|
50
|
+
│ LibretroCore │
|
|
51
|
+
│ (Implements Core, wraps libretro API) │
|
|
52
|
+
└─────────────────────────┬───────────────────────────────┘
|
|
53
|
+
│ FFI (koffi)
|
|
54
|
+
▼
|
|
55
|
+
┌─────────────────────────────────────────────────────────┐
|
|
56
|
+
│ Native Libretro Core (.so/.dylib) │
|
|
57
|
+
│ (picodrive_libretro.dylib, etc.) │
|
|
58
|
+
└─────────────────────────────────────────────────────────┘
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Directory Structure
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
src/cores/libretro/
|
|
65
|
+
├── index.ts # LibretroCore class (implements Core)
|
|
66
|
+
├── api.ts # FFI bindings for libretro functions
|
|
67
|
+
├── types.ts # Libretro type definitions
|
|
68
|
+
├── environment.ts # Environment callback handler
|
|
69
|
+
├── callbacks.ts # Video/audio/input callback implementations
|
|
70
|
+
└── core-info.ts # Core metadata and system info mapping
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Libretro API Bindings
|
|
76
|
+
|
|
77
|
+
### Core Functions
|
|
78
|
+
|
|
79
|
+
The libretro API consists of ~25 functions. Essential ones for basic operation:
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
// src/cores/libretro/api.ts
|
|
83
|
+
|
|
84
|
+
import koffi from 'koffi';
|
|
85
|
+
|
|
86
|
+
// Type definitions
|
|
87
|
+
const retro_game_info = koffi.struct('retro_game_info', {
|
|
88
|
+
path: 'const char*',
|
|
89
|
+
data: 'const void*',
|
|
90
|
+
size: 'size_t',
|
|
91
|
+
meta: 'const char*',
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const retro_system_info = koffi.struct('retro_system_info', {
|
|
95
|
+
library_name: 'const char*',
|
|
96
|
+
library_version: 'const char*',
|
|
97
|
+
valid_extensions: 'const char*',
|
|
98
|
+
need_fullpath: 'bool',
|
|
99
|
+
block_extract: 'bool',
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const retro_system_av_info = koffi.struct('retro_system_av_info', {
|
|
103
|
+
geometry: koffi.struct({
|
|
104
|
+
base_width: 'unsigned',
|
|
105
|
+
base_height: 'unsigned',
|
|
106
|
+
max_width: 'unsigned',
|
|
107
|
+
max_height: 'unsigned',
|
|
108
|
+
aspect_ratio: 'float',
|
|
109
|
+
}),
|
|
110
|
+
timing: koffi.struct({
|
|
111
|
+
fps: 'double',
|
|
112
|
+
sample_rate: 'double',
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Callback types
|
|
117
|
+
const retro_video_refresh_t = koffi.proto(
|
|
118
|
+
'void retro_video_refresh_t(const void* data, unsigned width, unsigned height, size_t pitch)'
|
|
119
|
+
);
|
|
120
|
+
const retro_audio_sample_t = koffi.proto(
|
|
121
|
+
'void retro_audio_sample_t(int16_t left, int16_t right)'
|
|
122
|
+
);
|
|
123
|
+
const retro_audio_sample_batch_t = koffi.proto(
|
|
124
|
+
'size_t retro_audio_sample_batch_t(const int16_t* data, size_t frames)'
|
|
125
|
+
);
|
|
126
|
+
const retro_input_poll_t = koffi.proto('void retro_input_poll_t()');
|
|
127
|
+
const retro_input_state_t = koffi.proto(
|
|
128
|
+
'int16_t retro_input_state_t(unsigned port, unsigned device, unsigned index, unsigned id)'
|
|
129
|
+
);
|
|
130
|
+
const retro_environment_t = koffi.proto(
|
|
131
|
+
'bool retro_environment_t(unsigned cmd, void* data)'
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
export class LibretroAPI {
|
|
135
|
+
private lib: koffi.IKoffiLib;
|
|
136
|
+
|
|
137
|
+
// Core functions
|
|
138
|
+
retro_init: () => void;
|
|
139
|
+
retro_deinit: () => void;
|
|
140
|
+
retro_api_version: () => number;
|
|
141
|
+
retro_get_system_info: (info: any) => void;
|
|
142
|
+
retro_get_system_av_info: (info: any) => void;
|
|
143
|
+
retro_set_controller_port_device: (port: number, device: number) => void;
|
|
144
|
+
retro_reset: () => void;
|
|
145
|
+
retro_run: () => void;
|
|
146
|
+
retro_load_game: (game: any) => boolean;
|
|
147
|
+
retro_unload_game: () => void;
|
|
148
|
+
|
|
149
|
+
// Serialization
|
|
150
|
+
retro_serialize_size: () => number;
|
|
151
|
+
retro_serialize: (data: Buffer, size: number) => boolean;
|
|
152
|
+
retro_unserialize: (data: Buffer, size: number) => boolean;
|
|
153
|
+
|
|
154
|
+
// Memory access
|
|
155
|
+
retro_get_memory_data: (id: number) => Buffer | null;
|
|
156
|
+
retro_get_memory_size: (id: number) => number;
|
|
157
|
+
|
|
158
|
+
// Callback setters
|
|
159
|
+
retro_set_video_refresh: (cb: any) => void;
|
|
160
|
+
retro_set_audio_sample: (cb: any) => void;
|
|
161
|
+
retro_set_audio_sample_batch: (cb: any) => void;
|
|
162
|
+
retro_set_input_poll: (cb: any) => void;
|
|
163
|
+
retro_set_input_state: (cb: any) => void;
|
|
164
|
+
retro_set_environment: (cb: any) => void;
|
|
165
|
+
|
|
166
|
+
constructor(corePath: string) {
|
|
167
|
+
this.lib = koffi.load(corePath);
|
|
168
|
+
this.bindFunctions();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private bindFunctions(): void {
|
|
172
|
+
this.retro_init = this.lib.func('void retro_init()');
|
|
173
|
+
this.retro_deinit = this.lib.func('void retro_deinit()');
|
|
174
|
+
this.retro_api_version = this.lib.func('unsigned retro_api_version()');
|
|
175
|
+
this.retro_run = this.lib.func('void retro_run()');
|
|
176
|
+
this.retro_reset = this.lib.func('void retro_reset()');
|
|
177
|
+
this.retro_load_game = this.lib.func('bool retro_load_game(retro_game_info*)');
|
|
178
|
+
this.retro_unload_game = this.lib.func('void retro_unload_game()');
|
|
179
|
+
// ... bind remaining functions
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
destroy(): void {
|
|
183
|
+
this.lib.unload();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Memory Constants
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
// Memory region IDs for retro_get_memory_data/size
|
|
192
|
+
export const RETRO_MEMORY = {
|
|
193
|
+
SAVE_RAM: 0, // Battery-backed save RAM
|
|
194
|
+
RTC: 1, // Real-time clock
|
|
195
|
+
SYSTEM_RAM: 2, // Main system RAM
|
|
196
|
+
VIDEO_RAM: 3, // Video RAM
|
|
197
|
+
} as const;
|
|
198
|
+
|
|
199
|
+
// Device types for retro_set_controller_port_device
|
|
200
|
+
export const RETRO_DEVICE = {
|
|
201
|
+
NONE: 0,
|
|
202
|
+
JOYPAD: 1,
|
|
203
|
+
MOUSE: 2,
|
|
204
|
+
KEYBOARD: 3,
|
|
205
|
+
LIGHTGUN: 4,
|
|
206
|
+
ANALOG: 5,
|
|
207
|
+
} as const;
|
|
208
|
+
|
|
209
|
+
// Joypad button IDs for retro_input_state
|
|
210
|
+
export const RETRO_DEVICE_ID_JOYPAD = {
|
|
211
|
+
B: 0,
|
|
212
|
+
Y: 1,
|
|
213
|
+
SELECT: 2,
|
|
214
|
+
START: 3,
|
|
215
|
+
UP: 4,
|
|
216
|
+
DOWN: 5,
|
|
217
|
+
LEFT: 6,
|
|
218
|
+
RIGHT: 7,
|
|
219
|
+
A: 8,
|
|
220
|
+
X: 9,
|
|
221
|
+
L: 10,
|
|
222
|
+
R: 11,
|
|
223
|
+
L2: 12,
|
|
224
|
+
R2: 13,
|
|
225
|
+
L3: 14,
|
|
226
|
+
R3: 15,
|
|
227
|
+
} as const;
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## Environment Callback
|
|
233
|
+
|
|
234
|
+
The environment callback is how cores query frontend capabilities and configure behavior. This is the most complex part of the integration.
|
|
235
|
+
|
|
236
|
+
### Environment Commands
|
|
237
|
+
|
|
238
|
+
```typescript
|
|
239
|
+
// src/cores/libretro/environment.ts
|
|
240
|
+
|
|
241
|
+
// Subset of environment commands (full list has 100+ commands)
|
|
242
|
+
export const RETRO_ENVIRONMENT = {
|
|
243
|
+
// Video
|
|
244
|
+
SET_PIXEL_FORMAT: 10, // Request pixel format (0555, XRGB8888, RGB565)
|
|
245
|
+
GET_SYSTEM_DIRECTORY: 9, // Path to system files (BIOS)
|
|
246
|
+
SET_GEOMETRY: 37, // Change video geometry mid-game
|
|
247
|
+
GET_VARIABLE: 15, // Get core option value
|
|
248
|
+
SET_VARIABLES: 16, // Define core options
|
|
249
|
+
|
|
250
|
+
// Input
|
|
251
|
+
SET_INPUT_DESCRIPTORS: 31, // Describe input layout
|
|
252
|
+
GET_INPUT_BITMASKS: 52, // Request bitmask input polling
|
|
253
|
+
|
|
254
|
+
// Save
|
|
255
|
+
GET_SAVE_DIRECTORY: 31, // Path for save files
|
|
256
|
+
|
|
257
|
+
// Info
|
|
258
|
+
GET_LOG_INTERFACE: 27, // Logging callback
|
|
259
|
+
SET_SUPPORT_NO_GAME: 18, // Core can run without ROM
|
|
260
|
+
GET_CORE_OPTIONS_VERSION: 52, // Options API version
|
|
261
|
+
} as const;
|
|
262
|
+
|
|
263
|
+
// Pixel formats
|
|
264
|
+
export const RETRO_PIXEL_FORMAT = {
|
|
265
|
+
XRGB1555: 0, // 15-bit, X ignored
|
|
266
|
+
XRGB8888: 1, // 32-bit XRGB
|
|
267
|
+
RGB565: 2, // 16-bit RGB
|
|
268
|
+
} as const;
|
|
269
|
+
|
|
270
|
+
export class EnvironmentHandler {
|
|
271
|
+
private pixelFormat = RETRO_PIXEL_FORMAT.XRGB1555;
|
|
272
|
+
private variables = new Map<string, string>();
|
|
273
|
+
private systemDirectory = './system';
|
|
274
|
+
private saveDirectory = './saves';
|
|
275
|
+
|
|
276
|
+
/** Handle environment callback from core */
|
|
277
|
+
handle(cmd: number, data: Buffer | null): boolean {
|
|
278
|
+
switch (cmd) {
|
|
279
|
+
case RETRO_ENVIRONMENT.SET_PIXEL_FORMAT:
|
|
280
|
+
if (data) {
|
|
281
|
+
this.pixelFormat = data.readUInt32LE(0);
|
|
282
|
+
return this.pixelFormat <= RETRO_PIXEL_FORMAT.RGB565;
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
|
|
286
|
+
case RETRO_ENVIRONMENT.GET_SYSTEM_DIRECTORY:
|
|
287
|
+
if (data) {
|
|
288
|
+
// Write string pointer to data
|
|
289
|
+
// Complex: requires allocating native string
|
|
290
|
+
}
|
|
291
|
+
return true;
|
|
292
|
+
|
|
293
|
+
case RETRO_ENVIRONMENT.GET_VARIABLE:
|
|
294
|
+
// Core requesting option value
|
|
295
|
+
return this.handleGetVariable(data);
|
|
296
|
+
|
|
297
|
+
case RETRO_ENVIRONMENT.SET_VARIABLES:
|
|
298
|
+
// Core defining available options
|
|
299
|
+
return this.handleSetVariables(data);
|
|
300
|
+
|
|
301
|
+
case RETRO_ENVIRONMENT.GET_LOG_INTERFACE:
|
|
302
|
+
// Provide logging callback
|
|
303
|
+
return this.handleLogInterface(data);
|
|
304
|
+
|
|
305
|
+
case RETRO_ENVIRONMENT.SET_INPUT_DESCRIPTORS:
|
|
306
|
+
// Core describing its input layout
|
|
307
|
+
return true; // Accept but we use our own mapping
|
|
308
|
+
|
|
309
|
+
default:
|
|
310
|
+
// Unknown command - return false
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
getPixelFormat(): number {
|
|
316
|
+
return this.pixelFormat;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private handleGetVariable(data: Buffer | null): boolean {
|
|
320
|
+
// Parse retro_variable struct, look up value, write back
|
|
321
|
+
return false; // Stub
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private handleSetVariables(data: Buffer | null): boolean {
|
|
325
|
+
// Parse variable definitions, store defaults
|
|
326
|
+
return true; // Stub
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private handleLogInterface(data: Buffer | null): boolean {
|
|
330
|
+
// Provide logging callback
|
|
331
|
+
return false; // Stub - logging not implemented
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### Callback Registration
|
|
337
|
+
|
|
338
|
+
```typescript
|
|
339
|
+
// src/cores/libretro/callbacks.ts
|
|
340
|
+
|
|
341
|
+
import koffi from 'koffi';
|
|
342
|
+
|
|
343
|
+
export class CallbackManager {
|
|
344
|
+
private videoCallback: koffi.IKoffiRegisteredCallback | null = null;
|
|
345
|
+
private audioCallback: koffi.IKoffiRegisteredCallback | null = null;
|
|
346
|
+
private inputPollCallback: koffi.IKoffiRegisteredCallback | null = null;
|
|
347
|
+
private inputStateCallback: koffi.IKoffiRegisteredCallback | null = null;
|
|
348
|
+
private environmentCallback: koffi.IKoffiRegisteredCallback | null = null;
|
|
349
|
+
|
|
350
|
+
// Current frame data
|
|
351
|
+
framebuffer: Uint8Array | null = null;
|
|
352
|
+
frameWidth = 0;
|
|
353
|
+
frameHeight = 0;
|
|
354
|
+
framePitch = 0;
|
|
355
|
+
|
|
356
|
+
// Audio buffer
|
|
357
|
+
audioBuffer: Int16Array = new Int16Array(4096);
|
|
358
|
+
audioSamples = 0;
|
|
359
|
+
|
|
360
|
+
// Input state
|
|
361
|
+
private buttonState = new Map<number, Map<number, boolean>>();
|
|
362
|
+
|
|
363
|
+
constructor(private envHandler: EnvironmentHandler) {}
|
|
364
|
+
|
|
365
|
+
createCallbacks(api: LibretroAPI): void {
|
|
366
|
+
// Video refresh callback
|
|
367
|
+
this.videoCallback = koffi.register(
|
|
368
|
+
(data: Buffer, width: number, height: number, pitch: number) => {
|
|
369
|
+
this.frameWidth = width;
|
|
370
|
+
this.frameHeight = height;
|
|
371
|
+
this.framePitch = pitch;
|
|
372
|
+
|
|
373
|
+
// Copy framebuffer (data may be invalidated after callback returns)
|
|
374
|
+
const size = height * pitch;
|
|
375
|
+
if (!this.framebuffer || this.framebuffer.length < size) {
|
|
376
|
+
this.framebuffer = new Uint8Array(size);
|
|
377
|
+
}
|
|
378
|
+
data.copy(this.framebuffer, 0, 0, size);
|
|
379
|
+
},
|
|
380
|
+
koffi.proto('void (*)(const void*, unsigned, unsigned, size_t)')
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
// Audio sample batch callback
|
|
384
|
+
this.audioCallback = koffi.register(
|
|
385
|
+
(data: Buffer, frames: number): number => {
|
|
386
|
+
// Copy interleaved stereo samples
|
|
387
|
+
const samples = frames * 2;
|
|
388
|
+
if (this.audioSamples + samples > this.audioBuffer.length) {
|
|
389
|
+
// Grow buffer
|
|
390
|
+
const newBuffer = new Int16Array(this.audioBuffer.length * 2);
|
|
391
|
+
newBuffer.set(this.audioBuffer);
|
|
392
|
+
this.audioBuffer = newBuffer;
|
|
393
|
+
}
|
|
394
|
+
for (let i = 0; i < samples; i++) {
|
|
395
|
+
this.audioBuffer[this.audioSamples++] = data.readInt16LE(i * 2);
|
|
396
|
+
}
|
|
397
|
+
return frames;
|
|
398
|
+
},
|
|
399
|
+
koffi.proto('size_t (*)(const int16_t*, size_t)')
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
// Input poll callback (called before input_state queries)
|
|
403
|
+
this.inputPollCallback = koffi.register(
|
|
404
|
+
() => {
|
|
405
|
+
// Nothing to do - we update state externally
|
|
406
|
+
},
|
|
407
|
+
koffi.proto('void (*)()')
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
// Input state callback
|
|
411
|
+
this.inputStateCallback = koffi.register(
|
|
412
|
+
(port: number, device: number, index: number, id: number): number => {
|
|
413
|
+
if (device !== RETRO_DEVICE.JOYPAD) return 0;
|
|
414
|
+
const portState = this.buttonState.get(port);
|
|
415
|
+
return portState?.get(id) ? 1 : 0;
|
|
416
|
+
},
|
|
417
|
+
koffi.proto('int16_t (*)(unsigned, unsigned, unsigned, unsigned)')
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
// Environment callback
|
|
421
|
+
this.environmentCallback = koffi.register(
|
|
422
|
+
(cmd: number, data: Buffer | null): boolean => {
|
|
423
|
+
return this.envHandler.handle(cmd, data);
|
|
424
|
+
},
|
|
425
|
+
koffi.proto('bool (*)(unsigned, void*)')
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
// Register callbacks with core
|
|
429
|
+
api.retro_set_environment(this.environmentCallback);
|
|
430
|
+
api.retro_set_video_refresh(this.videoCallback);
|
|
431
|
+
api.retro_set_audio_sample_batch(this.audioCallback);
|
|
432
|
+
api.retro_set_input_poll(this.inputPollCallback);
|
|
433
|
+
api.retro_set_input_state(this.inputStateCallback);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
setButtonState(port: number, button: number, pressed: boolean): void {
|
|
437
|
+
let portState = this.buttonState.get(port);
|
|
438
|
+
if (!portState) {
|
|
439
|
+
portState = new Map();
|
|
440
|
+
this.buttonState.set(port, portState);
|
|
441
|
+
}
|
|
442
|
+
portState.set(button, pressed);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
getButtonState(port: number): Map<number, boolean> {
|
|
446
|
+
return this.buttonState.get(port) ?? new Map();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Drain audio buffer and return samples */
|
|
450
|
+
drainAudio(): Float32Array {
|
|
451
|
+
const samples = new Float32Array(this.audioSamples);
|
|
452
|
+
for (let i = 0; i < this.audioSamples; i++) {
|
|
453
|
+
samples[i] = this.audioBuffer[i] / 32768;
|
|
454
|
+
}
|
|
455
|
+
this.audioSamples = 0;
|
|
456
|
+
return samples;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
destroy(): void {
|
|
460
|
+
// Callbacks are cleaned up when koffi lib is unloaded
|
|
461
|
+
this.videoCallback = null;
|
|
462
|
+
this.audioCallback = null;
|
|
463
|
+
this.inputPollCallback = null;
|
|
464
|
+
this.inputStateCallback = null;
|
|
465
|
+
this.environmentCallback = null;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
---
|
|
471
|
+
|
|
472
|
+
## LibretroCore Implementation
|
|
473
|
+
|
|
474
|
+
### Core Class
|
|
475
|
+
|
|
476
|
+
```typescript
|
|
477
|
+
// src/cores/libretro/index.ts
|
|
478
|
+
|
|
479
|
+
import { readFileSync } from 'fs';
|
|
480
|
+
import { basename, extname } from 'path';
|
|
481
|
+
import { Core, SystemInfo, AudioConfig, CoreState, ButtonDefinition } from '../../core/core.js';
|
|
482
|
+
import { LibretroAPI } from './api.js';
|
|
483
|
+
import { EnvironmentHandler, RETRO_PIXEL_FORMAT } from './environment.js';
|
|
484
|
+
import { CallbackManager, RETRO_DEVICE_ID_JOYPAD } from './callbacks.js';
|
|
485
|
+
import { registerCore } from '../../frontend/core-registry.js';
|
|
486
|
+
|
|
487
|
+
export class LibretroCore implements Core {
|
|
488
|
+
private api: LibretroAPI;
|
|
489
|
+
private envHandler: EnvironmentHandler;
|
|
490
|
+
private callbacks: CallbackManager;
|
|
491
|
+
private systemInfo: SystemInfo;
|
|
492
|
+
private romPath = '';
|
|
493
|
+
private romData: Buffer | null = null;
|
|
494
|
+
|
|
495
|
+
constructor(corePath: string) {
|
|
496
|
+
this.envHandler = new EnvironmentHandler();
|
|
497
|
+
this.api = new LibretroAPI(corePath);
|
|
498
|
+
this.callbacks = new CallbackManager(this.envHandler);
|
|
499
|
+
|
|
500
|
+
// Set environment callback before init (required by some cores)
|
|
501
|
+
this.callbacks.createCallbacks(this.api);
|
|
502
|
+
|
|
503
|
+
// Initialize core
|
|
504
|
+
this.api.retro_init();
|
|
505
|
+
|
|
506
|
+
// Get system info
|
|
507
|
+
this.systemInfo = this.buildSystemInfo();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
private buildSystemInfo(): SystemInfo {
|
|
511
|
+
const info = {} as any;
|
|
512
|
+
this.api.retro_get_system_info(info);
|
|
513
|
+
|
|
514
|
+
const avInfo = { geometry: {}, timing: {} } as any;
|
|
515
|
+
// Note: av_info not available until game is loaded for some cores
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
id: `libretro-${info.library_name.toLowerCase().replace(/\s+/g, '-')}`,
|
|
519
|
+
name: `${info.library_name} (libretro)`,
|
|
520
|
+
extensions: info.valid_extensions.split('|').map((e: string) => `.${e}`),
|
|
521
|
+
width: 320, // Updated after ROM load
|
|
522
|
+
height: 240, // Updated after ROM load
|
|
523
|
+
fps: 60, // Updated after ROM load
|
|
524
|
+
sampleRate: 44100,
|
|
525
|
+
pixelAspectRatio: 1,
|
|
526
|
+
maxPlayers: 2,
|
|
527
|
+
buttons: this.getDefaultButtons(),
|
|
528
|
+
colorSpace: 'rgb24', // We convert internally
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private getDefaultButtons(): ButtonDefinition[] {
|
|
533
|
+
// Standard libretro joypad layout
|
|
534
|
+
return [
|
|
535
|
+
{ id: RETRO_DEVICE_ID_JOYPAD.A, name: 'A', defaultKey: 'k', defaultGamepad: 'A' },
|
|
536
|
+
{ id: RETRO_DEVICE_ID_JOYPAD.B, name: 'B', defaultKey: 'j', defaultGamepad: 'B' },
|
|
537
|
+
{ id: RETRO_DEVICE_ID_JOYPAD.X, name: 'X', defaultKey: 'i', defaultGamepad: 'X' },
|
|
538
|
+
{ id: RETRO_DEVICE_ID_JOYPAD.Y, name: 'Y', defaultKey: 'u', defaultGamepad: 'Y' },
|
|
539
|
+
{ id: RETRO_DEVICE_ID_JOYPAD.L, name: 'L', defaultKey: 'q', defaultGamepad: 'LB' },
|
|
540
|
+
{ id: RETRO_DEVICE_ID_JOYPAD.R, name: 'R', defaultKey: 'e', defaultGamepad: 'RB' },
|
|
541
|
+
{ id: RETRO_DEVICE_ID_JOYPAD.SELECT, name: 'Select', defaultKey: ' ', defaultGamepad: 'Back' },
|
|
542
|
+
{ id: RETRO_DEVICE_ID_JOYPAD.START, name: 'Start', defaultKey: 'Enter', defaultGamepad: 'Start' },
|
|
543
|
+
{ id: RETRO_DEVICE_ID_JOYPAD.UP, name: 'Up', defaultKey: 'w', defaultGamepad: 'DPadUp' },
|
|
544
|
+
{ id: RETRO_DEVICE_ID_JOYPAD.DOWN, name: 'Down', defaultKey: 's', defaultGamepad: 'DPadDown' },
|
|
545
|
+
{ id: RETRO_DEVICE_ID_JOYPAD.LEFT, name: 'Left', defaultKey: 'a', defaultGamepad: 'DPadLeft' },
|
|
546
|
+
{ id: RETRO_DEVICE_ID_JOYPAD.RIGHT, name: 'Right', defaultKey: 'd', defaultGamepad: 'DPadRight' },
|
|
547
|
+
];
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
getSystemInfo(): SystemInfo {
|
|
551
|
+
return this.systemInfo;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
loadRom(romPath: string): void {
|
|
555
|
+
this.romPath = romPath;
|
|
556
|
+
this.romData = readFileSync(romPath);
|
|
557
|
+
|
|
558
|
+
const gameInfo = {
|
|
559
|
+
path: romPath,
|
|
560
|
+
data: this.romData,
|
|
561
|
+
size: this.romData.length,
|
|
562
|
+
meta: null,
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const success = this.api.retro_load_game(gameInfo);
|
|
566
|
+
if (!success) {
|
|
567
|
+
throw new Error(`Failed to load ROM: ${romPath}`);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Update system info with actual values
|
|
571
|
+
const avInfo = { geometry: {}, timing: {} } as any;
|
|
572
|
+
this.api.retro_get_system_av_info(avInfo);
|
|
573
|
+
|
|
574
|
+
this.systemInfo.width = avInfo.geometry.base_width;
|
|
575
|
+
this.systemInfo.height = avInfo.geometry.base_height;
|
|
576
|
+
this.systemInfo.fps = avInfo.timing.fps;
|
|
577
|
+
this.systemInfo.sampleRate = avInfo.timing.sample_rate;
|
|
578
|
+
this.systemInfo.pixelAspectRatio = avInfo.geometry.aspect_ratio || 1;
|
|
579
|
+
|
|
580
|
+
// Set up controller ports
|
|
581
|
+
this.api.retro_set_controller_port_device(0, RETRO_DEVICE.JOYPAD);
|
|
582
|
+
this.api.retro_set_controller_port_device(1, RETRO_DEVICE.JOYPAD);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
reset(): void {
|
|
586
|
+
this.api.retro_reset();
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
destroy(): void {
|
|
590
|
+
this.api.retro_unload_game();
|
|
591
|
+
this.api.retro_deinit();
|
|
592
|
+
this.callbacks.destroy();
|
|
593
|
+
this.api.destroy();
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
runFrame(): void {
|
|
597
|
+
this.api.retro_run();
|
|
598
|
+
|
|
599
|
+
// Push audio samples if callback is set
|
|
600
|
+
if (this.audioCallback && this.callbacks.audioSamples > 0) {
|
|
601
|
+
const samples = this.callbacks.drainAudio();
|
|
602
|
+
this.audioCallback(samples);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
isFrameComplete(): boolean {
|
|
607
|
+
return true; // libretro cores always complete one frame per run()
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
getFramebuffer(): Uint8Array {
|
|
611
|
+
const fb = this.callbacks.framebuffer;
|
|
612
|
+
if (!fb) return new Uint8Array(0);
|
|
613
|
+
|
|
614
|
+
// Convert to RGB24 based on pixel format
|
|
615
|
+
return this.convertFramebuffer(
|
|
616
|
+
fb,
|
|
617
|
+
this.callbacks.frameWidth,
|
|
618
|
+
this.callbacks.frameHeight,
|
|
619
|
+
this.callbacks.framePitch
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
private convertFramebuffer(
|
|
624
|
+
data: Uint8Array,
|
|
625
|
+
width: number,
|
|
626
|
+
height: number,
|
|
627
|
+
pitch: number
|
|
628
|
+
): Uint8Array {
|
|
629
|
+
const format = this.envHandler.getPixelFormat();
|
|
630
|
+
const output = new Uint8Array(width * height * 3);
|
|
631
|
+
let outIdx = 0;
|
|
632
|
+
|
|
633
|
+
for (let y = 0; y < height; y++) {
|
|
634
|
+
const rowOffset = y * pitch;
|
|
635
|
+
|
|
636
|
+
for (let x = 0; x < width; x++) {
|
|
637
|
+
let r: number, g: number, b: number;
|
|
638
|
+
|
|
639
|
+
switch (format) {
|
|
640
|
+
case RETRO_PIXEL_FORMAT.XRGB8888: {
|
|
641
|
+
const idx = rowOffset + x * 4;
|
|
642
|
+
b = data[idx];
|
|
643
|
+
g = data[idx + 1];
|
|
644
|
+
r = data[idx + 2];
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
case RETRO_PIXEL_FORMAT.RGB565: {
|
|
648
|
+
const idx = rowOffset + x * 2;
|
|
649
|
+
const pixel = data[idx] | (data[idx + 1] << 8);
|
|
650
|
+
r = ((pixel >> 11) & 0x1f) << 3;
|
|
651
|
+
g = ((pixel >> 5) & 0x3f) << 2;
|
|
652
|
+
b = (pixel & 0x1f) << 3;
|
|
653
|
+
break;
|
|
654
|
+
}
|
|
655
|
+
case RETRO_PIXEL_FORMAT.XRGB1555:
|
|
656
|
+
default: {
|
|
657
|
+
const idx = rowOffset + x * 2;
|
|
658
|
+
const pixel = data[idx] | (data[idx + 1] << 8);
|
|
659
|
+
r = ((pixel >> 10) & 0x1f) << 3;
|
|
660
|
+
g = ((pixel >> 5) & 0x1f) << 3;
|
|
661
|
+
b = (pixel & 0x1f) << 3;
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
output[outIdx++] = r;
|
|
667
|
+
output[outIdx++] = g;
|
|
668
|
+
output[outIdx++] = b;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return output;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Audio
|
|
676
|
+
private audioCallback: ((samples: Float32Array) => void) | null = null;
|
|
677
|
+
|
|
678
|
+
getAudioConfig(): AudioConfig {
|
|
679
|
+
return {
|
|
680
|
+
sampleRate: this.systemInfo.sampleRate,
|
|
681
|
+
channels: 2, // libretro is always stereo
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
setAudioCallback(callback: ((samples: Float32Array) => void) | null): void {
|
|
686
|
+
this.audioCallback = callback;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Input
|
|
690
|
+
setButtonState(port: number, button: number, pressed: boolean): void {
|
|
691
|
+
this.callbacks.setButtonState(port, button, pressed);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
getButtonState(port: number): Map<number, boolean> {
|
|
695
|
+
return this.callbacks.getButtonState(port);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// State management
|
|
699
|
+
getState(): CoreState {
|
|
700
|
+
const size = this.api.retro_serialize_size();
|
|
701
|
+
const buffer = Buffer.alloc(size);
|
|
702
|
+
const success = this.api.retro_serialize(buffer, size);
|
|
703
|
+
|
|
704
|
+
return {
|
|
705
|
+
version: 1,
|
|
706
|
+
coreId: this.systemInfo.id,
|
|
707
|
+
gameId: this.romPath,
|
|
708
|
+
data: {
|
|
709
|
+
state: success ? buffer.toString('base64') : null,
|
|
710
|
+
},
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
setState(state: CoreState): void {
|
|
715
|
+
if (state.coreId !== this.systemInfo.id) {
|
|
716
|
+
throw new Error(`State core mismatch: expected ${this.systemInfo.id}, got ${state.coreId}`);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const stateData = state.data.state as string | null;
|
|
720
|
+
if (!stateData) return;
|
|
721
|
+
|
|
722
|
+
const buffer = Buffer.from(stateData, 'base64');
|
|
723
|
+
const success = this.api.retro_unserialize(buffer, buffer.length);
|
|
724
|
+
|
|
725
|
+
if (!success) {
|
|
726
|
+
throw new Error('Failed to load state');
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
getStateVersion(): number {
|
|
731
|
+
return 1;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Battery save
|
|
735
|
+
hasBatterySave(): boolean {
|
|
736
|
+
return this.api.retro_get_memory_size(RETRO_MEMORY.SAVE_RAM) > 0;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
getBatteryRam(): Uint8Array | null {
|
|
740
|
+
const size = this.api.retro_get_memory_size(RETRO_MEMORY.SAVE_RAM);
|
|
741
|
+
if (size === 0) return null;
|
|
742
|
+
|
|
743
|
+
const data = this.api.retro_get_memory_data(RETRO_MEMORY.SAVE_RAM);
|
|
744
|
+
if (!data) return null;
|
|
745
|
+
|
|
746
|
+
return new Uint8Array(data);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
setBatteryRam(data: Uint8Array): void {
|
|
750
|
+
const ptr = this.api.retro_get_memory_data(RETRO_MEMORY.SAVE_RAM);
|
|
751
|
+
if (!ptr) return;
|
|
752
|
+
|
|
753
|
+
const size = this.api.retro_get_memory_size(RETRO_MEMORY.SAVE_RAM);
|
|
754
|
+
const copySize = Math.min(data.length, size);
|
|
755
|
+
data.copy(ptr, 0, 0, copySize);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Memory constants
|
|
760
|
+
const RETRO_MEMORY = {
|
|
761
|
+
SAVE_RAM: 0,
|
|
762
|
+
RTC: 1,
|
|
763
|
+
SYSTEM_RAM: 2,
|
|
764
|
+
VIDEO_RAM: 3,
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
const RETRO_DEVICE = {
|
|
768
|
+
NONE: 0,
|
|
769
|
+
JOYPAD: 1,
|
|
770
|
+
MOUSE: 2,
|
|
771
|
+
KEYBOARD: 3,
|
|
772
|
+
LIGHTGUN: 4,
|
|
773
|
+
ANALOG: 5,
|
|
774
|
+
};
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
---
|
|
778
|
+
|
|
779
|
+
## Core Discovery and Registration
|
|
780
|
+
|
|
781
|
+
### Dynamic Core Loading
|
|
782
|
+
|
|
783
|
+
```typescript
|
|
784
|
+
// src/cores/libretro/loader.ts
|
|
785
|
+
|
|
786
|
+
import { existsSync, readdirSync } from 'fs';
|
|
787
|
+
import { join, basename, extname } from 'path';
|
|
788
|
+
import { platform } from 'os';
|
|
789
|
+
import { LibretroCore } from './index.js';
|
|
790
|
+
import { registerCore } from '../../frontend/core-registry.js';
|
|
791
|
+
|
|
792
|
+
/** Platform-specific library extension */
|
|
793
|
+
function getLibraryExtension(): string {
|
|
794
|
+
switch (platform()) {
|
|
795
|
+
case 'darwin': return '.dylib';
|
|
796
|
+
case 'win32': return '.dll';
|
|
797
|
+
default: return '.so';
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** Scan directory for libretro cores and register them */
|
|
802
|
+
export function loadLibretroCores(coreDirectory: string): void {
|
|
803
|
+
if (!existsSync(coreDirectory)) return;
|
|
804
|
+
|
|
805
|
+
const ext = getLibraryExtension();
|
|
806
|
+
const files = readdirSync(coreDirectory).filter(f => f.endsWith(`_libretro${ext}`));
|
|
807
|
+
|
|
808
|
+
for (const file of files) {
|
|
809
|
+
const corePath = join(coreDirectory, file);
|
|
810
|
+
const coreName = basename(file, `_libretro${ext}`);
|
|
811
|
+
|
|
812
|
+
try {
|
|
813
|
+
// Create a temporary instance to get system info
|
|
814
|
+
const tempCore = new LibretroCore(corePath);
|
|
815
|
+
const info = tempCore.getSystemInfo();
|
|
816
|
+
tempCore.destroy();
|
|
817
|
+
|
|
818
|
+
// Register core factory
|
|
819
|
+
registerCore(info.id, {
|
|
820
|
+
create: () => new LibretroCore(corePath),
|
|
821
|
+
extensions: info.extensions,
|
|
822
|
+
name: info.name,
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
console.log(`Loaded libretro core: ${info.name}`);
|
|
826
|
+
} catch (error) {
|
|
827
|
+
console.warn(`Failed to load libretro core ${file}:`, error);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
### CLI Integration
|
|
834
|
+
|
|
835
|
+
```typescript
|
|
836
|
+
// In src/index.ts
|
|
837
|
+
|
|
838
|
+
import { loadLibretroCores } from './cores/libretro/loader.js';
|
|
839
|
+
|
|
840
|
+
// Load libretro cores from default locations
|
|
841
|
+
const coreSearchPaths = [
|
|
842
|
+
'./cores', // Local cores directory
|
|
843
|
+
join(homedir(), '.config/emoemu/cores'), // User config
|
|
844
|
+
'/usr/lib/libretro', // System (Linux)
|
|
845
|
+
'/usr/local/lib/libretro', // Homebrew (macOS)
|
|
846
|
+
];
|
|
847
|
+
|
|
848
|
+
for (const path of coreSearchPaths) {
|
|
849
|
+
loadLibretroCores(path);
|
|
850
|
+
}
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
---
|
|
854
|
+
|
|
855
|
+
## Considerations and Challenges
|
|
856
|
+
|
|
857
|
+
### Memory Management
|
|
858
|
+
|
|
859
|
+
1. **Framebuffer Copying**: Native framebuffer must be copied in the callback since it may be invalidated after the callback returns.
|
|
860
|
+
|
|
861
|
+
2. **String Handling**: Environment callbacks that return strings (paths) require careful native memory allocation.
|
|
862
|
+
|
|
863
|
+
3. **Buffer Lifetimes**: ROM data must remain valid for the lifetime of the core.
|
|
864
|
+
|
|
865
|
+
### Thread Safety
|
|
866
|
+
|
|
867
|
+
1. **Single-Threaded**: Libretro cores expect single-threaded operation. Do not call core functions from multiple threads.
|
|
868
|
+
|
|
869
|
+
2. **Callback Context**: Callbacks execute in the same thread as `retro_run()`.
|
|
870
|
+
|
|
871
|
+
### Core Compatibility
|
|
872
|
+
|
|
873
|
+
Not all libretro cores will work:
|
|
874
|
+
|
|
875
|
+
| Category | Compatibility |
|
|
876
|
+
|----------|--------------|
|
|
877
|
+
| Software-rendered cores | Full support |
|
|
878
|
+
| OpenGL cores | Not supported (no GL context) |
|
|
879
|
+
| Vulkan cores | Not supported |
|
|
880
|
+
| Hardware-accelerated cores | Not supported |
|
|
881
|
+
| Cores requiring BIOS files | Requires user to provide BIOS |
|
|
882
|
+
|
|
883
|
+
### Performance Considerations
|
|
884
|
+
|
|
885
|
+
1. **FFI Overhead**: Each callback incurs FFI crossing overhead. The video callback is called once per frame, audio potentially multiple times.
|
|
886
|
+
|
|
887
|
+
2. **Buffer Conversion**: Pixel format conversion adds CPU overhead. Consider caching converted buffers.
|
|
888
|
+
|
|
889
|
+
3. **Memory Copies**: Minimize copies between native and JS buffers where possible.
|
|
890
|
+
|
|
891
|
+
### Supported Cores (Initial Target)
|
|
892
|
+
|
|
893
|
+
| Core | System | Status |
|
|
894
|
+
|------|--------|--------|
|
|
895
|
+
| picodrive | Sega Genesis/MD, SMS, Game Gear, 32X, Sega CD | Target |
|
|
896
|
+
| gambatte | Game Boy / Game Boy Color | Target (alternative to native) |
|
|
897
|
+
| mgba | Game Boy Advance | Target |
|
|
898
|
+
| snes9x | SNES | Target (alternative to native) |
|
|
899
|
+
| nestopia | NES | Target (alternative to native) |
|
|
900
|
+
| fceumm | NES | Target (alternative to native) |
|
|
901
|
+
|
|
902
|
+
---
|
|
903
|
+
|
|
904
|
+
## Dependencies
|
|
905
|
+
|
|
906
|
+
### Required Packages
|
|
907
|
+
|
|
908
|
+
```json
|
|
909
|
+
{
|
|
910
|
+
"dependencies": {
|
|
911
|
+
"koffi": "^2.8.0"
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
### Alternative FFI Libraries
|
|
917
|
+
|
|
918
|
+
| Library | Pros | Cons |
|
|
919
|
+
|---------|------|------|
|
|
920
|
+
| koffi | Fast, modern, good callback support | Newer, less battle-tested |
|
|
921
|
+
| node-ffi-napi | Well-established, widely used | Slower, complex callback setup |
|
|
922
|
+
| sbffi | Very fast | Limited features |
|
|
923
|
+
|
|
924
|
+
**Recommendation**: Use `koffi` for its superior callback support and performance.
|
|
925
|
+
|
|
926
|
+
---
|
|
927
|
+
|
|
928
|
+
## Testing Strategy
|
|
929
|
+
|
|
930
|
+
### Unit Tests
|
|
931
|
+
|
|
932
|
+
1. **API Binding Tests**: Verify function signatures match libretro header
|
|
933
|
+
2. **Environment Handler Tests**: Test command parsing and responses
|
|
934
|
+
3. **Pixel Format Conversion Tests**: Verify correct color conversion
|
|
935
|
+
|
|
936
|
+
### Integration Tests
|
|
937
|
+
|
|
938
|
+
1. **Core Loading**: Test loading various core types
|
|
939
|
+
2. **ROM Loading**: Test ROM load/unload cycle
|
|
940
|
+
3. **Frame Execution**: Verify frame output and timing
|
|
941
|
+
4. **State Save/Load**: Test serialization round-trip
|
|
942
|
+
5. **Input Mapping**: Verify button state propagation
|
|
943
|
+
|
|
944
|
+
### Manual Testing
|
|
945
|
+
|
|
946
|
+
1. Test with reference cores (picodrive, gambatte)
|
|
947
|
+
2. Verify audio/video sync
|
|
948
|
+
3. Test save states across core restarts
|
|
949
|
+
4. Verify battery save persistence
|
|
950
|
+
|
|
951
|
+
---
|
|
952
|
+
|
|
953
|
+
## Future Enhancements
|
|
954
|
+
|
|
955
|
+
1. **Core Options UI**: Expose core-specific options (e.g., region, video filters)
|
|
956
|
+
2. **Subsystem Support**: Support cores with multiple content types (e.g., BIOS + ROM)
|
|
957
|
+
3. **Cheats**: Implement cheat code interface
|
|
958
|
+
4. **Rewind**: Leverage libretro's serialization for rewind feature
|
|
959
|
+
5. **Run-Ahead**: Reduce input latency using state save/load
|
|
960
|
+
6. **Disk Control**: Support multi-disc games (PlayStation)
|
|
961
|
+
|
|
962
|
+
---
|
|
963
|
+
|
|
964
|
+
## Appendix: Libretro API Reference
|
|
965
|
+
|
|
966
|
+
### Essential Functions
|
|
967
|
+
|
|
968
|
+
| Function | Description |
|
|
969
|
+
|----------|-------------|
|
|
970
|
+
| `retro_init()` | Initialize core (call once) |
|
|
971
|
+
| `retro_deinit()` | Cleanup core (call once) |
|
|
972
|
+
| `retro_load_game()` | Load ROM/content |
|
|
973
|
+
| `retro_unload_game()` | Unload ROM/content |
|
|
974
|
+
| `retro_run()` | Execute one frame |
|
|
975
|
+
| `retro_reset()` | Reset to power-on state |
|
|
976
|
+
| `retro_serialize()` | Save state to buffer |
|
|
977
|
+
| `retro_unserialize()` | Load state from buffer |
|
|
978
|
+
|
|
979
|
+
### Callback Registration Order
|
|
980
|
+
|
|
981
|
+
```
|
|
982
|
+
1. retro_set_environment() # Before retro_init() for some cores
|
|
983
|
+
2. retro_init()
|
|
984
|
+
3. retro_set_video_refresh()
|
|
985
|
+
4. retro_set_audio_sample_batch()
|
|
986
|
+
5. retro_set_input_poll()
|
|
987
|
+
6. retro_set_input_state()
|
|
988
|
+
7. retro_load_game()
|
|
989
|
+
8. retro_get_system_av_info() # After load_game()
|
|
990
|
+
9. Main loop: retro_run()
|
|
991
|
+
```
|
|
992
|
+
|
|
993
|
+
### Resources
|
|
994
|
+
|
|
995
|
+
- [Libretro Documentation](https://docs.libretro.com/)
|
|
996
|
+
- [libretro.h Header](https://github.com/libretro/libretro-common/blob/master/include/libretro.h)
|
|
997
|
+
- [RetroArch Core Downloads](https://buildbot.libretro.com/nightly/)
|