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,286 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { FrameBuffer, createFrameBuffer } from '.';
|
|
3
|
+
import { crc32 } from '../crc32';
|
|
4
|
+
import { DEFAULT_FRAME_BUFFER_SIZE, MAX_INPUT_DEVICES } from '..';
|
|
5
|
+
|
|
6
|
+
describe('FrameBuffer', () => {
|
|
7
|
+
let buffer: FrameBuffer;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
buffer = createFrameBuffer(10); // Small buffer for testing
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('initialization', () => {
|
|
14
|
+
it('should create buffer with specified capacity', () => {
|
|
15
|
+
expect(buffer.capacity).toBe(10);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should start empty', () => {
|
|
19
|
+
expect(buffer.size).toBe(0);
|
|
20
|
+
expect(buffer.newestFrame).toBe(-1);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should use default capacity if not specified', () => {
|
|
24
|
+
const defaultBuffer = createFrameBuffer();
|
|
25
|
+
expect(defaultBuffer.capacity).toBe(DEFAULT_FRAME_BUFFER_SIZE);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('advance', () => {
|
|
30
|
+
it('should advance to first frame', () => {
|
|
31
|
+
const frame = buffer.advance();
|
|
32
|
+
expect(frame.frameNumber).toBe(0);
|
|
33
|
+
expect(buffer.newestFrame).toBe(0);
|
|
34
|
+
expect(buffer.size).toBe(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should advance multiple frames', () => {
|
|
38
|
+
for (let i = 0; i < 5; i++) {
|
|
39
|
+
buffer.advance();
|
|
40
|
+
}
|
|
41
|
+
expect(buffer.newestFrame).toBe(4);
|
|
42
|
+
expect(buffer.oldestFrame).toBe(0);
|
|
43
|
+
expect(buffer.size).toBe(5);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should wrap around when exceeding capacity', () => {
|
|
47
|
+
for (let i = 0; i < 15; i++) {
|
|
48
|
+
buffer.advance();
|
|
49
|
+
}
|
|
50
|
+
expect(buffer.newestFrame).toBe(14);
|
|
51
|
+
expect(buffer.oldestFrame).toBe(5);
|
|
52
|
+
expect(buffer.size).toBe(10);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should reset frame state on reuse', () => {
|
|
56
|
+
const frame1 = buffer.advance();
|
|
57
|
+
frame1.serializedState = Buffer.from([1, 2, 3]);
|
|
58
|
+
frame1.crc = 12345;
|
|
59
|
+
|
|
60
|
+
// Advance past capacity to reuse slot
|
|
61
|
+
for (let i = 0; i < 10; i++) {
|
|
62
|
+
buffer.advance();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Frame 10 should be in slot 0, which was frame 0
|
|
66
|
+
const frame10 = buffer.get(10);
|
|
67
|
+
expect(frame10).not.toBeNull();
|
|
68
|
+
expect(frame10!.serializedState).toBeNull();
|
|
69
|
+
expect(frame10!.crc).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('get', () => {
|
|
74
|
+
it('should return null for frame not in buffer', () => {
|
|
75
|
+
expect(buffer.get(0)).toBeNull();
|
|
76
|
+
expect(buffer.get(100)).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should return frame by number', () => {
|
|
80
|
+
buffer.advance();
|
|
81
|
+
buffer.advance();
|
|
82
|
+
const frame = buffer.get(1);
|
|
83
|
+
expect(frame).not.toBeNull();
|
|
84
|
+
expect(frame!.frameNumber).toBe(1);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should return null for expired frame', () => {
|
|
88
|
+
for (let i = 0; i < 15; i++) {
|
|
89
|
+
buffer.advance();
|
|
90
|
+
}
|
|
91
|
+
expect(buffer.get(0)).toBeNull();
|
|
92
|
+
expect(buffer.get(4)).toBeNull();
|
|
93
|
+
expect(buffer.get(5)).not.toBeNull();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('getCurrent', () => {
|
|
98
|
+
it('should return null when empty', () => {
|
|
99
|
+
expect(buffer.getCurrent()).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should return newest frame', () => {
|
|
103
|
+
buffer.advance();
|
|
104
|
+
buffer.advance();
|
|
105
|
+
buffer.advance();
|
|
106
|
+
const current = buffer.getCurrent();
|
|
107
|
+
expect(current).not.toBeNull();
|
|
108
|
+
expect(current!.frameNumber).toBe(2);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('hasFrame', () => {
|
|
113
|
+
it('should return false for empty buffer', () => {
|
|
114
|
+
expect(buffer.hasFrame(0)).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should return true for frames in range', () => {
|
|
118
|
+
for (let i = 0; i < 5; i++) {
|
|
119
|
+
buffer.advance();
|
|
120
|
+
}
|
|
121
|
+
expect(buffer.hasFrame(0)).toBe(true);
|
|
122
|
+
expect(buffer.hasFrame(4)).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should return false for frames outside range', () => {
|
|
126
|
+
for (let i = 0; i < 5; i++) {
|
|
127
|
+
buffer.advance();
|
|
128
|
+
}
|
|
129
|
+
expect(buffer.hasFrame(5)).toBe(false);
|
|
130
|
+
expect(buffer.hasFrame(-1)).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('state management', () => {
|
|
135
|
+
it('should set and get state', () => {
|
|
136
|
+
buffer.advance();
|
|
137
|
+
const state = Buffer.from([1, 2, 3, 4, 5]);
|
|
138
|
+
expect(buffer.setState(0, state)).toBe(true);
|
|
139
|
+
expect(buffer.getState(0)).toEqual(state);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should compute CRC when setting state', () => {
|
|
143
|
+
buffer.advance();
|
|
144
|
+
const state = Buffer.from('test state');
|
|
145
|
+
buffer.setState(0, state);
|
|
146
|
+
const crc = buffer.getCrc(0);
|
|
147
|
+
expect(crc).not.toBeNull();
|
|
148
|
+
expect(crc).toBe(crc32(state));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should return false when setting state for invalid frame', () => {
|
|
152
|
+
expect(buffer.setState(0, Buffer.alloc(0))).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('local input', () => {
|
|
157
|
+
it('should set local input for current frame', () => {
|
|
158
|
+
buffer.advance();
|
|
159
|
+
const input = [0xff, 0x1234, 0x5678];
|
|
160
|
+
expect(buffer.setLocalInput(input)).toBe(true);
|
|
161
|
+
|
|
162
|
+
const frame = buffer.getCurrent();
|
|
163
|
+
expect(frame!.localInput[0]).toBe(0xff);
|
|
164
|
+
expect(frame!.localInput[1]).toBe(0x1234);
|
|
165
|
+
expect(frame!.localInput[2]).toBe(0x5678);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should set input for specific device', () => {
|
|
169
|
+
buffer.advance();
|
|
170
|
+
expect(buffer.setLocalInputForDevice(1, 0xabcd, 0x1111, 0x2222)).toBe(true);
|
|
171
|
+
|
|
172
|
+
const frame = buffer.getCurrent();
|
|
173
|
+
// Device 1 starts at index 3 (device 0 uses indices 0-2)
|
|
174
|
+
expect(frame!.localInput[3]).toBe(0xabcd);
|
|
175
|
+
expect(frame!.localInput[4]).toBe(0x1111);
|
|
176
|
+
expect(frame!.localInput[5]).toBe(0x2222);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should reject device index out of range', () => {
|
|
180
|
+
buffer.advance();
|
|
181
|
+
expect(buffer.setLocalInputForDevice(MAX_INPUT_DEVICES, 0xff)).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('remote input', () => {
|
|
186
|
+
it('should set and get remote input', () => {
|
|
187
|
+
buffer.advance();
|
|
188
|
+
const input = [0xaa, 0xbb, 0xcc];
|
|
189
|
+
buffer.setRemoteInput(0, 1, input, true);
|
|
190
|
+
|
|
191
|
+
expect(buffer.getRemoteInput(0, 1)).toEqual(input);
|
|
192
|
+
expect(buffer.isRemoteInputReal(0, 1)).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should track simulated vs real input', () => {
|
|
196
|
+
buffer.advance();
|
|
197
|
+
buffer.setRemoteInput(0, 1, [1, 2, 3], false);
|
|
198
|
+
expect(buffer.isRemoteInputReal(0, 1)).toBe(false);
|
|
199
|
+
|
|
200
|
+
buffer.setRemoteInput(0, 1, [4, 5, 6], true);
|
|
201
|
+
expect(buffer.isRemoteInputReal(0, 1)).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should return null for unknown client', () => {
|
|
205
|
+
buffer.advance();
|
|
206
|
+
expect(buffer.getRemoteInput(0, 99)).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('sync frame finding', () => {
|
|
211
|
+
it('should find sync frame with all real input', () => {
|
|
212
|
+
for (let i = 0; i < 5; i++) {
|
|
213
|
+
buffer.advance();
|
|
214
|
+
buffer.setRemoteInput(i, 1, [i], true);
|
|
215
|
+
buffer.setRemoteInput(i, 2, [i], true);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const syncFrame = buffer.findSyncFrame([1, 2]);
|
|
219
|
+
expect(syncFrame).toBe(4);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should find last frame before simulated input', () => {
|
|
223
|
+
for (let i = 0; i < 5; i++) {
|
|
224
|
+
buffer.advance();
|
|
225
|
+
buffer.setRemoteInput(i, 1, [i], true);
|
|
226
|
+
buffer.setRemoteInput(i, 2, [i], i < 3); // Only real for frames 0-2
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const syncFrame = buffer.findSyncFrame([1, 2]);
|
|
230
|
+
expect(syncFrame).toBe(2);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should return null if no synced frames', () => {
|
|
234
|
+
for (let i = 0; i < 3; i++) {
|
|
235
|
+
buffer.advance();
|
|
236
|
+
buffer.setRemoteInput(i, 1, [i], false);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const syncFrame = buffer.findSyncFrame([1]);
|
|
240
|
+
expect(syncFrame).toBeNull();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe('unread frame finding', () => {
|
|
245
|
+
it('should find first frame with simulated input', () => {
|
|
246
|
+
for (let i = 0; i < 5; i++) {
|
|
247
|
+
buffer.advance();
|
|
248
|
+
buffer.setRemoteInput(i, 1, [i], i < 3);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const unreadFrame = buffer.findUnreadFrame([1]);
|
|
252
|
+
expect(unreadFrame).toBe(3);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('should return null if all frames have real input', () => {
|
|
256
|
+
for (let i = 0; i < 5; i++) {
|
|
257
|
+
buffer.advance();
|
|
258
|
+
buffer.setRemoteInput(i, 1, [i], true);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const unreadFrame = buffer.findUnreadFrame([1]);
|
|
262
|
+
expect(unreadFrame).toBeNull();
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('initializeAt', () => {
|
|
267
|
+
it('should initialize buffer at specific frame', () => {
|
|
268
|
+
const frame = buffer.initializeAt(1000);
|
|
269
|
+
expect(frame.frameNumber).toBe(1000);
|
|
270
|
+
expect(buffer.newestFrame).toBe(1000);
|
|
271
|
+
expect(buffer.oldestFrame).toBe(1000);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe('clear', () => {
|
|
276
|
+
it('should reset buffer to empty state', () => {
|
|
277
|
+
for (let i = 0; i < 5; i++) {
|
|
278
|
+
buffer.advance();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
buffer.clear();
|
|
282
|
+
expect(buffer.size).toBe(0);
|
|
283
|
+
expect(buffer.newestFrame).toBe(-1);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
});
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input Buffer for Netplay
|
|
3
|
+
*
|
|
4
|
+
* Manages input state per client, including:
|
|
5
|
+
* - Input delay queue for local input
|
|
6
|
+
* - Input prediction for remote clients
|
|
7
|
+
* - Frame-accurate input tracking
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { times } from 'remeda';
|
|
11
|
+
import { MAX_INPUT_DEVICES, MAX_INPUT_DELAY_FRAMES } from '..';
|
|
12
|
+
|
|
13
|
+
export * from './consts';
|
|
14
|
+
import { INPUTS_PER_DEVICE, INPUT_JOYPAD, INPUT_ANALOG_LEFT, INPUT_ANALOG_RIGHT } from './consts';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a zero-filled number array of the given size.
|
|
18
|
+
*/
|
|
19
|
+
const createZeroArray = (size: number): number[] => times(size, () => 0);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Per-client input state tracking.
|
|
23
|
+
*/
|
|
24
|
+
interface ClientInputState {
|
|
25
|
+
/** Client ID */
|
|
26
|
+
clientId: number;
|
|
27
|
+
|
|
28
|
+
/** Last known input values per device */
|
|
29
|
+
lastInput: number[];
|
|
30
|
+
|
|
31
|
+
/** Last frame we received real input for */
|
|
32
|
+
lastRealInputFrame: number;
|
|
33
|
+
|
|
34
|
+
/** Is this client local? */
|
|
35
|
+
isLocal: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Delayed input entry for local input delay queue.
|
|
40
|
+
*/
|
|
41
|
+
interface DelayedInput {
|
|
42
|
+
/** Frame number this input is for */
|
|
43
|
+
frameNumber: number;
|
|
44
|
+
|
|
45
|
+
/** Input values */
|
|
46
|
+
input: number[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* InputBuffer manages input state for all clients in a netplay session.
|
|
51
|
+
*/
|
|
52
|
+
export class InputBuffer {
|
|
53
|
+
private readonly clients: Map<number, ClientInputState> = new Map();
|
|
54
|
+
private readonly delayQueue: DelayedInput[] = [];
|
|
55
|
+
private _inputDelayFrames: number = 0;
|
|
56
|
+
private _localClientId: number = 0;
|
|
57
|
+
|
|
58
|
+
/** Current input delay in frames */
|
|
59
|
+
get inputDelayFrames(): number {
|
|
60
|
+
return this._inputDelayFrames;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Local client ID */
|
|
64
|
+
get localClientId(): number {
|
|
65
|
+
return this._localClientId;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Initialize the input buffer with local client ID and delay.
|
|
70
|
+
*/
|
|
71
|
+
initialize(localClientId: number, inputDelayFrames: number = 0): void {
|
|
72
|
+
this._localClientId = localClientId;
|
|
73
|
+
this._inputDelayFrames = Math.min(inputDelayFrames, MAX_INPUT_DELAY_FRAMES);
|
|
74
|
+
this.clients.clear();
|
|
75
|
+
this.delayQueue.length = 0;
|
|
76
|
+
|
|
77
|
+
// Register local client
|
|
78
|
+
this.registerClient(localClientId, true);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Update the local client ID.
|
|
83
|
+
* Called when MODE command assigns our client number.
|
|
84
|
+
*/
|
|
85
|
+
updateLocalClientId(newClientId: number): void {
|
|
86
|
+
const oldId = this._localClientId;
|
|
87
|
+
if (oldId === newClientId) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Move client state from old ID to new ID
|
|
92
|
+
const oldState = this.clients.get(oldId);
|
|
93
|
+
if (oldState) {
|
|
94
|
+
this.clients.delete(oldId);
|
|
95
|
+
oldState.clientId = newClientId;
|
|
96
|
+
this.clients.set(newClientId, oldState);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
this._localClientId = newClientId;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Register a client for input tracking.
|
|
104
|
+
*/
|
|
105
|
+
registerClient(clientId: number, isLocal: boolean = false): void {
|
|
106
|
+
if (this.clients.has(clientId)) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this.clients.set(clientId, {
|
|
111
|
+
clientId,
|
|
112
|
+
lastInput: createZeroArray(MAX_INPUT_DEVICES * INPUTS_PER_DEVICE),
|
|
113
|
+
lastRealInputFrame: -1,
|
|
114
|
+
isLocal,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Unregister a client.
|
|
120
|
+
*/
|
|
121
|
+
unregisterClient(clientId: number): void {
|
|
122
|
+
this.clients.delete(clientId);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get all registered client IDs.
|
|
127
|
+
*/
|
|
128
|
+
getClientIds(): number[] {
|
|
129
|
+
return Array.from(this.clients.keys());
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Get remote client IDs (excluding local).
|
|
134
|
+
*/
|
|
135
|
+
getRemoteClientIds(): number[] {
|
|
136
|
+
return Array.from(this.clients.entries())
|
|
137
|
+
.filter(([_, state]) => !state.isLocal)
|
|
138
|
+
.map(([id]) => id);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Queue local input with delay.
|
|
143
|
+
* Returns the frame number this input will be applied to.
|
|
144
|
+
*/
|
|
145
|
+
queueLocalInput(currentFrame: number, input: number[]): number {
|
|
146
|
+
const targetFrame = currentFrame + this._inputDelayFrames;
|
|
147
|
+
|
|
148
|
+
this.delayQueue.push({
|
|
149
|
+
frameNumber: targetFrame,
|
|
150
|
+
input: [...input],
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Update last known input for local client
|
|
154
|
+
const localState = this.clients.get(this._localClientId);
|
|
155
|
+
if (localState) {
|
|
156
|
+
const len = Math.min(input.length, localState.lastInput.length);
|
|
157
|
+
for (let i = 0; i < len; i++) {
|
|
158
|
+
localState.lastInput[i] = input[i];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return targetFrame;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Get local input for a specific frame from the delay queue.
|
|
167
|
+
* Returns null if not yet available.
|
|
168
|
+
*/
|
|
169
|
+
getDelayedLocalInput(frameNumber: number): number[] | null {
|
|
170
|
+
const entry = this.delayQueue.find((e) => e.frameNumber === frameNumber);
|
|
171
|
+
return entry?.input ?? null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Remove processed entries from the delay queue.
|
|
176
|
+
*/
|
|
177
|
+
pruneDelayQueue(upToFrame: number): void {
|
|
178
|
+
while (this.delayQueue.length > 0 && this.delayQueue[0].frameNumber <= upToFrame) {
|
|
179
|
+
this.delayQueue.shift();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Record received input from a remote client.
|
|
185
|
+
*/
|
|
186
|
+
recordRemoteInput(clientId: number, frameNumber: number, input: number[]): void {
|
|
187
|
+
let state = this.clients.get(clientId);
|
|
188
|
+
if (!state) {
|
|
189
|
+
// Auto-register unknown client
|
|
190
|
+
this.registerClient(clientId, false);
|
|
191
|
+
state = this.clients.get(clientId)!;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Update last known input
|
|
195
|
+
const len = Math.min(input.length, state.lastInput.length);
|
|
196
|
+
for (let i = 0; i < len; i++) {
|
|
197
|
+
state.lastInput[i] = input[i];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Update last real input frame
|
|
201
|
+
if (frameNumber > state.lastRealInputFrame) {
|
|
202
|
+
state.lastRealInputFrame = frameNumber;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Get input for a client at a specific frame.
|
|
208
|
+
* If real input isn't available, returns predicted input.
|
|
209
|
+
*/
|
|
210
|
+
getInputForClient(
|
|
211
|
+
clientId: number,
|
|
212
|
+
_frameNumber: number,
|
|
213
|
+
realInputAvailable: boolean
|
|
214
|
+
): { input: number[]; isReal: boolean } {
|
|
215
|
+
const state = this.clients.get(clientId);
|
|
216
|
+
if (!state) {
|
|
217
|
+
// Unknown client - return zeros
|
|
218
|
+
return {
|
|
219
|
+
input: createZeroArray(MAX_INPUT_DEVICES * INPUTS_PER_DEVICE),
|
|
220
|
+
isReal: false,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (realInputAvailable) {
|
|
225
|
+
return {
|
|
226
|
+
input: [...state.lastInput],
|
|
227
|
+
isReal: true,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Predict: repeat last known input
|
|
232
|
+
return {
|
|
233
|
+
input: this.predictInput(state),
|
|
234
|
+
isReal: false,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Predict input for a client based on last known state.
|
|
240
|
+
* Simple strategy: repeat last known input.
|
|
241
|
+
*/
|
|
242
|
+
private predictInput(state: ClientInputState): number[] {
|
|
243
|
+
// Simple prediction: repeat last known input
|
|
244
|
+
return [...state.lastInput];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Get the last frame we received real input for a client.
|
|
249
|
+
*/
|
|
250
|
+
getLastRealInputFrame(clientId: number): number {
|
|
251
|
+
return this.clients.get(clientId)?.lastRealInputFrame ?? -1;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Set input for a specific device.
|
|
256
|
+
* Convenience method for setting joypad + analog values.
|
|
257
|
+
*/
|
|
258
|
+
setDeviceInput(
|
|
259
|
+
clientId: number,
|
|
260
|
+
deviceIndex: number,
|
|
261
|
+
joypad: number,
|
|
262
|
+
analogLeft: number = 0,
|
|
263
|
+
analogRight: number = 0
|
|
264
|
+
): void {
|
|
265
|
+
const state = this.clients.get(clientId);
|
|
266
|
+
if (!state || deviceIndex >= MAX_INPUT_DEVICES) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const base = deviceIndex * INPUTS_PER_DEVICE;
|
|
271
|
+
state.lastInput[base + INPUT_JOYPAD] = joypad;
|
|
272
|
+
state.lastInput[base + INPUT_ANALOG_LEFT] = analogLeft;
|
|
273
|
+
state.lastInput[base + INPUT_ANALOG_RIGHT] = analogRight;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Get input for a specific device.
|
|
278
|
+
*/
|
|
279
|
+
getDeviceInput(
|
|
280
|
+
clientId: number,
|
|
281
|
+
deviceIndex: number
|
|
282
|
+
): { joypad: number; analogLeft: number; analogRight: number } | null {
|
|
283
|
+
const state = this.clients.get(clientId);
|
|
284
|
+
if (!state || deviceIndex >= MAX_INPUT_DEVICES) {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const base = deviceIndex * INPUTS_PER_DEVICE;
|
|
289
|
+
return {
|
|
290
|
+
joypad: state.lastInput[base + INPUT_JOYPAD],
|
|
291
|
+
analogLeft: state.lastInput[base + INPUT_ANALOG_LEFT],
|
|
292
|
+
analogRight: state.lastInput[base + INPUT_ANALOG_RIGHT],
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Merge input from multiple clients into a single input array.
|
|
298
|
+
* Each client controls specific device slots.
|
|
299
|
+
*/
|
|
300
|
+
mergeInputs(
|
|
301
|
+
clientDeviceMap: Map<number, number[]>,
|
|
302
|
+
frameNumber: number,
|
|
303
|
+
realInputMap: Map<number, boolean>
|
|
304
|
+
): { merged: number[]; allReal: boolean } {
|
|
305
|
+
const merged = createZeroArray(MAX_INPUT_DEVICES * INPUTS_PER_DEVICE);
|
|
306
|
+
let allReal = true;
|
|
307
|
+
|
|
308
|
+
for (const [clientId, deviceIndices] of clientDeviceMap) {
|
|
309
|
+
const isReal = realInputMap.get(clientId) ?? false;
|
|
310
|
+
const { input } = this.getInputForClient(clientId, frameNumber, isReal);
|
|
311
|
+
|
|
312
|
+
if (!isReal) {
|
|
313
|
+
allReal = false;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
for (const deviceIndex of deviceIndices) {
|
|
317
|
+
if (deviceIndex >= MAX_INPUT_DEVICES) {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const srcBase = 0; // Assuming client's first device maps to their assigned slot
|
|
322
|
+
const dstBase = deviceIndex * INPUTS_PER_DEVICE;
|
|
323
|
+
|
|
324
|
+
merged[dstBase + INPUT_JOYPAD] = input[srcBase + INPUT_JOYPAD];
|
|
325
|
+
merged[dstBase + INPUT_ANALOG_LEFT] = input[srcBase + INPUT_ANALOG_LEFT];
|
|
326
|
+
merged[dstBase + INPUT_ANALOG_RIGHT] = input[srcBase + INPUT_ANALOG_RIGHT];
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return { merged, allReal };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Clear all client state and queues.
|
|
335
|
+
*/
|
|
336
|
+
clear(): void {
|
|
337
|
+
this.clients.clear();
|
|
338
|
+
this.delayQueue.length = 0;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Create a new input buffer.
|
|
344
|
+
*/
|
|
345
|
+
export const createInputBuffer = (): InputBuffer => {
|
|
346
|
+
return new InputBuffer();
|
|
347
|
+
};
|