retroemu 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 (51) hide show
  1. package/README.md +372 -0
  2. package/bin/cli.js +199 -0
  3. package/cores/atari800_libretro.js +2 -0
  4. package/cores/atari800_libretro.wasm +0 -0
  5. package/cores/beetle_pce_fast_libretro.js +2 -0
  6. package/cores/beetle_pce_fast_libretro.wasm +0 -0
  7. package/cores/fceumm_libretro.js +2 -0
  8. package/cores/fceumm_libretro.wasm +0 -0
  9. package/cores/fmsx_libretro.js +2 -0
  10. package/cores/fmsx_libretro.wasm +0 -0
  11. package/cores/fuse_libretro.js +2 -0
  12. package/cores/fuse_libretro.wasm +0 -0
  13. package/cores/gambatte_libretro.js +2 -0
  14. package/cores/gambatte_libretro.wasm +0 -0
  15. package/cores/gearcoleco_libretro.js +2 -0
  16. package/cores/gearcoleco_libretro.wasm +0 -0
  17. package/cores/genesis_plus_gx_libretro.js +2 -0
  18. package/cores/genesis_plus_gx_libretro.wasm +0 -0
  19. package/cores/handy_libretro.js +2 -0
  20. package/cores/handy_libretro.wasm +0 -0
  21. package/cores/mednafen_ngp_libretro.js +2 -0
  22. package/cores/mednafen_ngp_libretro.wasm +0 -0
  23. package/cores/mednafen_wswan_libretro.js +2 -0
  24. package/cores/mednafen_wswan_libretro.wasm +0 -0
  25. package/cores/mgba_libretro.js +2 -0
  26. package/cores/mgba_libretro.wasm +0 -0
  27. package/cores/pcsx_rearmed_libretro.js +2 -0
  28. package/cores/pcsx_rearmed_libretro.wasm +0 -0
  29. package/cores/prosystem_libretro.js +2 -0
  30. package/cores/prosystem_libretro.wasm +0 -0
  31. package/cores/snes9x2010_libretro.js +2 -0
  32. package/cores/snes9x2010_libretro.wasm +0 -0
  33. package/cores/snes9x_libretro.js +2 -0
  34. package/cores/snes9x_libretro.wasm +0 -0
  35. package/cores/stella2014_libretro.js +2 -0
  36. package/cores/stella2014_libretro.wasm +0 -0
  37. package/cores/vecx_libretro.js +2 -0
  38. package/cores/vecx_libretro.wasm +0 -0
  39. package/index.js +8 -0
  40. package/package.json +52 -0
  41. package/src/audio/AudioBridge.js +61 -0
  42. package/src/constants/libretro.js +97 -0
  43. package/src/core/CoreLoader.js +42 -0
  44. package/src/core/LibretroHost.js +542 -0
  45. package/src/core/RomLoader.js +96 -0
  46. package/src/core/SaveManager.js +115 -0
  47. package/src/core/SystemDetector.js +122 -0
  48. package/src/input/InputManager.js +222 -0
  49. package/src/input/InputMap.js +47 -0
  50. package/src/video/VideoOutput.js +207 -0
  51. package/src/video/videoWorker.js +164 -0
@@ -0,0 +1,207 @@
1
+ import { Worker } from 'worker_threads';
2
+ import { fileURLToPath } from 'url';
3
+ import { dirname, join } from 'path';
4
+ import {
5
+ RETRO_PIXEL_FORMAT_0RGB1555,
6
+ RETRO_PIXEL_FORMAT_XRGB8888,
7
+ RETRO_PIXEL_FORMAT_RGB565,
8
+ } from '../constants/libretro.js';
9
+
10
+ // Pre-computed lookup tables for RGB565 → RGB8 conversion
11
+ const RGB5_TO_8 = new Uint8Array(32);
12
+ const RGB6_TO_8 = new Uint8Array(64);
13
+ for (let i = 0; i < 32; i++) RGB5_TO_8[i] = (i * 255 / 31 + 0.5) | 0;
14
+ for (let i = 0; i < 64; i++) RGB6_TO_8[i] = (i * 255 / 63 + 0.5) | 0;
15
+
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = dirname(__filename);
18
+
19
+ export class VideoOutput {
20
+ constructor() {
21
+ this.worker = null;
22
+ this.workerReady = false;
23
+ this.frameCount = 0;
24
+ this.renderEveryN = 2; // Render every Nth frame to terminal
25
+ this.rgbaBuffer = null;
26
+ this.pendingFrame = false;
27
+ this.displayAspectRatio = 4 / 3; // Default to 4:3, can be set by core
28
+ this.contrast = 1.0; // 1.0 = no change, >1 = more contrast
29
+ this.renderMode = 'detailed'; // 'detailed' or 'fast' (half blocks)
30
+ }
31
+
32
+ async init() {
33
+ return new Promise((resolve, reject) => {
34
+ this.worker = new Worker(join(__dirname, 'videoWorker.js'));
35
+
36
+ this.worker.on('message', (msg) => {
37
+ if (msg.type === 'ready') {
38
+ this.workerReady = true;
39
+ resolve();
40
+ } else if (msg.type === 'frame') {
41
+ this.pendingFrame = false;
42
+ process.stdout.write(`\x1b[H${msg.ansi}`);
43
+ } else if (msg.type === 'error') {
44
+ if (!this.workerReady) {
45
+ reject(new Error(msg.message));
46
+ } else {
47
+ console.error('Video worker error:', msg.message);
48
+ }
49
+ }
50
+ });
51
+
52
+ this.worker.on('error', (err) => {
53
+ if (!this.workerReady) {
54
+ reject(err);
55
+ } else {
56
+ console.error('Video worker error:', err.message);
57
+ }
58
+ });
59
+ });
60
+ }
61
+
62
+ setFrameSkip(n) {
63
+ this.renderEveryN = Math.max(1, n | 0);
64
+ }
65
+
66
+ setAspectRatio(ratio) {
67
+ this.displayAspectRatio = ratio > 0 ? ratio : 4 / 3;
68
+ }
69
+
70
+ setContrast(value) {
71
+ this.contrast = Math.max(0.5, Math.min(3.0, value));
72
+ }
73
+
74
+ setRenderMode(mode) {
75
+ const validModes = ['fast', 'ascii', 'braille', 'braille-dither'];
76
+ if (validModes.includes(mode)) {
77
+ this.renderMode = mode;
78
+ } else {
79
+ this.renderMode = 'detailed';
80
+ }
81
+ }
82
+
83
+ onFrame(wasmModule, dataPtr, width, height, pitch, pixelFormat) {
84
+ this.frameCount++;
85
+
86
+ if (this.frameCount % this.renderEveryN !== 0) return;
87
+ if (this.pendingFrame || !this.workerReady) return;
88
+
89
+ // Convert to RGBA on main thread (known working)
90
+ const rgbaData = this._convertToRGBA(wasmModule, dataPtr, width, height, pitch, pixelFormat);
91
+
92
+ const termCols = process.stdout.columns || 80;
93
+ const termRows = (process.stdout.rows || 24) - 4;
94
+
95
+ // Calculate dimensions that preserve display aspect ratio (4:3 for most retro consoles)
96
+ // Terminal chars are ~2:1 (height:width), so multiply width by 2
97
+ const sourceAspect = this.displayAspectRatio;
98
+ const termCharAspect = 2.0;
99
+
100
+ let usedCols, usedRows;
101
+ const rowsNeededForWidth = termCols / (sourceAspect * termCharAspect);
102
+
103
+ if (rowsNeededForWidth <= termRows) {
104
+ // Width-constrained: use full width, calculate height
105
+ usedCols = termCols;
106
+ usedRows = Math.floor(rowsNeededForWidth);
107
+ } else {
108
+ // Height-constrained: use full height, calculate width
109
+ usedRows = termRows;
110
+ usedCols = Math.floor(termRows * sourceAspect * termCharAspect);
111
+ }
112
+
113
+ this.pendingFrame = true;
114
+ this.worker.postMessage({
115
+ type: 'render',
116
+ rgbaData: rgbaData.buffer,
117
+ width,
118
+ height,
119
+ termCols: usedCols,
120
+ termRows: usedRows,
121
+ contrast: this.contrast,
122
+ renderMode: this.renderMode
123
+ }, [rgbaData.buffer]);
124
+
125
+ this.rgbaBuffer = null; // Need new buffer since we transferred
126
+ }
127
+
128
+ _convertToRGBA(wasmModule, dataPtr, width, height, pitch, pixelFormat) {
129
+ const totalPixels = width * height;
130
+
131
+ if (!this.rgbaBuffer || this.rgbaBuffer.length !== totalPixels * 4) {
132
+ this.rgbaBuffer = new Uint8ClampedArray(totalPixels * 4);
133
+ }
134
+
135
+ const rgba = this.rgbaBuffer;
136
+
137
+ switch (pixelFormat) {
138
+ case RETRO_PIXEL_FORMAT_XRGB8888:
139
+ this._convertXRGB8888(wasmModule, dataPtr, width, height, pitch, rgba);
140
+ break;
141
+ case RETRO_PIXEL_FORMAT_RGB565:
142
+ this._convertRGB565(wasmModule, dataPtr, width, height, pitch, rgba);
143
+ break;
144
+ case RETRO_PIXEL_FORMAT_0RGB1555:
145
+ this._convert0RGB1555(wasmModule, dataPtr, width, height, pitch, rgba);
146
+ break;
147
+ }
148
+
149
+ return rgba;
150
+ }
151
+
152
+ _convertXRGB8888(mod, dataPtr, width, height, pitch, rgba) {
153
+ for (let y = 0; y < height; y++) {
154
+ const srcRowByteOffset = dataPtr + y * pitch;
155
+ const dstRowOffset = y * width * 4;
156
+
157
+ for (let x = 0; x < width; x++) {
158
+ const pixel = mod.HEAPU32[(srcRowByteOffset >> 2) + x];
159
+ const dst = dstRowOffset + x * 4;
160
+ rgba[dst] = (pixel >> 16) & 0xFF;
161
+ rgba[dst + 1] = (pixel >> 8) & 0xFF;
162
+ rgba[dst + 2] = pixel & 0xFF;
163
+ rgba[dst + 3] = 255;
164
+ }
165
+ }
166
+ }
167
+
168
+ _convertRGB565(mod, dataPtr, width, height, pitch, rgba) {
169
+ for (let y = 0; y < height; y++) {
170
+ const srcRowByteOffset = dataPtr + y * pitch;
171
+ const dstRowOffset = y * width * 4;
172
+
173
+ for (let x = 0; x < width; x++) {
174
+ const pixel = mod.HEAPU16[(srcRowByteOffset >> 1) + x];
175
+ const dst = dstRowOffset + x * 4;
176
+ rgba[dst] = RGB5_TO_8[(pixel >> 11) & 0x1F];
177
+ rgba[dst + 1] = RGB6_TO_8[(pixel >> 5) & 0x3F];
178
+ rgba[dst + 2] = RGB5_TO_8[pixel & 0x1F];
179
+ rgba[dst + 3] = 255;
180
+ }
181
+ }
182
+ }
183
+
184
+ _convert0RGB1555(mod, dataPtr, width, height, pitch, rgba) {
185
+ for (let y = 0; y < height; y++) {
186
+ const srcRowByteOffset = dataPtr + y * pitch;
187
+ const dstRowOffset = y * width * 4;
188
+
189
+ for (let x = 0; x < width; x++) {
190
+ const pixel = mod.HEAPU16[(srcRowByteOffset >> 1) + x];
191
+ const dst = dstRowOffset + x * 4;
192
+ rgba[dst] = RGB5_TO_8[(pixel >> 10) & 0x1F];
193
+ rgba[dst + 1] = RGB5_TO_8[(pixel >> 5) & 0x1F];
194
+ rgba[dst + 2] = RGB5_TO_8[pixel & 0x1F];
195
+ rgba[dst + 3] = 255;
196
+ }
197
+ }
198
+ }
199
+
200
+ destroy() {
201
+ if (this.worker) {
202
+ this.worker.terminate();
203
+ this.worker = null;
204
+ }
205
+ this.workerReady = false;
206
+ }
207
+ }
@@ -0,0 +1,164 @@
1
+ import { parentPort } from 'worker_threads';
2
+
3
+ // Chafa constants
4
+ const CHAFA_CANVAS_MODE_TRUECOLOR = 0;
5
+ const CHAFA_SYMBOL_TAG_SPACE = 0x1;
6
+ const CHAFA_SYMBOL_TAG_BLOCK = 0x8;
7
+ const CHAFA_SYMBOL_TAG_BORDER = 0x10;
8
+ const CHAFA_SYMBOL_TAG_VHALF = 0x200; // Vertical half blocks (▀▄) - fastest mode
9
+ const CHAFA_SYMBOL_TAG_BRAILLE = 0x800; // Braille characters
10
+ const CHAFA_SYMBOL_TAG_ASCII = 0x4000; // ASCII characters
11
+
12
+ const CHAFA_CANVAS_MODE_FGBG = 5; // 2-color mode (foreground/background)
13
+ const CHAFA_DITHER_MODE_NONE = 0;
14
+ const CHAFA_DITHER_MODE_DIFFUSION = 2; // Floyd-Steinberg dithering
15
+
16
+ let chafa = null;
17
+ let canvasConfig = 0;
18
+ let symbolMap = 0;
19
+ let canvas = 0;
20
+ let lastWidth = 0;
21
+ let lastHeight = 0;
22
+ let lastRenderMode = 'detailed';
23
+ let currentCanvasMode = CHAFA_CANVAS_MODE_TRUECOLOR;
24
+ let currentDitherMode = CHAFA_DITHER_MODE_NONE;
25
+
26
+ function applyContrast(rgbaData, contrast) {
27
+ if (contrast === 1.0) return rgbaData;
28
+
29
+ const result = new Uint8ClampedArray(rgbaData.length);
30
+ for (let i = 0; i < rgbaData.length; i += 4) {
31
+ // Apply contrast to RGB, leave alpha unchanged
32
+ result[i] = Math.min(255, Math.max(0, ((rgbaData[i] - 128) * contrast) + 128));
33
+ result[i + 1] = Math.min(255, Math.max(0, ((rgbaData[i + 1] - 128) * contrast) + 128));
34
+ result[i + 2] = Math.min(255, Math.max(0, ((rgbaData[i + 2] - 128) * contrast) + 128));
35
+ result[i + 3] = rgbaData[i + 3];
36
+ }
37
+ return result;
38
+ }
39
+
40
+ function createSymbolMap(mode) {
41
+ if (symbolMap) {
42
+ chafa._chafa_symbol_map_unref(symbolMap);
43
+ }
44
+ symbolMap = chafa._chafa_symbol_map_new();
45
+
46
+ // Reset to defaults
47
+ currentCanvasMode = CHAFA_CANVAS_MODE_TRUECOLOR;
48
+ currentDitherMode = CHAFA_DITHER_MODE_NONE;
49
+
50
+ if (mode === 'fast') {
51
+ // Use only vertical half blocks - fastest possible rendering
52
+ chafa._chafa_symbol_map_add_by_tags(symbolMap, CHAFA_SYMBOL_TAG_SPACE | CHAFA_SYMBOL_TAG_VHALF);
53
+ } else if (mode === 'ascii') {
54
+ // ASCII characters only
55
+ chafa._chafa_symbol_map_add_by_tags(symbolMap, CHAFA_SYMBOL_TAG_SPACE | CHAFA_SYMBOL_TAG_ASCII);
56
+ } else if (mode === 'braille') {
57
+ // Braille characters, black and white
58
+ chafa._chafa_symbol_map_add_by_tags(symbolMap, CHAFA_SYMBOL_TAG_BRAILLE);
59
+ currentCanvasMode = CHAFA_CANVAS_MODE_FGBG;
60
+ } else if (mode === 'braille-dither') {
61
+ // Braille characters with dithering for better grayscale
62
+ chafa._chafa_symbol_map_add_by_tags(symbolMap, CHAFA_SYMBOL_TAG_BRAILLE);
63
+ currentCanvasMode = CHAFA_CANVAS_MODE_FGBG;
64
+ currentDitherMode = CHAFA_DITHER_MODE_DIFFUSION;
65
+ } else {
66
+ // Detailed mode - block symbols for better quality
67
+ chafa._chafa_symbol_map_add_by_tags(symbolMap, CHAFA_SYMBOL_TAG_SPACE | CHAFA_SYMBOL_TAG_BLOCK | CHAFA_SYMBOL_TAG_BORDER);
68
+ }
69
+ }
70
+
71
+ async function initChafa() {
72
+ const chafaModule = await import('chafa-wasm');
73
+ chafa = chafaModule.default || chafaModule;
74
+ if (typeof chafa === 'function') {
75
+ chafa = await chafa();
76
+ }
77
+
78
+ createSymbolMap('detailed');
79
+
80
+ parentPort.postMessage({ type: 'ready' });
81
+ }
82
+
83
+ function renderFrame(rgbaData, width, height, termCols, termRows, contrast, renderMode = 'detailed') {
84
+ if (!chafa) return null;
85
+
86
+ // Apply contrast boost if needed
87
+ if (contrast && contrast !== 1.0) {
88
+ rgbaData = applyContrast(rgbaData, contrast);
89
+ }
90
+
91
+ // Recreate symbol map if render mode changed
92
+ if (lastRenderMode !== renderMode) {
93
+ createSymbolMap(renderMode);
94
+ lastRenderMode = renderMode;
95
+ // Force canvas config recreation
96
+ lastWidth = 0;
97
+ lastHeight = 0;
98
+ }
99
+
100
+ // Recreate canvas config if terminal size changed
101
+ if (lastWidth !== termCols || lastHeight !== termRows) {
102
+ if (canvasConfig) {
103
+ chafa._chafa_canvas_config_unref(canvasConfig);
104
+ }
105
+ if (canvas) {
106
+ chafa._chafa_canvas_unref(canvas);
107
+ canvas = 0;
108
+ }
109
+
110
+ canvasConfig = chafa._chafa_canvas_config_new();
111
+ chafa._chafa_canvas_config_set_geometry(canvasConfig, termCols, termRows);
112
+ chafa._chafa_canvas_config_set_canvas_mode(canvasConfig, currentCanvasMode);
113
+ chafa._chafa_canvas_config_set_symbol_map(canvasConfig, symbolMap);
114
+ if (currentDitherMode !== CHAFA_DITHER_MODE_NONE) {
115
+ chafa._chafa_canvas_config_set_dither_mode(canvasConfig, currentDitherMode);
116
+ }
117
+
118
+ lastWidth = termCols;
119
+ lastHeight = termRows;
120
+ }
121
+
122
+ if (!canvas) {
123
+ canvas = chafa._chafa_canvas_new(canvasConfig);
124
+ }
125
+
126
+ // Allocate and copy RGBA data to chafa's heap
127
+ const dataPtr = chafa._malloc(rgbaData.length);
128
+ chafa.HEAPU8.set(rgbaData, dataPtr);
129
+
130
+ // Draw pixels
131
+ chafa._chafa_canvas_set_contents_rgba8(canvas, dataPtr, width, height, width * 4);
132
+ chafa._free(dataPtr);
133
+
134
+ // Build ANSI output
135
+ const gsPtr = chafa._chafa_canvas_build_ansi(canvas);
136
+ if (!gsPtr) return null;
137
+
138
+ const strPtr = chafa._g_string_free_and_steal(gsPtr);
139
+ const ansi = chafa.UTF8ToString(strPtr);
140
+ chafa._free(strPtr);
141
+
142
+ return ansi;
143
+ }
144
+
145
+ parentPort.on('message', (msg) => {
146
+ if (msg.type === 'render') {
147
+ const ansi = renderFrame(
148
+ new Uint8ClampedArray(msg.rgbaData),
149
+ msg.width,
150
+ msg.height,
151
+ msg.termCols,
152
+ msg.termRows,
153
+ msg.contrast || 1.0,
154
+ msg.renderMode || 'detailed'
155
+ );
156
+ if (ansi) {
157
+ parentPort.postMessage({ type: 'frame', ansi });
158
+ }
159
+ }
160
+ });
161
+
162
+ initChafa().catch(err => {
163
+ parentPort.postMessage({ type: 'error', message: err.message });
164
+ });